diff --git a/.coveragerc36 b/.coveragerc36 new file mode 100644 index 0000000000..e0c19bb634 --- /dev/null +++ b/.coveragerc36 @@ -0,0 +1,14 @@ +# This is the coverage.py config for Python 3.6 +# The config for newer Python versions is in pyproject.toml. + +[run] +branch = true +omit = + /tmp/* + */tests/* + */.venv/* + + +[report] +exclude_lines = + if TYPE_CHECKING: diff --git a/.craft.yml b/.craft.yml index 43bbfdd7bd..665f06834a 100644 --- a/.craft.yml +++ b/.craft.yml @@ -8,20 +8,25 @@ targets: pypi:sentry-sdk: - name: github - name: aws-lambda-layer - includeNames: /^sentry-python-serverless-\d+(\.\d+)*\.zip$/ + # This regex that matches the version is taken from craft: + # https://github.com/getsentry/craft/blob/8d77c38ddbe4be59f98f61b6e42952ca087d3acd/src/utils/version.ts#L11 + includeNames: /^sentry-python-serverless-\bv?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-?([\da-z-]+(?:\.[\da-z-]+)*))?(?:\+([\da-z-]+(?:\.[\da-z-]+)*))?\b.zip$/ layerName: SentryPythonServerlessSDK compatibleRuntimes: - name: python versions: # The number of versions must be, at most, the maximum number of - # runtimes AWS Lambda permits for a layer. + # runtimes AWS Lambda permits for a layer (currently 15). # On the other hand, AWS Lambda does not support every Python runtime. # The supported runtimes are available in the following link: # https://docs.aws.amazon.com/lambda/latest/dg/lambda-python.html - - python3.6 - python3.7 - python3.8 - python3.9 + - python3.10 + - python3.11 + - python3.12 + - python3.13 license: MIT - name: sentry-pypi internalPypiRepo: getsentry/pypi diff --git a/.cursor/rules/core-architecture.mdc b/.cursor/rules/core-architecture.mdc new file mode 100644 index 0000000000..0af65f4815 --- /dev/null +++ b/.cursor/rules/core-architecture.mdc @@ -0,0 +1,122 @@ +--- +description: +globs: +alwaysApply: false +--- +# Core Architecture + +## Scope and Client Pattern + +The Sentry SDK uses a **Scope and Client** pattern for managing state and context: + +### Scope +- [sentry_sdk/scope.py](mdc:sentry_sdk/scope.py) - Holds contextual data +- Holds a reference to the Client +- Contains tags, extra data, user info, breadcrumbs +- Thread-local storage for isolation + +### Client +- [sentry_sdk/client.py](mdc:sentry_sdk/client.py) - Handles event processing +- Manages transport and event serialization +- Applies sampling and filtering + +## Key Components + +### API Layer +- [sentry_sdk/api.py](mdc:sentry_sdk/api.py) - Public API functions +- `init()` - Initialize the SDK +- `capture_exception()` - Capture exceptions +- `capture_message()` - Capture custom messages +- `set_tag()`, `set_user()`, `set_context()` - Add context +- `start_transaction()` - Performance monitoring + +### Transport +- [sentry_sdk/transport.py](mdc:sentry_sdk/transport.py) - Event delivery +- `HttpTransport` - HTTP transport to Sentry servers +- Handles retries, rate limiting, and queuing + +### Integrations System +- [sentry_sdk/integrations/__init__.py](mdc:sentry_sdk/integrations/__init__.py) - Integration registry +- Base `Integration` class for all integrations +- Automatic setup and teardown +- Integration-specific configuration + +## Data Flow + +### Event Capture Flow +1. **Exception occurs** or **manual capture** called +2. **get_current_scope** gets the active current scope +2. **get_isolation_scope** gets the active isolation scope +3. **Scope data** (tags, user, context) is attached +4. **Client.process_event()** processes the event +5. **Sampling** and **filtering** applied +6. **Transport** sends to Sentry servers + +### Performance Monitoring Flow +1. **Transaction started** with `start_transaction()` +2. **Spans** created for operations within transaction with `start_span()` +3. **Timing data** collected automatically +4. **Transaction finished** and sent to Sentry + +## Context Management + +### Scope Stack +- **Global scope**: Default scope for the process +- **Isolation scope**: Isolated scope for specific operations, manages concurrency isolation +- **Current scope**: Active scope for current execution context + +### Scope Operations +- `configure_scope()` - Modify current scope +- `new_scope()` - Create isolated scope + +## Integration Architecture + +### Integration Lifecycle +1. **Registration**: Integration registered during `init()` +2. **Setup**: `setup_once()` called to install hooks +3. **Runtime**: Integration monitors and captures events +4. **Teardown**: Integration cleaned up on shutdown + +### Common Integration Patterns +- **Monkey patching**: Replace functions/methods with instrumented versions +- **Signal handlers**: Hook into framework signals/events +- **Middleware**: Add middleware to web frameworks +- **Exception handlers**: Catch and process exceptions + +### Integration Configuration +```python +# Example integration setup +sentry_sdk.init( + dsn="your-dsn", + integrations=[ + DjangoIntegration(), + CeleryIntegration(), + RedisIntegration(), + ], + traces_sample_rate=1.0, +) +``` + +## Error Handling + +### Exception Processing +- **Automatic capture**: Unhandled exceptions captured automatically +- **Manual capture**: Use `capture_exception()` for handled exceptions +- **Context preservation**: Stack traces, local variables, and context preserved + +### Breadcrumbs +- **Automatic breadcrumbs**: Framework operations logged automatically +- **Manual breadcrumbs**: Use `add_breadcrumb()` for custom events +- **Breadcrumb categories**: HTTP, database, navigation, etc. + +## Performance Monitoring + +### Transaction Tracking +- **Automatic transactions**: Web requests, background tasks +- **Custom transactions**: Use `start_transaction()` for custom operations +- **Span tracking**: Database queries, HTTP requests, custom operations +- **Performance data**: Timing, resource usage, custom measurements + +### Sampling +- **Transaction sampling**: Control percentage of transactions captured +- **Dynamic sampling**: Adjust sampling based on context diff --git a/.cursor/rules/integrations-guide.mdc b/.cursor/rules/integrations-guide.mdc new file mode 100644 index 0000000000..6785b1522d --- /dev/null +++ b/.cursor/rules/integrations-guide.mdc @@ -0,0 +1,158 @@ +--- +description: +globs: +alwaysApply: false +--- +# Integrations Guide + +## Integration Categories + +The Sentry Python SDK includes integrations for popular frameworks, libraries, and services: + +### Web Frameworks +- [sentry_sdk/integrations/django/](mdc:sentry_sdk/integrations/django) - Django web framework +- [sentry_sdk/integrations/flask/](mdc:sentry_sdk/integrations/flask) - Flask microframework +- [sentry_sdk/integrations/fastapi/](mdc:sentry_sdk/integrations/fastapi) - FastAPI framework +- [sentry_sdk/integrations/starlette/](mdc:sentry_sdk/integrations/starlette) - Starlette ASGI framework +- [sentry_sdk/integrations/sanic/](mdc:sentry_sdk/integrations/sanic) - Sanic async framework +- [sentry_sdk/integrations/tornado/](mdc:sentry_sdk/integrations/tornado) - Tornado web framework +- [sentry_sdk/integrations/pyramid/](mdc:sentry_sdk/integrations/pyramid) - Pyramid framework +- [sentry_sdk/integrations/bottle/](mdc:sentry_sdk/integrations/bottle) - Bottle microframework +- [sentry_sdk/integrations/chalice/](mdc:sentry_sdk/integrations/chalice) - AWS Chalice +- [sentry_sdk/integrations/quart/](mdc:sentry_sdk/integrations/quart) - Quart async framework +- [sentry_sdk/integrations/falcon/](mdc:sentry_sdk/integrations/falcon) - Falcon framework +- [sentry_sdk/integrations/litestar/](mdc:sentry_sdk/integrations/litestar) - Litestar framework +- [sentry_sdk/integrations/starlite/](mdc:sentry_sdk/integrations/starlite) - Starlite framework + +### Task Queues and Background Jobs +- [sentry_sdk/integrations/celery/](mdc:sentry_sdk/integrations/celery) - Celery task queue +- [sentry_sdk/integrations/rq/](mdc:sentry_sdk/integrations/rq) - Redis Queue +- [sentry_sdk/integrations/huey/](mdc:sentry_sdk/integrations/huey) - Huey task queue +- [sentry_sdk/integrations/arq/](mdc:sentry_sdk/integrations/arq) - Arq async task queue +- [sentry_sdk/integrations/dramatiq/](mdc:sentry_sdk/integrations/dramatiq) - Dramatiq task queue + +### Databases and Data Stores +- [sentry_sdk/integrations/sqlalchemy/](mdc:sentry_sdk/integrations/sqlalchemy) - SQLAlchemy ORM +- [sentry_sdk/integrations/asyncpg/](mdc:sentry_sdk/integrations/asyncpg) - AsyncPG PostgreSQL +- [sentry_sdk/integrations/pymongo/](mdc:sentry_sdk/integrations/pymongo) - PyMongo MongoDB +- [sentry_sdk/integrations/redis/](mdc:sentry_sdk/integrations/redis) - Redis client +- [sentry_sdk/integrations/clickhouse_driver/](mdc:sentry_sdk/integrations/clickhouse_driver) - ClickHouse driver + +### Cloud and Serverless +- [sentry_sdk/integrations/aws_lambda/](mdc:sentry_sdk/integrations/aws_lambda) - AWS Lambda +- [sentry_sdk/integrations/gcp/](mdc:sentry_sdk/integrations/gcp) - Google Cloud Platform +- [sentry_sdk/integrations/serverless/](mdc:sentry_sdk/integrations/serverless) - Serverless framework + +### HTTP and Networking +- [sentry_sdk/integrations/requests/](mdc:sentry_sdk/integrations/requests) - Requests HTTP library +- [sentry_sdk/integrations/httpx/](mdc:sentry_sdk/integrations/httpx) - HTTPX async HTTP client +- [sentry_sdk/integrations/aiohttp/](mdc:sentry_sdk/integrations/aiohttp) - aiohttp async HTTP +- [sentry_sdk/integrations/grpc/](mdc:sentry_sdk/integrations/grpc) - gRPC framework + +### AI and Machine Learning +- [sentry_sdk/integrations/openai/](mdc:sentry_sdk/integrations/openai) - OpenAI API +- [sentry_sdk/integrations/anthropic/](mdc:sentry_sdk/integrations/anthropic) - Anthropic Claude +- [sentry_sdk/integrations/cohere/](mdc:sentry_sdk/integrations/cohere) - Cohere AI +- [sentry_sdk/integrations/huggingface_hub/](mdc:sentry_sdk/integrations/huggingface_hub) - Hugging Face Hub +- [sentry_sdk/integrations/langchain/](mdc:sentry_sdk/integrations/langchain) - LangChain framework + +### GraphQL +- [sentry_sdk/integrations/graphene/](mdc:sentry_sdk/integrations/graphene) - Graphene GraphQL +- [sentry_sdk/integrations/ariadne/](mdc:sentry_sdk/integrations/ariadne) - Ariadne GraphQL +- [sentry_sdk/integrations/strawberry/](mdc:sentry_sdk/integrations/strawberry) - Strawberry GraphQL +- [sentry_sdk/integrations/gql/](mdc:sentry_sdk/integrations/gql) - GQL GraphQL client + +### Feature Flags and Configuration +- [sentry_sdk/integrations/launchdarkly/](mdc:sentry_sdk/integrations/launchdarkly) - LaunchDarkly +- [sentry_sdk/integrations/unleash/](mdc:sentry_sdk/integrations/unleash) - Unleash +- [sentry_sdk/integrations/statsig/](mdc:sentry_sdk/integrations/statsig) - Statsig +- [sentry_sdk/integrations/openfeature/](mdc:sentry_sdk/integrations/openfeature) - OpenFeature + +### Other Integrations +- [sentry_sdk/integrations/logging/](mdc:sentry_sdk/integrations/logging) - Python logging +- [sentry_sdk/integrations/loguru/](mdc:sentry_sdk/integrations/loguru) - Loguru logging +- [sentry_sdk/integrations/opentelemetry/](mdc:sentry_sdk/integrations/opentelemetry) - OpenTelemetry +- [sentry_sdk/integrations/ray/](mdc:sentry_sdk/integrations/ray) - Ray distributed computing +- [sentry_sdk/integrations/spark/](mdc:sentry_sdk/integrations/spark) - Apache Spark +- [sentry_sdk/integrations/beam/](mdc:sentry_sdk/integrations/beam) - Apache Beam + +## Integration Usage + +### Basic Integration Setup +```python +import sentry_sdk +from sentry_sdk.integrations.django import DjangoIntegration +from sentry_sdk.integrations.celery import CeleryIntegration + +sentry_sdk.init( + dsn="your-dsn", + integrations=[ + DjangoIntegration(), + CeleryIntegration(), + ], + traces_sample_rate=1.0, +) +``` + +### Integration Configuration +Most integrations accept configuration parameters: +```python +from sentry_sdk.integrations.django import DjangoIntegration +from sentry_sdk.integrations.redis import RedisIntegration + +sentry_sdk.init( + dsn="your-dsn", + integrations=[ + DjangoIntegration( + transaction_style="url", # Customize transaction naming + ), + RedisIntegration( + cache_prefixes=["myapp:"], # Filter cache operations + ), + ], +) +``` + +### Integration Testing +Each integration has corresponding tests in [tests/integrations/](mdc:tests/integrations): +- [tests/integrations/django/](mdc:tests/integrations/django) - Django integration tests +- [tests/integrations/flask/](mdc:tests/integrations/flask) - Flask integration tests +- [tests/integrations/celery/](mdc:tests/integrations/celery) - Celery integration tests + +## Integration Development + +### Creating New Integrations +1. **Create integration file** in [sentry_sdk/integrations/](mdc:sentry_sdk/integrations) +2. **Inherit from Integration base class** +3. **Implement setup_once() method** +4. **Add to integration registry** + +### Integration Base Class +```python +from sentry_sdk.integrations import Integration + +class MyIntegration(Integration): + identifier = "my_integration" + + def __init__(self, param=None): + self.param = param + + @staticmethod + def setup_once(): + # Install hooks, monkey patches, etc. + pass +``` + +### Common Integration Patterns +- **Monkey patching**: Replace functions with instrumented versions +- **Middleware**: Add middleware to web frameworks +- **Signal handlers**: Hook into framework signals +- **Exception handlers**: Catch and process exceptions +- **Context managers**: Add context to operations + +### Integration Best Practices +- **Zero configuration**: Work without user setup +- **Check integration status**: Use `sentry_sdk.get_client().get_integration()` +- **No side effects**: Don't alter library behavior +- **Graceful degradation**: Handle missing dependencies +- **Comprehensive testing**: Test all integration features diff --git a/.cursor/rules/project-overview.mdc b/.cursor/rules/project-overview.mdc new file mode 100644 index 0000000000..c8c0cf77b9 --- /dev/null +++ b/.cursor/rules/project-overview.mdc @@ -0,0 +1,47 @@ +--- +description: +globs: +alwaysApply: false +--- +# Sentry Python SDK - Project Overview + +## What is this project? + +The Sentry Python SDK is the official Python SDK for [Sentry](mdc:https://sentry.io), an error monitoring and performance monitoring platform. It helps developers capture errors, exceptions, traces and profiles from Python applications. + +## Key Files and Directories + +### Core SDK +- [sentry_sdk/__init__.py](mdc:sentry_sdk/__init__.py) - Main entry point, exports all public APIs +- [sentry_sdk/api.py](mdc:sentry_sdk/api.py) - Public API functions (init, capture_exception, etc.) +- [sentry_sdk/client.py](mdc:sentry_sdk/client.py) - Core client implementation +- [sentry_sdk/scope.py](mdc:sentry_sdk/scope.py) - Scope holds contextual metadata such as tags that are applied automatically to events and envelopes +- [sentry_sdk/transport.py](mdc:sentry_sdk/transport.py) - HTTP Transport that sends the envelopes to Sentry's servers +- [sentry_sdk/worker.py](mdc:sentry_sdk/worker.py) - Background threaded worker with a queue to manage transport requests +- [sentry_sdk/serializer.py](mdc:sentry_sdk/serializer.py) - Serializes the payload along with truncation logic + +### Integrations +- [sentry_sdk/integrations/](mdc:sentry_sdk/integrations) - Framework and library integrations + - [sentry_sdk/integrations/__init__.py](mdc:sentry_sdk/integrations/__init__.py) - Integration registry + - [sentry_sdk/integrations/django/](mdc:sentry_sdk/integrations/django) - Django framework integration + - [sentry_sdk/integrations/flask/](mdc:sentry_sdk/integrations/flask) - Flask framework integration + - [sentry_sdk/integrations/fastapi/](mdc:sentry_sdk/integrations/fastapi) - FastAPI integration + - [sentry_sdk/integrations/celery/](mdc:sentry_sdk/integrations/celery) - Celery task queue integration + - [sentry_sdk/integrations/aws_lambda/](mdc:sentry_sdk/integrations/aws_lambda) - AWS Lambda integration + +### Configuration and Setup +- [setup.py](mdc:setup.py) - Package configuration and dependencies +- [pyproject.toml](mdc:pyproject.toml) - Modern Python project configuration +- [tox.ini](mdc:tox.ini) - Test matrix configuration for multiple Python versions and integrations +- [requirements-*.txt](mdc:requirements-testing.txt) - Various dependency requirements + +### Documentation and Guides +- [README.md](mdc:README.md) - Project overview and quick start +- [CONTRIBUTING.md](mdc:CONTRIBUTING.md) - Development and contribution guidelines +- [MIGRATION_GUIDE.md](mdc:MIGRATION_GUIDE.md) - Migration from older versions +- [CHANGELOG.md](mdc:CHANGELOG.md) - Version history and changes + +### Testing +- [tests/](mdc:tests) - Comprehensive test suite + - [tests/integrations/](mdc:tests/integrations) - Integration-specific tests + - [tests/conftest.py](mdc:tests/conftest.py) - Pytest configuration and fixtures diff --git a/.cursor/rules/quick-reference.mdc b/.cursor/rules/quick-reference.mdc new file mode 100644 index 0000000000..ef90c22f78 --- /dev/null +++ b/.cursor/rules/quick-reference.mdc @@ -0,0 +1,51 @@ +--- +description: +globs: +alwaysApply: false +--- +# Quick Reference + +## Common Commands + +### Development Setup +```bash +make .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate +``` + +### Testing + +Our test matrix is implemented in [tox](mdc:https://tox.wiki). +The following runs the whole test suite and takes a long time. + +```bash +source .venv/bin/activate +tox +``` + +Prefer testing a single environment instead while developing. + +```bash +tox -e py3.12-common +``` + +For running a single test, use the pattern: + +```bash +tox -e py3.12-common -- project/tests/test_file.py::TestClassName::test_method +``` + +For testing specific integrations, refer to the test matrix in [sentry_sdk/tox.ini](mdc:sentry_sdk/tox.ini) for finding an entry. +For example, to test django, use: + +```bash +tox -e py3.12-django-v5.2.3 +``` + +### Code Quality + +Our `linters` tox environment runs `ruff-format` for formatting, `ruff-check` for linting and `mypy` for type checking. + +```bash +tox -e linters +``` diff --git a/.cursor/rules/testing-guide.mdc b/.cursor/rules/testing-guide.mdc new file mode 100644 index 0000000000..939f1a095b --- /dev/null +++ b/.cursor/rules/testing-guide.mdc @@ -0,0 +1,93 @@ +--- +description: +globs: +alwaysApply: false +--- +# Testing Guide + +## Test Structure + +### Test Organization +- [tests/](mdc:tests) - Main test directory +- [tests/conftest.py](mdc:tests/conftest.py) - Shared pytest fixtures and configuration +- [tests/integrations/](mdc:tests/integrations) - Integration-specific tests +- [tests/tracing/](mdc:tests/tracing) - Performance monitoring tests +- [tests/utils/](mdc:tests/utils) - Utility and helper tests + +### Integration Test Structure +Each integration has its own test directory: +- [tests/integrations/django/](mdc:tests/integrations/django) - Django integration tests +- [tests/integrations/flask/](mdc:tests/integrations/flask) - Flask integration tests +- [tests/integrations/celery/](mdc:tests/integrations/celery) - Celery integration tests +- [tests/integrations/aws_lambda/](mdc:tests/integrations/aws_lambda) - AWS Lambda tests + +## Running Tests + +### Tox Testing Matrix + +The [tox.ini](mdc:tox.ini) file defines comprehensive test environments. +Always run tests via `tox` from the main `.venv`. + +```bash +source .venv/bin/activate + +# Run all tox environments, takes a long time +tox + +# Run specific environment +tox -e py3.11-django-v4.2 + +# Run environments for specific Python version +tox -e py3.11-* + +# Run environments for specific integration +tox -e *-django-* + +# Run a single test +tox -e py3.12-common -- project/tests/test_file.py::TestClassName::test_method +``` + +### Test Environment Categories +- **Common tests**: `{py3.6,py3.7,py3.8,py3.9,py3.10,py3.11,py3.12,py3.13}-common` +- **Integration tests**: `{python_version}-{integration}-v{framework_version}` +- **Gevent tests**: `{py3.6,py3.8,py3.10,py3.11,py3.12}-gevent` + +## Writing Tests + +### Test File Structure +```python +import pytest +import sentry_sdk +from sentry_sdk.integrations.flask import FlaskIntegration + +def test_flask_integration(sentry_init, capture_events): + """Test Flask integration captures exceptions.""" + # Test setup + sentry_init(integrations=[FlaskIntegration()]) + events = capture_events() + + # Test execution + # ... test code ... + + # Assertions + assert len(events) == 1 + assert events[0]["exception"]["values"][0]["type"] == "ValueError" +``` + +### Common Test Patterns + +## Test Best Practices + +### Test Organization +- **One test per function**: Each test should verify one specific behavior +- **Descriptive names**: Use clear, descriptive test function names +- **Arrange-Act-Assert**: Structure tests with setup, execution, and verification +- **Isolation**: Each test should be independent and not affect others +- **No mocking**: Never use mocks in tests +- **Cleanup**: Ensure tests clean up after themselves + +## Fixtures +The most important fixtures for testing are: +- `sentry_init`: Use in the beginning of a test to simulate initializing the SDK +- `capture_events`: Intercept the events for testing event payload +- `capture_envelopes`: Intercept the envelopes for testing envelope headers and payload diff --git a/.flake8 b/.flake8 deleted file mode 100644 index 8610e09241..0000000000 --- a/.flake8 +++ /dev/null @@ -1,21 +0,0 @@ -[flake8] -extend-ignore = - # Handled by black (Whitespace before ':' -- handled by black) - E203, - # Handled by black (Line too long) - E501, - # Sometimes not possible due to execution order (Module level import is not at top of file) - E402, - # I don't care (Do not assign a lambda expression, use a def) - E731, - # does not apply to Python 2 (redundant exception types by flake8-bugbear) - B014, - # I don't care (Lowercase imported as non-lowercase by pep8-naming) - N812, - # is a worse version of and conflicts with B902 (first argument of a classmethod should be named cls) - N804, -extend-exclude=checkouts,lol* -exclude = - # gRCP generated files - grpc_test_service_pb2.py - grpc_test_service_pb2_grpc.py diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000000..8efbe19ec3 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,4 @@ +# Formatting commits to ignore in git blame +afea4a017bf13f78e82f725ea9d6a56a8e02cb34 +23a340a9dca60eea36de456def70c00952a33556 +973dda79311cf6b9cb8f1ba67ca0515dfaf9f49c diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000000..1dc1a4882f --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @getsentry/owners-python-sdk diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index 78f1e03d21..c13d6c4bb0 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -1,5 +1,6 @@ name: 🐞 Bug Report description: Tell us about something that's not working the way we (probably) intend. +labels: ["Python", "Bug"] body: - type: dropdown id: type diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 17d8a34dc5..31f71b14f1 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,4 +1,4 @@ -blank_issues_enabled: false +blank_issues_enabled: true contact_links: - name: Support Request url: https://sentry.io/support diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml index e462e3bae7..64b31873d8 100644 --- a/.github/ISSUE_TEMPLATE/feature.yml +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -1,6 +1,6 @@ name: 💡 Feature Request description: Create a feature request for sentry-python SDK. -labels: 'enhancement' +labels: ["Python", "Feature"] body: - type: markdown attributes: diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000..79f27c30d8 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,14 @@ +### Description + + +#### Issues + + +#### Reminders +- Please add tests to validate your changes, and lint your code using `tox -e linters`. +- Add GH Issue ID _&_ Linear ID (if applicable) +- PR title should use [conventional commit](https://develop.sentry.dev/engineering-practices/commit-messages/#type) style (`feat:`, `fix:`, `ref:`, `meta:`) +- For external contributors: [CONTRIBUTING.md](https://github.com/getsentry/sentry-python/blob/master/CONTRIBUTING.md), [Sentry SDK development docs](https://develop.sentry.dev/sdk/), [Discord community](https://discord.gg/Ww9hbqr) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d375588780..2b91d51cc0 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,27 +9,9 @@ updates: - dependency-type: direct - dependency-type: indirect ignore: - - dependency-name: pytest - versions: - - "> 3.7.3" - - dependency-name: flake8 # Later versions dropped Python 2 support - versions: - - "> 5.0.4" - - dependency-name: jsonschema # Later versions dropped Python 2 support - versions: - - "> 3.2.0" - - dependency-name: pytest-cov - versions: - - "> 2.8.1" - - dependency-name: pytest-forked - versions: - - "> 1.1.3" - dependency-name: sphinx versions: - ">= 2.4.a, < 2.5" - - dependency-name: tox - versions: - - "> 3.7.0" - dependency-name: werkzeug versions: - "> 0.15.5, < 1" diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000000..058bc4d5bb --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,32 @@ +changelog: + exclude: + labels: + - skip-changelog + authors: + - dependabot + categories: + - title: New Features ✨ + labels: + - "Changelog: Feature" + commit_patterns: + - "^feat\\b" + - title: Bug Fixes 🐛 + labels: + - "Changelog: Bugfix" + commit_patterns: + - "^(fix|bugfix)\\b" + - title: Deprecations 🏗️ + labels: + - "Changelog: Deprecation" + commit_patterns: + - "deprecat" # deprecation, deprecated + - title: Documentation 📚 + labels: + - "Changelog: Docs" + commit_patterns: + - "^docs?\\b" + - title: Internal Changes 🔧 + labels: + - "Changelog: Internal" + commit_patterns: + - "^(build|ref|chore|ci|tests?)\\b" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a5fe39478..44fe331b34 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,7 @@ on: branches: - master - release/** + - potel-base pull_request: @@ -23,41 +24,27 @@ jobs: timeout-minutes: 10 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 + - uses: actions/checkout@v6.0.1 + - uses: actions/setup-python@v6 with: - python-version: 3.11 + python-version: 3.14 - run: | pip install tox tox -e linters - check-ci-config: - name: Check CI config - runs-on: ubuntu-latest - timeout-minutes: 10 - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 - with: - python-version: 3.9 - - - run: | - python scripts/split-tox-gh-actions/split-tox-gh-actions.py --fail-on-changes - build_lambda_layer: name: Build Package runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 + - uses: actions/checkout@v6.0.1 + - uses: actions/setup-python@v6 with: - python-version: 3.9 + python-version: 3.12 - name: Setup build cache - uses: actions/cache@v3 + uses: actions/cache@v5 id: build_cache with: path: ${{ env.CACHED_BUILD_PATHS }} @@ -65,15 +52,17 @@ jobs: - name: Build Packages run: | echo "Creating directory containing Python SDK Lambda Layer" - pip install virtualenv # This will also trigger "make dist" that creates the Python packages make aws-lambda-layer - name: Upload Python Packages - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v6 with: - name: ${{ github.sha }} + name: artifact-build_lambda_layer path: | dist/* + if-no-files-found: 'error' + # since this artifact will be merged, compression is not necessary + compression-level: '0' docs: name: Build SDK API Doc @@ -81,17 +70,32 @@ jobs: timeout-minutes: 10 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 + - uses: actions/checkout@v6.0.1 + - uses: actions/setup-python@v6 with: - python-version: 3.11 + python-version: 3.12 - run: | - pip install virtualenv make apidocs cd docs/_build && zip -r gh-pages ./ - - uses: actions/upload-artifact@v3.1.1 + - uses: actions/upload-artifact@v6 + with: + name: artifact-docs + path: | + docs/_build/gh-pages.zip + if-no-files-found: 'error' + # since this artifact will be merged, compression is not necessary + compression-level: '0' + + merge: + name: Create Release Artifact + runs-on: ubuntu-latest + needs: [build_lambda_layer, docs] + steps: + - uses: actions/upload-artifact/merge@v6 with: + # Craft expects release assets from github to be a single artifact named after the sha. name: ${{ github.sha }} - path: docs/_build/gh-pages.zip + pattern: artifact-* + delete-merged: true diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 7c70312103..79af0a3039 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -13,13 +13,19 @@ name: "CodeQL" on: push: - branches: [ master ] + branches: + - master + - potel-base pull_request: - # The branches below must be a subset of the branches above - branches: [ master ] schedule: - cron: '18 18 * * 3' +# Cancel in progress workflows on pull_requests. +# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + permissions: contents: read @@ -42,11 +48,11 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6.0.1 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -57,7 +63,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v2 + uses: github/codeql-action/autobuild@v4 # ℹ️ Command-line programs to run using the OS shell. # 📚 https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions @@ -71,4 +77,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v4 diff --git a/.github/workflows/enforce-license-compliance.yml b/.github/workflows/enforce-license-compliance.yml index b331974711..5517e5347f 100644 --- a/.github/workflows/enforce-license-compliance.yml +++ b/.github/workflows/enforce-license-compliance.yml @@ -2,9 +2,17 @@ name: Enforce License Compliance on: push: - branches: [master, main, release/*] + branches: + - master + - main + - release/* + - potel-base pull_request: - branches: [master, main] + +# Cancel in progress workflows on pull_requests. +# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} jobs: enforce-license-compliance: diff --git a/.github/workflows/release-comment-issues.yml b/.github/workflows/release-comment-issues.yml new file mode 100644 index 0000000000..8870f25bc0 --- /dev/null +++ b/.github/workflows/release-comment-issues.yml @@ -0,0 +1,34 @@ +name: "Automation: Notify issues for release" +on: + release: + types: + - published + workflow_dispatch: + inputs: + version: + description: Which version to notify issues for + required: false + +# This workflow is triggered when a release is published +jobs: + release-comment-issues: + runs-on: ubuntu-20.04 + name: Notify issues + steps: + - name: Get version + id: get_version + env: + INPUTS_VERSION: ${{ github.event.inputs.version }} + RELEASE_TAG_NAME: ${{ github.event.release.tag_name }} + run: echo "version=${$INPUTS_VERSION:-$RELEASE_TAG_NAME}" >> "$GITHUB_OUTPUT" + + - name: Comment on linked issues that are mentioned in release + if: | + steps.get_version.outputs.version != '' + && !contains(steps.get_version.outputs.version, 'a') + && !contains(steps.get_version.outputs.version, 'b') + && !contains(steps.get_version.outputs.version, 'rc') + uses: getsentry/release-comment-issues-gh-action@v1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + version: ${{ steps.get_version.outputs.version }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cda4c8b2a5..a5b89d2734 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,20 +9,30 @@ on: force: description: Force a release even when there are release-blockers (optional) required: false + merge_target: + description: Target branch to merge into. Uses the default branch as a fallback (optional) + required: false jobs: release: runs-on: ubuntu-latest name: "Release a new version" steps: - - uses: actions/checkout@v4 + - name: Get auth token + id: token + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 + with: + app-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }} + private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }} + - uses: actions/checkout@v6.0.1 with: - token: ${{ secrets.GH_RELEASE_PAT }} + token: ${{ steps.token.outputs.token }} fetch-depth: 0 - name: Prepare release uses: getsentry/action-prepare-release@v1 env: - GITHUB_TOKEN: ${{ secrets.GH_RELEASE_PAT }} + GITHUB_TOKEN: ${{ steps.token.outputs.token }} with: version: ${{ github.event.inputs.version }} force: ${{ github.event.inputs.force }} + merge_target: ${{ github.event.inputs.merge_target }} diff --git a/.github/workflows/test-common.yml b/.github/workflows/test-common.yml deleted file mode 100644 index 03117b7db1..0000000000 --- a/.github/workflows/test-common.yml +++ /dev/null @@ -1,115 +0,0 @@ -name: Test common - -on: - push: - branches: - - master - - release/** - - pull_request: - -# Cancel in progress workflows on pull_requests. -# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - -env: - BUILD_CACHE_KEY: ${{ github.sha }} - CACHED_BUILD_PATHS: | - ${{ github.workspace }}/dist-serverless - -jobs: - test: - name: common, python ${{ matrix.python-version }}, ${{ matrix.os }} - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - - strategy: - fail-fast: false - matrix: - python-version: ["3.5","3.6","3.7","3.8","3.9","3.10","3.11"] - # python3.6 reached EOL and is no longer being supported on - # new versions of hosted runners on Github Actions - # ubuntu-20.04 is the last version that supported python3.6 - # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 - os: [ubuntu-20.04] - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - - - name: Test common - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py${{ matrix.python-version }}-common" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - - uses: codecov/codecov-action@v3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: coverage.xml - - test-py27: - name: common, python 2.7, ubuntu-20.04 - runs-on: ubuntu-20.04 - container: python:2.7 - timeout-minutes: 30 - - steps: - - uses: actions/checkout@v4 - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - - - name: Test common - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py2.7-common" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - check_required_tests: - name: All common tests passed or skipped - needs: [test, test-py27] - # Always run this, even if a dependent job failed - if: always() - runs-on: ubuntu-20.04 - steps: - - name: Check for failures - if: contains(needs.test.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 - - name: Check for 2.7 failures - if: contains(needs.test-py27.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integration-aiohttp.yml b/.github/workflows/test-integration-aiohttp.yml deleted file mode 100644 index f70d652f2e..0000000000 --- a/.github/workflows/test-integration-aiohttp.yml +++ /dev/null @@ -1,83 +0,0 @@ -name: Test aiohttp - -on: - push: - branches: - - master - - release/** - - pull_request: - -# Cancel in progress workflows on pull_requests. -# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - -env: - BUILD_CACHE_KEY: ${{ github.sha }} - CACHED_BUILD_PATHS: | - ${{ github.workspace }}/dist-serverless - -jobs: - test: - name: aiohttp, python ${{ matrix.python-version }}, ${{ matrix.os }} - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - - strategy: - fail-fast: false - matrix: - python-version: ["3.7","3.8","3.9","3.10","3.11"] - # python3.6 reached EOL and is no longer being supported on - # new versions of hosted runners on Github Actions - # ubuntu-20.04 is the last version that supported python3.6 - # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 - os: [ubuntu-20.04] - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - - - name: Test aiohttp - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py${{ matrix.python-version }}-aiohttp" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - - uses: codecov/codecov-action@v3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: coverage.xml - - - check_required_tests: - name: All aiohttp tests passed or skipped - needs: test - # Always run this, even if a dependent job failed - if: always() - runs-on: ubuntu-20.04 - steps: - - name: Check for failures - if: contains(needs.test.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integration-ariadne.yml b/.github/workflows/test-integration-ariadne.yml deleted file mode 100644 index eeb7a0208f..0000000000 --- a/.github/workflows/test-integration-ariadne.yml +++ /dev/null @@ -1,83 +0,0 @@ -name: Test ariadne - -on: - push: - branches: - - master - - release/** - - pull_request: - -# Cancel in progress workflows on pull_requests. -# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - -env: - BUILD_CACHE_KEY: ${{ github.sha }} - CACHED_BUILD_PATHS: | - ${{ github.workspace }}/dist-serverless - -jobs: - test: - name: ariadne, python ${{ matrix.python-version }}, ${{ matrix.os }} - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - - strategy: - fail-fast: false - matrix: - python-version: ["3.8","3.9","3.10","3.11"] - # python3.6 reached EOL and is no longer being supported on - # new versions of hosted runners on Github Actions - # ubuntu-20.04 is the last version that supported python3.6 - # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 - os: [ubuntu-20.04] - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - - - name: Test ariadne - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py${{ matrix.python-version }}-ariadne" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - - uses: codecov/codecov-action@v3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: coverage.xml - - - check_required_tests: - name: All ariadne tests passed or skipped - needs: test - # Always run this, even if a dependent job failed - if: always() - runs-on: ubuntu-20.04 - steps: - - name: Check for failures - if: contains(needs.test.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integration-arq.yml b/.github/workflows/test-integration-arq.yml deleted file mode 100644 index 9a902ab20c..0000000000 --- a/.github/workflows/test-integration-arq.yml +++ /dev/null @@ -1,83 +0,0 @@ -name: Test arq - -on: - push: - branches: - - master - - release/** - - pull_request: - -# Cancel in progress workflows on pull_requests. -# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - -env: - BUILD_CACHE_KEY: ${{ github.sha }} - CACHED_BUILD_PATHS: | - ${{ github.workspace }}/dist-serverless - -jobs: - test: - name: arq, python ${{ matrix.python-version }}, ${{ matrix.os }} - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - - strategy: - fail-fast: false - matrix: - python-version: ["3.7","3.8","3.9","3.10","3.11"] - # python3.6 reached EOL and is no longer being supported on - # new versions of hosted runners on Github Actions - # ubuntu-20.04 is the last version that supported python3.6 - # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 - os: [ubuntu-20.04] - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - - - name: Test arq - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py${{ matrix.python-version }}-arq" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - - uses: codecov/codecov-action@v3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: coverage.xml - - - check_required_tests: - name: All arq tests passed or skipped - needs: test - # Always run this, even if a dependent job failed - if: always() - runs-on: ubuntu-20.04 - steps: - - name: Check for failures - if: contains(needs.test.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integration-asgi.yml b/.github/workflows/test-integration-asgi.yml deleted file mode 100644 index 1b9e6916ec..0000000000 --- a/.github/workflows/test-integration-asgi.yml +++ /dev/null @@ -1,83 +0,0 @@ -name: Test asgi - -on: - push: - branches: - - master - - release/** - - pull_request: - -# Cancel in progress workflows on pull_requests. -# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - -env: - BUILD_CACHE_KEY: ${{ github.sha }} - CACHED_BUILD_PATHS: | - ${{ github.workspace }}/dist-serverless - -jobs: - test: - name: asgi, python ${{ matrix.python-version }}, ${{ matrix.os }} - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - - strategy: - fail-fast: false - matrix: - python-version: ["3.7","3.8","3.9","3.10","3.11"] - # python3.6 reached EOL and is no longer being supported on - # new versions of hosted runners on Github Actions - # ubuntu-20.04 is the last version that supported python3.6 - # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 - os: [ubuntu-20.04] - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - - - name: Test asgi - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py${{ matrix.python-version }}-asgi" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - - uses: codecov/codecov-action@v3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: coverage.xml - - - check_required_tests: - name: All asgi tests passed or skipped - needs: test - # Always run this, even if a dependent job failed - if: always() - runs-on: ubuntu-20.04 - steps: - - name: Check for failures - if: contains(needs.test.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integration-asyncpg.yml b/.github/workflows/test-integration-asyncpg.yml deleted file mode 100644 index de6ad8c9c0..0000000000 --- a/.github/workflows/test-integration-asyncpg.yml +++ /dev/null @@ -1,104 +0,0 @@ -name: Test asyncpg - -on: - push: - branches: - - master - - release/** - - pull_request: - -# Cancel in progress workflows on pull_requests. -# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - -env: - BUILD_CACHE_KEY: ${{ github.sha }} - CACHED_BUILD_PATHS: | - ${{ github.workspace }}/dist-serverless - -jobs: - test: - name: asyncpg, python ${{ matrix.python-version }}, ${{ matrix.os }} - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - - strategy: - fail-fast: false - matrix: - python-version: ["3.7","3.8","3.9","3.10","3.11"] - # python3.6 reached EOL and is no longer being supported on - # new versions of hosted runners on Github Actions - # ubuntu-20.04 is the last version that supported python3.6 - # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 - os: [ubuntu-20.04] - services: - postgres: - image: postgres - env: - POSTGRES_PASSWORD: sentry - # Set health checks to wait until postgres has started - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - # Maps tcp port 5432 on service container to the host - ports: - - 5432:5432 - env: - SENTRY_PYTHON_TEST_POSTGRES_USER: postgres - SENTRY_PYTHON_TEST_POSTGRES_PASSWORD: sentry - SENTRY_PYTHON_TEST_POSTGRES_NAME: ci_test - SENTRY_PYTHON_TEST_POSTGRES_HOST: localhost - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - psql postgresql://postgres:sentry@localhost:5432 -c "create database ${SENTRY_PYTHON_TEST_POSTGRES_NAME};" || true - psql postgresql://postgres:sentry@localhost:5432 -c "grant all privileges on database ${SENTRY_PYTHON_TEST_POSTGRES_NAME} to ${SENTRY_PYTHON_TEST_POSTGRES_USER};" || true - - - name: Test asyncpg - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py${{ matrix.python-version }}-asyncpg" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - - uses: codecov/codecov-action@v3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: coverage.xml - - - check_required_tests: - name: All asyncpg tests passed or skipped - needs: test - # Always run this, even if a dependent job failed - if: always() - runs-on: ubuntu-20.04 - steps: - - name: Check for failures - if: contains(needs.test.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integration-aws_lambda.yml b/.github/workflows/test-integration-aws_lambda.yml deleted file mode 100644 index 62bfab90f2..0000000000 --- a/.github/workflows/test-integration-aws_lambda.yml +++ /dev/null @@ -1,83 +0,0 @@ -name: Test aws_lambda - -on: - push: - branches: - - master - - release/** - - pull_request: - -# Cancel in progress workflows on pull_requests. -# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - -env: - BUILD_CACHE_KEY: ${{ github.sha }} - CACHED_BUILD_PATHS: | - ${{ github.workspace }}/dist-serverless - -jobs: - test: - name: aws_lambda, python ${{ matrix.python-version }}, ${{ matrix.os }} - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - - strategy: - fail-fast: false - matrix: - python-version: ["3.7"] - # python3.6 reached EOL and is no longer being supported on - # new versions of hosted runners on Github Actions - # ubuntu-20.04 is the last version that supported python3.6 - # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 - os: [ubuntu-20.04] - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - - - name: Test aws_lambda - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py${{ matrix.python-version }}-aws_lambda" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - - uses: codecov/codecov-action@v3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: coverage.xml - - - check_required_tests: - name: All aws_lambda tests passed or skipped - needs: test - # Always run this, even if a dependent job failed - if: always() - runs-on: ubuntu-20.04 - steps: - - name: Check for failures - if: contains(needs.test.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integration-beam.yml b/.github/workflows/test-integration-beam.yml deleted file mode 100644 index a86d6ccd7d..0000000000 --- a/.github/workflows/test-integration-beam.yml +++ /dev/null @@ -1,83 +0,0 @@ -name: Test beam - -on: - push: - branches: - - master - - release/** - - pull_request: - -# Cancel in progress workflows on pull_requests. -# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - -env: - BUILD_CACHE_KEY: ${{ github.sha }} - CACHED_BUILD_PATHS: | - ${{ github.workspace }}/dist-serverless - -jobs: - test: - name: beam, python ${{ matrix.python-version }}, ${{ matrix.os }} - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - - strategy: - fail-fast: false - matrix: - python-version: ["3.7"] - # python3.6 reached EOL and is no longer being supported on - # new versions of hosted runners on Github Actions - # ubuntu-20.04 is the last version that supported python3.6 - # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 - os: [ubuntu-20.04] - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - - - name: Test beam - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py${{ matrix.python-version }}-beam" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - - uses: codecov/codecov-action@v3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: coverage.xml - - - check_required_tests: - name: All beam tests passed or skipped - needs: test - # Always run this, even if a dependent job failed - if: always() - runs-on: ubuntu-20.04 - steps: - - name: Check for failures - if: contains(needs.test.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integration-boto3.yml b/.github/workflows/test-integration-boto3.yml deleted file mode 100644 index fb246c899e..0000000000 --- a/.github/workflows/test-integration-boto3.yml +++ /dev/null @@ -1,115 +0,0 @@ -name: Test boto3 - -on: - push: - branches: - - master - - release/** - - pull_request: - -# Cancel in progress workflows on pull_requests. -# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - -env: - BUILD_CACHE_KEY: ${{ github.sha }} - CACHED_BUILD_PATHS: | - ${{ github.workspace }}/dist-serverless - -jobs: - test: - name: boto3, python ${{ matrix.python-version }}, ${{ matrix.os }} - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - - strategy: - fail-fast: false - matrix: - python-version: ["3.6","3.7","3.8"] - # python3.6 reached EOL and is no longer being supported on - # new versions of hosted runners on Github Actions - # ubuntu-20.04 is the last version that supported python3.6 - # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 - os: [ubuntu-20.04] - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - - - name: Test boto3 - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py${{ matrix.python-version }}-boto3" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - - uses: codecov/codecov-action@v3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: coverage.xml - - test-py27: - name: boto3, python 2.7, ubuntu-20.04 - runs-on: ubuntu-20.04 - container: python:2.7 - timeout-minutes: 30 - - steps: - - uses: actions/checkout@v4 - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - - - name: Test boto3 - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py2.7-boto3" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - check_required_tests: - name: All boto3 tests passed or skipped - needs: [test, test-py27] - # Always run this, even if a dependent job failed - if: always() - runs-on: ubuntu-20.04 - steps: - - name: Check for failures - if: contains(needs.test.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 - - name: Check for 2.7 failures - if: contains(needs.test-py27.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integration-bottle.yml b/.github/workflows/test-integration-bottle.yml deleted file mode 100644 index 41e496a12b..0000000000 --- a/.github/workflows/test-integration-bottle.yml +++ /dev/null @@ -1,115 +0,0 @@ -name: Test bottle - -on: - push: - branches: - - master - - release/** - - pull_request: - -# Cancel in progress workflows on pull_requests. -# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - -env: - BUILD_CACHE_KEY: ${{ github.sha }} - CACHED_BUILD_PATHS: | - ${{ github.workspace }}/dist-serverless - -jobs: - test: - name: bottle, python ${{ matrix.python-version }}, ${{ matrix.os }} - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - - strategy: - fail-fast: false - matrix: - python-version: ["3.5","3.6","3.7","3.8","3.9","3.10","3.11"] - # python3.6 reached EOL and is no longer being supported on - # new versions of hosted runners on Github Actions - # ubuntu-20.04 is the last version that supported python3.6 - # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 - os: [ubuntu-20.04] - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - - - name: Test bottle - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py${{ matrix.python-version }}-bottle" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - - uses: codecov/codecov-action@v3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: coverage.xml - - test-py27: - name: bottle, python 2.7, ubuntu-20.04 - runs-on: ubuntu-20.04 - container: python:2.7 - timeout-minutes: 30 - - steps: - - uses: actions/checkout@v4 - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - - - name: Test bottle - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py2.7-bottle" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - check_required_tests: - name: All bottle tests passed or skipped - needs: [test, test-py27] - # Always run this, even if a dependent job failed - if: always() - runs-on: ubuntu-20.04 - steps: - - name: Check for failures - if: contains(needs.test.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 - - name: Check for 2.7 failures - if: contains(needs.test-py27.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integration-celery.yml b/.github/workflows/test-integration-celery.yml deleted file mode 100644 index 71623f0e1e..0000000000 --- a/.github/workflows/test-integration-celery.yml +++ /dev/null @@ -1,115 +0,0 @@ -name: Test celery - -on: - push: - branches: - - master - - release/** - - pull_request: - -# Cancel in progress workflows on pull_requests. -# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - -env: - BUILD_CACHE_KEY: ${{ github.sha }} - CACHED_BUILD_PATHS: | - ${{ github.workspace }}/dist-serverless - -jobs: - test: - name: celery, python ${{ matrix.python-version }}, ${{ matrix.os }} - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - - strategy: - fail-fast: false - matrix: - python-version: ["3.5","3.6","3.7","3.8","3.9","3.10","3.11"] - # python3.6 reached EOL and is no longer being supported on - # new versions of hosted runners on Github Actions - # ubuntu-20.04 is the last version that supported python3.6 - # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 - os: [ubuntu-20.04] - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - - - name: Test celery - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py${{ matrix.python-version }}-celery" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - - uses: codecov/codecov-action@v3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: coverage.xml - - test-py27: - name: celery, python 2.7, ubuntu-20.04 - runs-on: ubuntu-20.04 - container: python:2.7 - timeout-minutes: 30 - - steps: - - uses: actions/checkout@v4 - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - - - name: Test celery - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py2.7-celery" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - check_required_tests: - name: All celery tests passed or skipped - needs: [test, test-py27] - # Always run this, even if a dependent job failed - if: always() - runs-on: ubuntu-20.04 - steps: - - name: Check for failures - if: contains(needs.test.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 - - name: Check for 2.7 failures - if: contains(needs.test-py27.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integration-chalice.yml b/.github/workflows/test-integration-chalice.yml deleted file mode 100644 index 6615aeb75d..0000000000 --- a/.github/workflows/test-integration-chalice.yml +++ /dev/null @@ -1,83 +0,0 @@ -name: Test chalice - -on: - push: - branches: - - master - - release/** - - pull_request: - -# Cancel in progress workflows on pull_requests. -# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - -env: - BUILD_CACHE_KEY: ${{ github.sha }} - CACHED_BUILD_PATHS: | - ${{ github.workspace }}/dist-serverless - -jobs: - test: - name: chalice, python ${{ matrix.python-version }}, ${{ matrix.os }} - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - - strategy: - fail-fast: false - matrix: - python-version: ["3.6","3.7","3.8"] - # python3.6 reached EOL and is no longer being supported on - # new versions of hosted runners on Github Actions - # ubuntu-20.04 is the last version that supported python3.6 - # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 - os: [ubuntu-20.04] - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - - - name: Test chalice - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py${{ matrix.python-version }}-chalice" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - - uses: codecov/codecov-action@v3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: coverage.xml - - - check_required_tests: - name: All chalice tests passed or skipped - needs: test - # Always run this, even if a dependent job failed - if: always() - runs-on: ubuntu-20.04 - steps: - - name: Check for failures - if: contains(needs.test.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integration-clickhouse_driver.yml b/.github/workflows/test-integration-clickhouse_driver.yml deleted file mode 100644 index 49b26e1803..0000000000 --- a/.github/workflows/test-integration-clickhouse_driver.yml +++ /dev/null @@ -1,85 +0,0 @@ -name: Test clickhouse_driver - -on: - push: - branches: - - master - - release/** - - pull_request: - -# Cancel in progress workflows on pull_requests. -# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - -env: - BUILD_CACHE_KEY: ${{ github.sha }} - CACHED_BUILD_PATHS: | - ${{ github.workspace }}/dist-serverless - -jobs: - test: - name: clickhouse_driver, python ${{ matrix.python-version }}, ${{ matrix.os }} - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - - strategy: - fail-fast: false - matrix: - python-version: ["3.8","3.9","3.10","3.11"] - # python3.6 reached EOL and is no longer being supported on - # new versions of hosted runners on Github Actions - # ubuntu-20.04 is the last version that supported python3.6 - # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 - os: [ubuntu-20.04] - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - uses: getsentry/action-clickhouse-in-ci@v1 - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - - - name: Test clickhouse_driver - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py${{ matrix.python-version }}-clickhouse_driver" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - - uses: codecov/codecov-action@v3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: coverage.xml - - - check_required_tests: - name: All clickhouse_driver tests passed or skipped - needs: test - # Always run this, even if a dependent job failed - if: always() - runs-on: ubuntu-20.04 - steps: - - name: Check for failures - if: contains(needs.test.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integration-cloud_resource_context.yml b/.github/workflows/test-integration-cloud_resource_context.yml deleted file mode 100644 index c59dca3078..0000000000 --- a/.github/workflows/test-integration-cloud_resource_context.yml +++ /dev/null @@ -1,83 +0,0 @@ -name: Test cloud_resource_context - -on: - push: - branches: - - master - - release/** - - pull_request: - -# Cancel in progress workflows on pull_requests. -# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - -env: - BUILD_CACHE_KEY: ${{ github.sha }} - CACHED_BUILD_PATHS: | - ${{ github.workspace }}/dist-serverless - -jobs: - test: - name: cloud_resource_context, python ${{ matrix.python-version }}, ${{ matrix.os }} - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - - strategy: - fail-fast: false - matrix: - python-version: ["3.6","3.7","3.8","3.9","3.10","3.11"] - # python3.6 reached EOL and is no longer being supported on - # new versions of hosted runners on Github Actions - # ubuntu-20.04 is the last version that supported python3.6 - # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 - os: [ubuntu-20.04] - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - - - name: Test cloud_resource_context - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py${{ matrix.python-version }}-cloud_resource_context" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - - uses: codecov/codecov-action@v3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: coverage.xml - - - check_required_tests: - name: All cloud_resource_context tests passed or skipped - needs: test - # Always run this, even if a dependent job failed - if: always() - runs-on: ubuntu-20.04 - steps: - - name: Check for failures - if: contains(needs.test.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integration-django.yml b/.github/workflows/test-integration-django.yml deleted file mode 100644 index d667464212..0000000000 --- a/.github/workflows/test-integration-django.yml +++ /dev/null @@ -1,155 +0,0 @@ -name: Test django - -on: - push: - branches: - - master - - release/** - - pull_request: - -# Cancel in progress workflows on pull_requests. -# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - -env: - BUILD_CACHE_KEY: ${{ github.sha }} - CACHED_BUILD_PATHS: | - ${{ github.workspace }}/dist-serverless - -jobs: - test: - name: django, python ${{ matrix.python-version }}, ${{ matrix.os }} - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - - strategy: - fail-fast: false - matrix: - python-version: ["3.5","3.6","3.7","3.8","3.9","3.10","3.11"] - # python3.6 reached EOL and is no longer being supported on - # new versions of hosted runners on Github Actions - # ubuntu-20.04 is the last version that supported python3.6 - # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 - os: [ubuntu-20.04] - services: - postgres: - image: postgres - env: - POSTGRES_PASSWORD: sentry - # Set health checks to wait until postgres has started - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - # Maps tcp port 5432 on service container to the host - ports: - - 5432:5432 - env: - SENTRY_PYTHON_TEST_POSTGRES_USER: postgres - SENTRY_PYTHON_TEST_POSTGRES_PASSWORD: sentry - SENTRY_PYTHON_TEST_POSTGRES_NAME: ci_test - SENTRY_PYTHON_TEST_POSTGRES_HOST: localhost - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - psql postgresql://postgres:sentry@localhost:5432 -c "create database ${SENTRY_PYTHON_TEST_POSTGRES_NAME};" || true - psql postgresql://postgres:sentry@localhost:5432 -c "grant all privileges on database ${SENTRY_PYTHON_TEST_POSTGRES_NAME} to ${SENTRY_PYTHON_TEST_POSTGRES_USER};" || true - - - name: Test django - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py${{ matrix.python-version }}-django" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - - uses: codecov/codecov-action@v3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: coverage.xml - - test-py27: - name: django, python 2.7, ubuntu-20.04 - runs-on: ubuntu-20.04 - container: python:2.7 - timeout-minutes: 30 - services: - postgres: - image: postgres - env: - POSTGRES_PASSWORD: sentry - # Set health checks to wait until postgres has started - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - # Maps tcp port 5432 on service container to the host - ports: - - 5432:5432 - env: - SENTRY_PYTHON_TEST_POSTGRES_USER: postgres - SENTRY_PYTHON_TEST_POSTGRES_PASSWORD: sentry - SENTRY_PYTHON_TEST_POSTGRES_NAME: ci_test - SENTRY_PYTHON_TEST_POSTGRES_HOST: postgres - - steps: - - uses: actions/checkout@v4 - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - - - name: Test django - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py2.7-django" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - check_required_tests: - name: All django tests passed or skipped - needs: [test, test-py27] - # Always run this, even if a dependent job failed - if: always() - runs-on: ubuntu-20.04 - steps: - - name: Check for failures - if: contains(needs.test.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 - - name: Check for 2.7 failures - if: contains(needs.test-py27.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integration-falcon.yml b/.github/workflows/test-integration-falcon.yml deleted file mode 100644 index 522956c959..0000000000 --- a/.github/workflows/test-integration-falcon.yml +++ /dev/null @@ -1,115 +0,0 @@ -name: Test falcon - -on: - push: - branches: - - master - - release/** - - pull_request: - -# Cancel in progress workflows on pull_requests. -# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - -env: - BUILD_CACHE_KEY: ${{ github.sha }} - CACHED_BUILD_PATHS: | - ${{ github.workspace }}/dist-serverless - -jobs: - test: - name: falcon, python ${{ matrix.python-version }}, ${{ matrix.os }} - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - - strategy: - fail-fast: false - matrix: - python-version: ["3.5","3.6","3.7","3.8","3.9","3.10","3.11"] - # python3.6 reached EOL and is no longer being supported on - # new versions of hosted runners on Github Actions - # ubuntu-20.04 is the last version that supported python3.6 - # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 - os: [ubuntu-20.04] - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - - - name: Test falcon - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py${{ matrix.python-version }}-falcon" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - - uses: codecov/codecov-action@v3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: coverage.xml - - test-py27: - name: falcon, python 2.7, ubuntu-20.04 - runs-on: ubuntu-20.04 - container: python:2.7 - timeout-minutes: 30 - - steps: - - uses: actions/checkout@v4 - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - - - name: Test falcon - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py2.7-falcon" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - check_required_tests: - name: All falcon tests passed or skipped - needs: [test, test-py27] - # Always run this, even if a dependent job failed - if: always() - runs-on: ubuntu-20.04 - steps: - - name: Check for failures - if: contains(needs.test.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 - - name: Check for 2.7 failures - if: contains(needs.test-py27.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integration-fastapi.yml b/.github/workflows/test-integration-fastapi.yml deleted file mode 100644 index 87af0054c7..0000000000 --- a/.github/workflows/test-integration-fastapi.yml +++ /dev/null @@ -1,83 +0,0 @@ -name: Test fastapi - -on: - push: - branches: - - master - - release/** - - pull_request: - -# Cancel in progress workflows on pull_requests. -# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - -env: - BUILD_CACHE_KEY: ${{ github.sha }} - CACHED_BUILD_PATHS: | - ${{ github.workspace }}/dist-serverless - -jobs: - test: - name: fastapi, python ${{ matrix.python-version }}, ${{ matrix.os }} - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - - strategy: - fail-fast: false - matrix: - python-version: ["3.7","3.8","3.9","3.10","3.11"] - # python3.6 reached EOL and is no longer being supported on - # new versions of hosted runners on Github Actions - # ubuntu-20.04 is the last version that supported python3.6 - # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 - os: [ubuntu-20.04] - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - - - name: Test fastapi - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py${{ matrix.python-version }}-fastapi" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - - uses: codecov/codecov-action@v3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: coverage.xml - - - check_required_tests: - name: All fastapi tests passed or skipped - needs: test - # Always run this, even if a dependent job failed - if: always() - runs-on: ubuntu-20.04 - steps: - - name: Check for failures - if: contains(needs.test.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integration-flask.yml b/.github/workflows/test-integration-flask.yml deleted file mode 100644 index 301256dffc..0000000000 --- a/.github/workflows/test-integration-flask.yml +++ /dev/null @@ -1,115 +0,0 @@ -name: Test flask - -on: - push: - branches: - - master - - release/** - - pull_request: - -# Cancel in progress workflows on pull_requests. -# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - -env: - BUILD_CACHE_KEY: ${{ github.sha }} - CACHED_BUILD_PATHS: | - ${{ github.workspace }}/dist-serverless - -jobs: - test: - name: flask, python ${{ matrix.python-version }}, ${{ matrix.os }} - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - - strategy: - fail-fast: false - matrix: - python-version: ["3.5","3.6","3.7","3.8","3.9","3.10","3.11"] - # python3.6 reached EOL and is no longer being supported on - # new versions of hosted runners on Github Actions - # ubuntu-20.04 is the last version that supported python3.6 - # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 - os: [ubuntu-20.04] - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - - - name: Test flask - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py${{ matrix.python-version }}-flask" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - - uses: codecov/codecov-action@v3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: coverage.xml - - test-py27: - name: flask, python 2.7, ubuntu-20.04 - runs-on: ubuntu-20.04 - container: python:2.7 - timeout-minutes: 30 - - steps: - - uses: actions/checkout@v4 - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - - - name: Test flask - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py2.7-flask" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - check_required_tests: - name: All flask tests passed or skipped - needs: [test, test-py27] - # Always run this, even if a dependent job failed - if: always() - runs-on: ubuntu-20.04 - steps: - - name: Check for failures - if: contains(needs.test.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 - - name: Check for 2.7 failures - if: contains(needs.test-py27.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integration-gcp.yml b/.github/workflows/test-integration-gcp.yml deleted file mode 100644 index c6eb4adcc8..0000000000 --- a/.github/workflows/test-integration-gcp.yml +++ /dev/null @@ -1,83 +0,0 @@ -name: Test gcp - -on: - push: - branches: - - master - - release/** - - pull_request: - -# Cancel in progress workflows on pull_requests. -# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - -env: - BUILD_CACHE_KEY: ${{ github.sha }} - CACHED_BUILD_PATHS: | - ${{ github.workspace }}/dist-serverless - -jobs: - test: - name: gcp, python ${{ matrix.python-version }}, ${{ matrix.os }} - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - - strategy: - fail-fast: false - matrix: - python-version: ["3.7"] - # python3.6 reached EOL and is no longer being supported on - # new versions of hosted runners on Github Actions - # ubuntu-20.04 is the last version that supported python3.6 - # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 - os: [ubuntu-20.04] - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - - - name: Test gcp - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py${{ matrix.python-version }}-gcp" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - - uses: codecov/codecov-action@v3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: coverage.xml - - - check_required_tests: - name: All gcp tests passed or skipped - needs: test - # Always run this, even if a dependent job failed - if: always() - runs-on: ubuntu-20.04 - steps: - - name: Check for failures - if: contains(needs.test.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integration-gevent.yml b/.github/workflows/test-integration-gevent.yml deleted file mode 100644 index d879f5c2f5..0000000000 --- a/.github/workflows/test-integration-gevent.yml +++ /dev/null @@ -1,115 +0,0 @@ -name: Test gevent - -on: - push: - branches: - - master - - release/** - - pull_request: - -# Cancel in progress workflows on pull_requests. -# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - -env: - BUILD_CACHE_KEY: ${{ github.sha }} - CACHED_BUILD_PATHS: | - ${{ github.workspace }}/dist-serverless - -jobs: - test: - name: gevent, python ${{ matrix.python-version }}, ${{ matrix.os }} - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - - strategy: - fail-fast: false - matrix: - python-version: ["3.6","3.7","3.8","3.9","3.10","3.11"] - # python3.6 reached EOL and is no longer being supported on - # new versions of hosted runners on Github Actions - # ubuntu-20.04 is the last version that supported python3.6 - # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 - os: [ubuntu-20.04] - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - - - name: Test gevent - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py${{ matrix.python-version }}-gevent" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - - uses: codecov/codecov-action@v3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: coverage.xml - - test-py27: - name: gevent, python 2.7, ubuntu-20.04 - runs-on: ubuntu-20.04 - container: python:2.7 - timeout-minutes: 30 - - steps: - - uses: actions/checkout@v4 - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - - - name: Test gevent - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py2.7-gevent" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - check_required_tests: - name: All gevent tests passed or skipped - needs: [test, test-py27] - # Always run this, even if a dependent job failed - if: always() - runs-on: ubuntu-20.04 - steps: - - name: Check for failures - if: contains(needs.test.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 - - name: Check for 2.7 failures - if: contains(needs.test-py27.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integration-gql.yml b/.github/workflows/test-integration-gql.yml deleted file mode 100644 index 9ebd5a16b7..0000000000 --- a/.github/workflows/test-integration-gql.yml +++ /dev/null @@ -1,83 +0,0 @@ -name: Test gql - -on: - push: - branches: - - master - - release/** - - pull_request: - -# Cancel in progress workflows on pull_requests. -# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - -env: - BUILD_CACHE_KEY: ${{ github.sha }} - CACHED_BUILD_PATHS: | - ${{ github.workspace }}/dist-serverless - -jobs: - test: - name: gql, python ${{ matrix.python-version }}, ${{ matrix.os }} - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - - strategy: - fail-fast: false - matrix: - python-version: ["3.7","3.8","3.9","3.10","3.11"] - # python3.6 reached EOL and is no longer being supported on - # new versions of hosted runners on Github Actions - # ubuntu-20.04 is the last version that supported python3.6 - # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 - os: [ubuntu-20.04] - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - - - name: Test gql - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py${{ matrix.python-version }}-gql" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - - uses: codecov/codecov-action@v3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: coverage.xml - - - check_required_tests: - name: All gql tests passed or skipped - needs: test - # Always run this, even if a dependent job failed - if: always() - runs-on: ubuntu-20.04 - steps: - - name: Check for failures - if: contains(needs.test.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integration-graphene.yml b/.github/workflows/test-integration-graphene.yml deleted file mode 100644 index 69d89958c3..0000000000 --- a/.github/workflows/test-integration-graphene.yml +++ /dev/null @@ -1,83 +0,0 @@ -name: Test graphene - -on: - push: - branches: - - master - - release/** - - pull_request: - -# Cancel in progress workflows on pull_requests. -# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - -env: - BUILD_CACHE_KEY: ${{ github.sha }} - CACHED_BUILD_PATHS: | - ${{ github.workspace }}/dist-serverless - -jobs: - test: - name: graphene, python ${{ matrix.python-version }}, ${{ matrix.os }} - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - - strategy: - fail-fast: false - matrix: - python-version: ["3.7","3.8","3.9","3.10","3.11"] - # python3.6 reached EOL and is no longer being supported on - # new versions of hosted runners on Github Actions - # ubuntu-20.04 is the last version that supported python3.6 - # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 - os: [ubuntu-20.04] - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - - - name: Test graphene - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py${{ matrix.python-version }}-graphene" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - - uses: codecov/codecov-action@v3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: coverage.xml - - - check_required_tests: - name: All graphene tests passed or skipped - needs: test - # Always run this, even if a dependent job failed - if: always() - runs-on: ubuntu-20.04 - steps: - - name: Check for failures - if: contains(needs.test.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integration-grpc.yml b/.github/workflows/test-integration-grpc.yml deleted file mode 100644 index 8c79fae4b8..0000000000 --- a/.github/workflows/test-integration-grpc.yml +++ /dev/null @@ -1,83 +0,0 @@ -name: Test grpc - -on: - push: - branches: - - master - - release/** - - pull_request: - -# Cancel in progress workflows on pull_requests. -# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - -env: - BUILD_CACHE_KEY: ${{ github.sha }} - CACHED_BUILD_PATHS: | - ${{ github.workspace }}/dist-serverless - -jobs: - test: - name: grpc, python ${{ matrix.python-version }}, ${{ matrix.os }} - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - - strategy: - fail-fast: false - matrix: - python-version: ["3.7","3.8","3.9","3.10","3.11"] - # python3.6 reached EOL and is no longer being supported on - # new versions of hosted runners on Github Actions - # ubuntu-20.04 is the last version that supported python3.6 - # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 - os: [ubuntu-20.04] - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - - - name: Test grpc - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py${{ matrix.python-version }}-grpc" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - - uses: codecov/codecov-action@v3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: coverage.xml - - - check_required_tests: - name: All grpc tests passed or skipped - needs: test - # Always run this, even if a dependent job failed - if: always() - runs-on: ubuntu-20.04 - steps: - - name: Check for failures - if: contains(needs.test.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integration-httpx.yml b/.github/workflows/test-integration-httpx.yml deleted file mode 100644 index 8aadb01812..0000000000 --- a/.github/workflows/test-integration-httpx.yml +++ /dev/null @@ -1,83 +0,0 @@ -name: Test httpx - -on: - push: - branches: - - master - - release/** - - pull_request: - -# Cancel in progress workflows on pull_requests. -# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - -env: - BUILD_CACHE_KEY: ${{ github.sha }} - CACHED_BUILD_PATHS: | - ${{ github.workspace }}/dist-serverless - -jobs: - test: - name: httpx, python ${{ matrix.python-version }}, ${{ matrix.os }} - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - - strategy: - fail-fast: false - matrix: - python-version: ["3.6","3.7","3.8","3.9","3.10","3.11"] - # python3.6 reached EOL and is no longer being supported on - # new versions of hosted runners on Github Actions - # ubuntu-20.04 is the last version that supported python3.6 - # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 - os: [ubuntu-20.04] - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - - - name: Test httpx - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py${{ matrix.python-version }}-httpx" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - - uses: codecov/codecov-action@v3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: coverage.xml - - - check_required_tests: - name: All httpx tests passed or skipped - needs: test - # Always run this, even if a dependent job failed - if: always() - runs-on: ubuntu-20.04 - steps: - - name: Check for failures - if: contains(needs.test.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integration-huey.yml b/.github/workflows/test-integration-huey.yml deleted file mode 100644 index a335b9dc9c..0000000000 --- a/.github/workflows/test-integration-huey.yml +++ /dev/null @@ -1,115 +0,0 @@ -name: Test huey - -on: - push: - branches: - - master - - release/** - - pull_request: - -# Cancel in progress workflows on pull_requests. -# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - -env: - BUILD_CACHE_KEY: ${{ github.sha }} - CACHED_BUILD_PATHS: | - ${{ github.workspace }}/dist-serverless - -jobs: - test: - name: huey, python ${{ matrix.python-version }}, ${{ matrix.os }} - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - - strategy: - fail-fast: false - matrix: - python-version: ["3.5","3.6","3.7","3.8","3.9","3.10","3.11"] - # python3.6 reached EOL and is no longer being supported on - # new versions of hosted runners on Github Actions - # ubuntu-20.04 is the last version that supported python3.6 - # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 - os: [ubuntu-20.04] - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - - - name: Test huey - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py${{ matrix.python-version }}-huey" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - - uses: codecov/codecov-action@v3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: coverage.xml - - test-py27: - name: huey, python 2.7, ubuntu-20.04 - runs-on: ubuntu-20.04 - container: python:2.7 - timeout-minutes: 30 - - steps: - - uses: actions/checkout@v4 - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - - - name: Test huey - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py2.7-huey" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - check_required_tests: - name: All huey tests passed or skipped - needs: [test, test-py27] - # Always run this, even if a dependent job failed - if: always() - runs-on: ubuntu-20.04 - steps: - - name: Check for failures - if: contains(needs.test.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 - - name: Check for 2.7 failures - if: contains(needs.test-py27.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integration-loguru.yml b/.github/workflows/test-integration-loguru.yml deleted file mode 100644 index f2b6b50317..0000000000 --- a/.github/workflows/test-integration-loguru.yml +++ /dev/null @@ -1,83 +0,0 @@ -name: Test loguru - -on: - push: - branches: - - master - - release/** - - pull_request: - -# Cancel in progress workflows on pull_requests. -# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - -env: - BUILD_CACHE_KEY: ${{ github.sha }} - CACHED_BUILD_PATHS: | - ${{ github.workspace }}/dist-serverless - -jobs: - test: - name: loguru, python ${{ matrix.python-version }}, ${{ matrix.os }} - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - - strategy: - fail-fast: false - matrix: - python-version: ["3.5","3.6","3.7","3.8","3.9","3.10","3.11"] - # python3.6 reached EOL and is no longer being supported on - # new versions of hosted runners on Github Actions - # ubuntu-20.04 is the last version that supported python3.6 - # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 - os: [ubuntu-20.04] - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - - - name: Test loguru - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py${{ matrix.python-version }}-loguru" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - - uses: codecov/codecov-action@v3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: coverage.xml - - - check_required_tests: - name: All loguru tests passed or skipped - needs: test - # Always run this, even if a dependent job failed - if: always() - runs-on: ubuntu-20.04 - steps: - - name: Check for failures - if: contains(needs.test.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integration-opentelemetry.yml b/.github/workflows/test-integration-opentelemetry.yml deleted file mode 100644 index 4179d2d22d..0000000000 --- a/.github/workflows/test-integration-opentelemetry.yml +++ /dev/null @@ -1,83 +0,0 @@ -name: Test opentelemetry - -on: - push: - branches: - - master - - release/** - - pull_request: - -# Cancel in progress workflows on pull_requests. -# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - -env: - BUILD_CACHE_KEY: ${{ github.sha }} - CACHED_BUILD_PATHS: | - ${{ github.workspace }}/dist-serverless - -jobs: - test: - name: opentelemetry, python ${{ matrix.python-version }}, ${{ matrix.os }} - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - - strategy: - fail-fast: false - matrix: - python-version: ["3.7","3.8","3.9","3.10","3.11"] - # python3.6 reached EOL and is no longer being supported on - # new versions of hosted runners on Github Actions - # ubuntu-20.04 is the last version that supported python3.6 - # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 - os: [ubuntu-20.04] - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - - - name: Test opentelemetry - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py${{ matrix.python-version }}-opentelemetry" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - - uses: codecov/codecov-action@v3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: coverage.xml - - - check_required_tests: - name: All opentelemetry tests passed or skipped - needs: test - # Always run this, even if a dependent job failed - if: always() - runs-on: ubuntu-20.04 - steps: - - name: Check for failures - if: contains(needs.test.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integration-pure_eval.yml b/.github/workflows/test-integration-pure_eval.yml deleted file mode 100644 index c723e02ede..0000000000 --- a/.github/workflows/test-integration-pure_eval.yml +++ /dev/null @@ -1,83 +0,0 @@ -name: Test pure_eval - -on: - push: - branches: - - master - - release/** - - pull_request: - -# Cancel in progress workflows on pull_requests. -# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - -env: - BUILD_CACHE_KEY: ${{ github.sha }} - CACHED_BUILD_PATHS: | - ${{ github.workspace }}/dist-serverless - -jobs: - test: - name: pure_eval, python ${{ matrix.python-version }}, ${{ matrix.os }} - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - - strategy: - fail-fast: false - matrix: - python-version: ["3.5","3.6","3.7","3.8","3.9","3.10","3.11"] - # python3.6 reached EOL and is no longer being supported on - # new versions of hosted runners on Github Actions - # ubuntu-20.04 is the last version that supported python3.6 - # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 - os: [ubuntu-20.04] - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - - - name: Test pure_eval - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py${{ matrix.python-version }}-pure_eval" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - - uses: codecov/codecov-action@v3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: coverage.xml - - - check_required_tests: - name: All pure_eval tests passed or skipped - needs: test - # Always run this, even if a dependent job failed - if: always() - runs-on: ubuntu-20.04 - steps: - - name: Check for failures - if: contains(needs.test.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integration-pymongo.yml b/.github/workflows/test-integration-pymongo.yml deleted file mode 100644 index ee7e21c425..0000000000 --- a/.github/workflows/test-integration-pymongo.yml +++ /dev/null @@ -1,115 +0,0 @@ -name: Test pymongo - -on: - push: - branches: - - master - - release/** - - pull_request: - -# Cancel in progress workflows on pull_requests. -# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - -env: - BUILD_CACHE_KEY: ${{ github.sha }} - CACHED_BUILD_PATHS: | - ${{ github.workspace }}/dist-serverless - -jobs: - test: - name: pymongo, python ${{ matrix.python-version }}, ${{ matrix.os }} - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - - strategy: - fail-fast: false - matrix: - python-version: ["3.6","3.7","3.8","3.9","3.10","3.11"] - # python3.6 reached EOL and is no longer being supported on - # new versions of hosted runners on Github Actions - # ubuntu-20.04 is the last version that supported python3.6 - # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 - os: [ubuntu-20.04] - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - - - name: Test pymongo - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py${{ matrix.python-version }}-pymongo" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - - uses: codecov/codecov-action@v3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: coverage.xml - - test-py27: - name: pymongo, python 2.7, ubuntu-20.04 - runs-on: ubuntu-20.04 - container: python:2.7 - timeout-minutes: 30 - - steps: - - uses: actions/checkout@v4 - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - - - name: Test pymongo - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py2.7-pymongo" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - check_required_tests: - name: All pymongo tests passed or skipped - needs: [test, test-py27] - # Always run this, even if a dependent job failed - if: always() - runs-on: ubuntu-20.04 - steps: - - name: Check for failures - if: contains(needs.test.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 - - name: Check for 2.7 failures - if: contains(needs.test-py27.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integration-pyramid.yml b/.github/workflows/test-integration-pyramid.yml deleted file mode 100644 index 6ad34e17d0..0000000000 --- a/.github/workflows/test-integration-pyramid.yml +++ /dev/null @@ -1,115 +0,0 @@ -name: Test pyramid - -on: - push: - branches: - - master - - release/** - - pull_request: - -# Cancel in progress workflows on pull_requests. -# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - -env: - BUILD_CACHE_KEY: ${{ github.sha }} - CACHED_BUILD_PATHS: | - ${{ github.workspace }}/dist-serverless - -jobs: - test: - name: pyramid, python ${{ matrix.python-version }}, ${{ matrix.os }} - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - - strategy: - fail-fast: false - matrix: - python-version: ["3.5","3.6","3.7","3.8","3.9","3.10","3.11"] - # python3.6 reached EOL and is no longer being supported on - # new versions of hosted runners on Github Actions - # ubuntu-20.04 is the last version that supported python3.6 - # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 - os: [ubuntu-20.04] - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - - - name: Test pyramid - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py${{ matrix.python-version }}-pyramid" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - - uses: codecov/codecov-action@v3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: coverage.xml - - test-py27: - name: pyramid, python 2.7, ubuntu-20.04 - runs-on: ubuntu-20.04 - container: python:2.7 - timeout-minutes: 30 - - steps: - - uses: actions/checkout@v4 - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - - - name: Test pyramid - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py2.7-pyramid" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - check_required_tests: - name: All pyramid tests passed or skipped - needs: [test, test-py27] - # Always run this, even if a dependent job failed - if: always() - runs-on: ubuntu-20.04 - steps: - - name: Check for failures - if: contains(needs.test.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 - - name: Check for 2.7 failures - if: contains(needs.test-py27.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integration-quart.yml b/.github/workflows/test-integration-quart.yml deleted file mode 100644 index 4c6ccb3157..0000000000 --- a/.github/workflows/test-integration-quart.yml +++ /dev/null @@ -1,83 +0,0 @@ -name: Test quart - -on: - push: - branches: - - master - - release/** - - pull_request: - -# Cancel in progress workflows on pull_requests. -# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - -env: - BUILD_CACHE_KEY: ${{ github.sha }} - CACHED_BUILD_PATHS: | - ${{ github.workspace }}/dist-serverless - -jobs: - test: - name: quart, python ${{ matrix.python-version }}, ${{ matrix.os }} - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - - strategy: - fail-fast: false - matrix: - python-version: ["3.7","3.8","3.9","3.10","3.11"] - # python3.6 reached EOL and is no longer being supported on - # new versions of hosted runners on Github Actions - # ubuntu-20.04 is the last version that supported python3.6 - # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 - os: [ubuntu-20.04] - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - - - name: Test quart - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py${{ matrix.python-version }}-quart" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - - uses: codecov/codecov-action@v3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: coverage.xml - - - check_required_tests: - name: All quart tests passed or skipped - needs: test - # Always run this, even if a dependent job failed - if: always() - runs-on: ubuntu-20.04 - steps: - - name: Check for failures - if: contains(needs.test.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integration-redis.yml b/.github/workflows/test-integration-redis.yml deleted file mode 100644 index 4af86fde47..0000000000 --- a/.github/workflows/test-integration-redis.yml +++ /dev/null @@ -1,115 +0,0 @@ -name: Test redis - -on: - push: - branches: - - master - - release/** - - pull_request: - -# Cancel in progress workflows on pull_requests. -# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - -env: - BUILD_CACHE_KEY: ${{ github.sha }} - CACHED_BUILD_PATHS: | - ${{ github.workspace }}/dist-serverless - -jobs: - test: - name: redis, python ${{ matrix.python-version }}, ${{ matrix.os }} - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - - strategy: - fail-fast: false - matrix: - python-version: ["3.7","3.8","3.9","3.10","3.11"] - # python3.6 reached EOL and is no longer being supported on - # new versions of hosted runners on Github Actions - # ubuntu-20.04 is the last version that supported python3.6 - # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 - os: [ubuntu-20.04] - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - - - name: Test redis - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py${{ matrix.python-version }}-redis" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - - uses: codecov/codecov-action@v3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: coverage.xml - - test-py27: - name: redis, python 2.7, ubuntu-20.04 - runs-on: ubuntu-20.04 - container: python:2.7 - timeout-minutes: 30 - - steps: - - uses: actions/checkout@v4 - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - - - name: Test redis - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py2.7-redis" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - check_required_tests: - name: All redis tests passed or skipped - needs: [test, test-py27] - # Always run this, even if a dependent job failed - if: always() - runs-on: ubuntu-20.04 - steps: - - name: Check for failures - if: contains(needs.test.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 - - name: Check for 2.7 failures - if: contains(needs.test-py27.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integration-rediscluster.yml b/.github/workflows/test-integration-rediscluster.yml deleted file mode 100644 index 73ed5c1733..0000000000 --- a/.github/workflows/test-integration-rediscluster.yml +++ /dev/null @@ -1,115 +0,0 @@ -name: Test rediscluster - -on: - push: - branches: - - master - - release/** - - pull_request: - -# Cancel in progress workflows on pull_requests. -# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - -env: - BUILD_CACHE_KEY: ${{ github.sha }} - CACHED_BUILD_PATHS: | - ${{ github.workspace }}/dist-serverless - -jobs: - test: - name: rediscluster, python ${{ matrix.python-version }}, ${{ matrix.os }} - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - - strategy: - fail-fast: false - matrix: - python-version: ["3.7","3.8","3.9"] - # python3.6 reached EOL and is no longer being supported on - # new versions of hosted runners on Github Actions - # ubuntu-20.04 is the last version that supported python3.6 - # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 - os: [ubuntu-20.04] - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - - - name: Test rediscluster - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py${{ matrix.python-version }}-rediscluster" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - - uses: codecov/codecov-action@v3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: coverage.xml - - test-py27: - name: rediscluster, python 2.7, ubuntu-20.04 - runs-on: ubuntu-20.04 - container: python:2.7 - timeout-minutes: 30 - - steps: - - uses: actions/checkout@v4 - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - - - name: Test rediscluster - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py2.7-rediscluster" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - check_required_tests: - name: All rediscluster tests passed or skipped - needs: [test, test-py27] - # Always run this, even if a dependent job failed - if: always() - runs-on: ubuntu-20.04 - steps: - - name: Check for failures - if: contains(needs.test.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 - - name: Check for 2.7 failures - if: contains(needs.test-py27.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integration-requests.yml b/.github/workflows/test-integration-requests.yml deleted file mode 100644 index 2645b13305..0000000000 --- a/.github/workflows/test-integration-requests.yml +++ /dev/null @@ -1,115 +0,0 @@ -name: Test requests - -on: - push: - branches: - - master - - release/** - - pull_request: - -# Cancel in progress workflows on pull_requests. -# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - -env: - BUILD_CACHE_KEY: ${{ github.sha }} - CACHED_BUILD_PATHS: | - ${{ github.workspace }}/dist-serverless - -jobs: - test: - name: requests, python ${{ matrix.python-version }}, ${{ matrix.os }} - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - - strategy: - fail-fast: false - matrix: - python-version: ["3.8","3.9","3.10","3.11"] - # python3.6 reached EOL and is no longer being supported on - # new versions of hosted runners on Github Actions - # ubuntu-20.04 is the last version that supported python3.6 - # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 - os: [ubuntu-20.04] - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - - - name: Test requests - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py${{ matrix.python-version }}-requests" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - - uses: codecov/codecov-action@v3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: coverage.xml - - test-py27: - name: requests, python 2.7, ubuntu-20.04 - runs-on: ubuntu-20.04 - container: python:2.7 - timeout-minutes: 30 - - steps: - - uses: actions/checkout@v4 - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - - - name: Test requests - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py2.7-requests" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - check_required_tests: - name: All requests tests passed or skipped - needs: [test, test-py27] - # Always run this, even if a dependent job failed - if: always() - runs-on: ubuntu-20.04 - steps: - - name: Check for failures - if: contains(needs.test.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 - - name: Check for 2.7 failures - if: contains(needs.test-py27.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integration-rq.yml b/.github/workflows/test-integration-rq.yml deleted file mode 100644 index 6aec4ac632..0000000000 --- a/.github/workflows/test-integration-rq.yml +++ /dev/null @@ -1,115 +0,0 @@ -name: Test rq - -on: - push: - branches: - - master - - release/** - - pull_request: - -# Cancel in progress workflows on pull_requests. -# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - -env: - BUILD_CACHE_KEY: ${{ github.sha }} - CACHED_BUILD_PATHS: | - ${{ github.workspace }}/dist-serverless - -jobs: - test: - name: rq, python ${{ matrix.python-version }}, ${{ matrix.os }} - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - - strategy: - fail-fast: false - matrix: - python-version: ["3.5","3.6","3.7","3.8","3.9","3.10","3.11"] - # python3.6 reached EOL and is no longer being supported on - # new versions of hosted runners on Github Actions - # ubuntu-20.04 is the last version that supported python3.6 - # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 - os: [ubuntu-20.04] - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - - - name: Test rq - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py${{ matrix.python-version }}-rq" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - - uses: codecov/codecov-action@v3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: coverage.xml - - test-py27: - name: rq, python 2.7, ubuntu-20.04 - runs-on: ubuntu-20.04 - container: python:2.7 - timeout-minutes: 30 - - steps: - - uses: actions/checkout@v4 - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - - - name: Test rq - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py2.7-rq" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - check_required_tests: - name: All rq tests passed or skipped - needs: [test, test-py27] - # Always run this, even if a dependent job failed - if: always() - runs-on: ubuntu-20.04 - steps: - - name: Check for failures - if: contains(needs.test.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 - - name: Check for 2.7 failures - if: contains(needs.test-py27.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integration-sanic.yml b/.github/workflows/test-integration-sanic.yml deleted file mode 100644 index 27ca05eb6a..0000000000 --- a/.github/workflows/test-integration-sanic.yml +++ /dev/null @@ -1,83 +0,0 @@ -name: Test sanic - -on: - push: - branches: - - master - - release/** - - pull_request: - -# Cancel in progress workflows on pull_requests. -# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - -env: - BUILD_CACHE_KEY: ${{ github.sha }} - CACHED_BUILD_PATHS: | - ${{ github.workspace }}/dist-serverless - -jobs: - test: - name: sanic, python ${{ matrix.python-version }}, ${{ matrix.os }} - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - - strategy: - fail-fast: false - matrix: - python-version: ["3.5","3.6","3.7","3.8","3.9","3.10","3.11"] - # python3.6 reached EOL and is no longer being supported on - # new versions of hosted runners on Github Actions - # ubuntu-20.04 is the last version that supported python3.6 - # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 - os: [ubuntu-20.04] - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - - - name: Test sanic - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py${{ matrix.python-version }}-sanic" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - - uses: codecov/codecov-action@v3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: coverage.xml - - - check_required_tests: - name: All sanic tests passed or skipped - needs: test - # Always run this, even if a dependent job failed - if: always() - runs-on: ubuntu-20.04 - steps: - - name: Check for failures - if: contains(needs.test.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integration-sqlalchemy.yml b/.github/workflows/test-integration-sqlalchemy.yml deleted file mode 100644 index a45ede7a2f..0000000000 --- a/.github/workflows/test-integration-sqlalchemy.yml +++ /dev/null @@ -1,115 +0,0 @@ -name: Test sqlalchemy - -on: - push: - branches: - - master - - release/** - - pull_request: - -# Cancel in progress workflows on pull_requests. -# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - -env: - BUILD_CACHE_KEY: ${{ github.sha }} - CACHED_BUILD_PATHS: | - ${{ github.workspace }}/dist-serverless - -jobs: - test: - name: sqlalchemy, python ${{ matrix.python-version }}, ${{ matrix.os }} - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - - strategy: - fail-fast: false - matrix: - python-version: ["3.7","3.8","3.9","3.10","3.11"] - # python3.6 reached EOL and is no longer being supported on - # new versions of hosted runners on Github Actions - # ubuntu-20.04 is the last version that supported python3.6 - # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 - os: [ubuntu-20.04] - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - - - name: Test sqlalchemy - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py${{ matrix.python-version }}-sqlalchemy" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - - uses: codecov/codecov-action@v3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: coverage.xml - - test-py27: - name: sqlalchemy, python 2.7, ubuntu-20.04 - runs-on: ubuntu-20.04 - container: python:2.7 - timeout-minutes: 30 - - steps: - - uses: actions/checkout@v4 - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - - - name: Test sqlalchemy - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py2.7-sqlalchemy" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - check_required_tests: - name: All sqlalchemy tests passed or skipped - needs: [test, test-py27] - # Always run this, even if a dependent job failed - if: always() - runs-on: ubuntu-20.04 - steps: - - name: Check for failures - if: contains(needs.test.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 - - name: Check for 2.7 failures - if: contains(needs.test-py27.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integration-starlette.yml b/.github/workflows/test-integration-starlette.yml deleted file mode 100644 index e19578b95c..0000000000 --- a/.github/workflows/test-integration-starlette.yml +++ /dev/null @@ -1,83 +0,0 @@ -name: Test starlette - -on: - push: - branches: - - master - - release/** - - pull_request: - -# Cancel in progress workflows on pull_requests. -# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - -env: - BUILD_CACHE_KEY: ${{ github.sha }} - CACHED_BUILD_PATHS: | - ${{ github.workspace }}/dist-serverless - -jobs: - test: - name: starlette, python ${{ matrix.python-version }}, ${{ matrix.os }} - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - - strategy: - fail-fast: false - matrix: - python-version: ["3.7","3.8","3.9","3.10","3.11"] - # python3.6 reached EOL and is no longer being supported on - # new versions of hosted runners on Github Actions - # ubuntu-20.04 is the last version that supported python3.6 - # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 - os: [ubuntu-20.04] - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - - - name: Test starlette - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py${{ matrix.python-version }}-starlette" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - - uses: codecov/codecov-action@v3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: coverage.xml - - - check_required_tests: - name: All starlette tests passed or skipped - needs: test - # Always run this, even if a dependent job failed - if: always() - runs-on: ubuntu-20.04 - steps: - - name: Check for failures - if: contains(needs.test.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integration-starlite.yml b/.github/workflows/test-integration-starlite.yml deleted file mode 100644 index 01715e1c66..0000000000 --- a/.github/workflows/test-integration-starlite.yml +++ /dev/null @@ -1,83 +0,0 @@ -name: Test starlite - -on: - push: - branches: - - master - - release/** - - pull_request: - -# Cancel in progress workflows on pull_requests. -# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - -env: - BUILD_CACHE_KEY: ${{ github.sha }} - CACHED_BUILD_PATHS: | - ${{ github.workspace }}/dist-serverless - -jobs: - test: - name: starlite, python ${{ matrix.python-version }}, ${{ matrix.os }} - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - - strategy: - fail-fast: false - matrix: - python-version: ["3.8","3.9","3.10","3.11"] - # python3.6 reached EOL and is no longer being supported on - # new versions of hosted runners on Github Actions - # ubuntu-20.04 is the last version that supported python3.6 - # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 - os: [ubuntu-20.04] - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - - - name: Test starlite - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py${{ matrix.python-version }}-starlite" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - - uses: codecov/codecov-action@v3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: coverage.xml - - - check_required_tests: - name: All starlite tests passed or skipped - needs: test - # Always run this, even if a dependent job failed - if: always() - runs-on: ubuntu-20.04 - steps: - - name: Check for failures - if: contains(needs.test.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integration-strawberry.yml b/.github/workflows/test-integration-strawberry.yml deleted file mode 100644 index b0e30a8f5b..0000000000 --- a/.github/workflows/test-integration-strawberry.yml +++ /dev/null @@ -1,83 +0,0 @@ -name: Test strawberry - -on: - push: - branches: - - master - - release/** - - pull_request: - -# Cancel in progress workflows on pull_requests. -# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - -env: - BUILD_CACHE_KEY: ${{ github.sha }} - CACHED_BUILD_PATHS: | - ${{ github.workspace }}/dist-serverless - -jobs: - test: - name: strawberry, python ${{ matrix.python-version }}, ${{ matrix.os }} - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - - strategy: - fail-fast: false - matrix: - python-version: ["3.8","3.9","3.10","3.11"] - # python3.6 reached EOL and is no longer being supported on - # new versions of hosted runners on Github Actions - # ubuntu-20.04 is the last version that supported python3.6 - # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 - os: [ubuntu-20.04] - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - - - name: Test strawberry - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py${{ matrix.python-version }}-strawberry" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - - uses: codecov/codecov-action@v3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: coverage.xml - - - check_required_tests: - name: All strawberry tests passed or skipped - needs: test - # Always run this, even if a dependent job failed - if: always() - runs-on: ubuntu-20.04 - steps: - - name: Check for failures - if: contains(needs.test.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integration-tornado.yml b/.github/workflows/test-integration-tornado.yml deleted file mode 100644 index ac4700db4a..0000000000 --- a/.github/workflows/test-integration-tornado.yml +++ /dev/null @@ -1,83 +0,0 @@ -name: Test tornado - -on: - push: - branches: - - master - - release/** - - pull_request: - -# Cancel in progress workflows on pull_requests. -# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - -env: - BUILD_CACHE_KEY: ${{ github.sha }} - CACHED_BUILD_PATHS: | - ${{ github.workspace }}/dist-serverless - -jobs: - test: - name: tornado, python ${{ matrix.python-version }}, ${{ matrix.os }} - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - - strategy: - fail-fast: false - matrix: - python-version: ["3.7","3.8","3.9","3.10","3.11"] - # python3.6 reached EOL and is no longer being supported on - # new versions of hosted runners on Github Actions - # ubuntu-20.04 is the last version that supported python3.6 - # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 - os: [ubuntu-20.04] - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - - - name: Test tornado - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py${{ matrix.python-version }}-tornado" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - - uses: codecov/codecov-action@v3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: coverage.xml - - - check_required_tests: - name: All tornado tests passed or skipped - needs: test - # Always run this, even if a dependent job failed - if: always() - runs-on: ubuntu-20.04 - steps: - - name: Check for failures - if: contains(needs.test.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integration-trytond.yml b/.github/workflows/test-integration-trytond.yml deleted file mode 100644 index 130ed096f7..0000000000 --- a/.github/workflows/test-integration-trytond.yml +++ /dev/null @@ -1,83 +0,0 @@ -name: Test trytond - -on: - push: - branches: - - master - - release/** - - pull_request: - -# Cancel in progress workflows on pull_requests. -# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - -env: - BUILD_CACHE_KEY: ${{ github.sha }} - CACHED_BUILD_PATHS: | - ${{ github.workspace }}/dist-serverless - -jobs: - test: - name: trytond, python ${{ matrix.python-version }}, ${{ matrix.os }} - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - - strategy: - fail-fast: false - matrix: - python-version: ["3.5","3.6","3.7","3.8","3.9","3.10","3.11"] - # python3.6 reached EOL and is no longer being supported on - # new versions of hosted runners on Github Actions - # ubuntu-20.04 is the last version that supported python3.6 - # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 - os: [ubuntu-20.04] - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - - - name: Test trytond - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py${{ matrix.python-version }}-trytond" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - - uses: codecov/codecov-action@v3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: coverage.xml - - - check_required_tests: - name: All trytond tests passed or skipped - needs: test - # Always run this, even if a dependent job failed - if: always() - runs-on: ubuntu-20.04 - steps: - - name: Check for failures - if: contains(needs.test.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integrations-agents.yml b/.github/workflows/test-integrations-agents.yml new file mode 100644 index 0000000000..e2be8508ea --- /dev/null +++ b/.github/workflows/test-integrations-agents.yml @@ -0,0 +1,98 @@ +# Do not edit this YAML file. This file is generated automatically by executing +# python scripts/split_tox_gh_actions/split_tox_gh_actions.py +# The template responsible for it is in +# scripts/split_tox_gh_actions/templates/base.jinja +name: Test Agents +on: + push: + branches: + - master + - release/** + - major/** + pull_request: +# Cancel in progress workflows on pull_requests. +# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true +permissions: + contents: read +env: + BUILD_CACHE_KEY: ${{ github.sha }} + CACHED_BUILD_PATHS: | + ${{ github.workspace }}/dist-serverless +jobs: + test-agents: + name: Agents + timeout-minutes: 30 + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + python-version: ["3.10","3.11","3.12","3.13","3.14","3.14t"] + # python3.6 reached EOL and is no longer being supported on + # new versions of hosted runners on Github Actions + # ubuntu-20.04 is the last version that supported python3.6 + # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 + os: [ubuntu-22.04] + # Use Docker container only for Python 3.6 + container: ${{ matrix.python-version == '3.6' && 'python:3.6' || null }} + steps: + - uses: actions/checkout@v6.0.1 + - uses: actions/setup-python@v6 + if: ${{ matrix.python-version != '3.6' }} + with: + python-version: ${{ matrix.python-version }} + allow-prereleases: true + - name: Setup Test Env + run: | + pip install "coverage[toml]" tox + - name: Erase coverage + run: | + coverage erase + - name: Test openai_agents + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-openai_agents" + - name: Test pydantic_ai + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-pydantic_ai" + - name: Generate coverage XML (Python 3.6) + if: ${{ !cancelled() && matrix.python-version == '3.6' }} + run: | + export COVERAGE_RCFILE=.coveragerc36 + coverage combine .coverage-sentry-* + coverage xml --ignore-errors + - name: Generate coverage XML + if: ${{ !cancelled() && matrix.python-version != '3.6' }} + run: | + coverage combine .coverage-sentry-* + coverage xml + - name: Upload coverage to Codecov + if: ${{ !cancelled() }} + uses: codecov/codecov-action@v5.5.2 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: coverage.xml + # make sure no plugins alter our coverage reports + plugins: noop + verbose: true + - name: Upload test results to Codecov + if: ${{ !cancelled() }} + uses: codecov/test-results-action@v1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: .junitxml + verbose: true + check_required_tests: + name: All Agents tests passed + needs: test-agents + # Always run this, even if a dependent job failed + if: always() + runs-on: ubuntu-22.04 + steps: + - name: Check for failures + if: needs.test-agents.result != 'success' + run: | + echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integrations-ai-workflow.yml b/.github/workflows/test-integrations-ai-workflow.yml new file mode 100644 index 0000000000..d641becd25 --- /dev/null +++ b/.github/workflows/test-integrations-ai-workflow.yml @@ -0,0 +1,102 @@ +# Do not edit this YAML file. This file is generated automatically by executing +# python scripts/split_tox_gh_actions/split_tox_gh_actions.py +# The template responsible for it is in +# scripts/split_tox_gh_actions/templates/base.jinja +name: Test AI Workflow +on: + push: + branches: + - master + - release/** + - major/** + pull_request: +# Cancel in progress workflows on pull_requests. +# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true +permissions: + contents: read +env: + BUILD_CACHE_KEY: ${{ github.sha }} + CACHED_BUILD_PATHS: | + ${{ github.workspace }}/dist-serverless +jobs: + test-ai_workflow: + name: AI Workflow + timeout-minutes: 30 + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + python-version: ["3.9","3.10","3.11","3.12","3.13","3.14"] + # python3.6 reached EOL and is no longer being supported on + # new versions of hosted runners on Github Actions + # ubuntu-20.04 is the last version that supported python3.6 + # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 + os: [ubuntu-22.04] + # Use Docker container only for Python 3.6 + container: ${{ matrix.python-version == '3.6' && 'python:3.6' || null }} + steps: + - uses: actions/checkout@v6.0.1 + - uses: actions/setup-python@v6 + if: ${{ matrix.python-version != '3.6' }} + with: + python-version: ${{ matrix.python-version }} + allow-prereleases: true + - name: Setup Test Env + run: | + pip install "coverage[toml]" tox + - name: Erase coverage + run: | + coverage erase + - name: Test langchain-base + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-langchain-base" + - name: Test langchain-notiktoken + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-langchain-notiktoken" + - name: Test langgraph + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-langgraph" + - name: Generate coverage XML (Python 3.6) + if: ${{ !cancelled() && matrix.python-version == '3.6' }} + run: | + export COVERAGE_RCFILE=.coveragerc36 + coverage combine .coverage-sentry-* + coverage xml --ignore-errors + - name: Generate coverage XML + if: ${{ !cancelled() && matrix.python-version != '3.6' }} + run: | + coverage combine .coverage-sentry-* + coverage xml + - name: Upload coverage to Codecov + if: ${{ !cancelled() }} + uses: codecov/codecov-action@v5.5.2 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: coverage.xml + # make sure no plugins alter our coverage reports + plugins: noop + verbose: true + - name: Upload test results to Codecov + if: ${{ !cancelled() }} + uses: codecov/test-results-action@v1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: .junitxml + verbose: true + check_required_tests: + name: All AI Workflow tests passed + needs: test-ai_workflow + # Always run this, even if a dependent job failed + if: always() + runs-on: ubuntu-22.04 + steps: + - name: Check for failures + if: needs.test-ai_workflow.result != 'success' + run: | + echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integrations-ai.yml b/.github/workflows/test-integrations-ai.yml new file mode 100644 index 0000000000..fc1a9f7b90 --- /dev/null +++ b/.github/workflows/test-integrations-ai.yml @@ -0,0 +1,118 @@ +# Do not edit this YAML file. This file is generated automatically by executing +# python scripts/split_tox_gh_actions/split_tox_gh_actions.py +# The template responsible for it is in +# scripts/split_tox_gh_actions/templates/base.jinja +name: Test AI +on: + push: + branches: + - master + - release/** + - major/** + pull_request: +# Cancel in progress workflows on pull_requests. +# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true +permissions: + contents: read +env: + BUILD_CACHE_KEY: ${{ github.sha }} + CACHED_BUILD_PATHS: | + ${{ github.workspace }}/dist-serverless +jobs: + test-ai: + name: AI + timeout-minutes: 30 + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + python-version: ["3.8","3.9","3.10","3.11","3.12","3.13","3.14","3.14t"] + # python3.6 reached EOL and is no longer being supported on + # new versions of hosted runners on Github Actions + # ubuntu-20.04 is the last version that supported python3.6 + # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 + os: [ubuntu-22.04] + # Use Docker container only for Python 3.6 + container: ${{ matrix.python-version == '3.6' && 'python:3.6' || null }} + steps: + - uses: actions/checkout@v6.0.1 + - uses: actions/setup-python@v6 + if: ${{ matrix.python-version != '3.6' }} + with: + python-version: ${{ matrix.python-version }} + allow-prereleases: true + - name: Setup Test Env + run: | + pip install "coverage[toml]" tox + - name: Erase coverage + run: | + coverage erase + - name: Test anthropic + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-anthropic" + - name: Test cohere + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-cohere" + - name: Test google_genai + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-google_genai" + - name: Test huggingface_hub + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-huggingface_hub" + - name: Test litellm + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-litellm" + - name: Test openai-base + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-openai-base" + - name: Test openai-notiktoken + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-openai-notiktoken" + - name: Generate coverage XML (Python 3.6) + if: ${{ !cancelled() && matrix.python-version == '3.6' }} + run: | + export COVERAGE_RCFILE=.coveragerc36 + coverage combine .coverage-sentry-* + coverage xml --ignore-errors + - name: Generate coverage XML + if: ${{ !cancelled() && matrix.python-version != '3.6' }} + run: | + coverage combine .coverage-sentry-* + coverage xml + - name: Upload coverage to Codecov + if: ${{ !cancelled() }} + uses: codecov/codecov-action@v5.5.2 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: coverage.xml + # make sure no plugins alter our coverage reports + plugins: noop + verbose: true + - name: Upload test results to Codecov + if: ${{ !cancelled() }} + uses: codecov/test-results-action@v1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: .junitxml + verbose: true + check_required_tests: + name: All AI tests passed + needs: test-ai + # Always run this, even if a dependent job failed + if: always() + runs-on: ubuntu-22.04 + steps: + - name: Check for failures + if: needs.test-ai.result != 'success' + run: | + echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integrations-cloud.yml b/.github/workflows/test-integrations-cloud.yml new file mode 100644 index 0000000000..d655af0a1a --- /dev/null +++ b/.github/workflows/test-integrations-cloud.yml @@ -0,0 +1,114 @@ +# Do not edit this YAML file. This file is generated automatically by executing +# python scripts/split_tox_gh_actions/split_tox_gh_actions.py +# The template responsible for it is in +# scripts/split_tox_gh_actions/templates/base.jinja +name: Test Cloud +on: + push: + branches: + - master + - release/** + - major/** + pull_request: +# Cancel in progress workflows on pull_requests. +# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true +permissions: + contents: read +env: + BUILD_CACHE_KEY: ${{ github.sha }} + CACHED_BUILD_PATHS: | + ${{ github.workspace }}/dist-serverless +jobs: + test-cloud: + name: Cloud + timeout-minutes: 30 + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + python-version: ["3.6","3.7","3.8","3.9","3.10","3.11","3.12","3.13","3.14","3.14t"] + # python3.6 reached EOL and is no longer being supported on + # new versions of hosted runners on Github Actions + # ubuntu-20.04 is the last version that supported python3.6 + # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 + os: [ubuntu-22.04] + services: + docker: + image: docker:dind # Required for Docker network management + options: --privileged # Required for Docker-in-Docker operations + # Use Docker container only for Python 3.6 + container: ${{ matrix.python-version == '3.6' && 'python:3.6' || null }} + steps: + - uses: actions/checkout@v6.0.1 + - uses: actions/setup-python@v6 + if: ${{ matrix.python-version != '3.6' }} + with: + python-version: ${{ matrix.python-version }} + allow-prereleases: true + - name: Setup Test Env + run: | + pip install "coverage[toml]" tox + - name: Erase coverage + run: | + coverage erase + - name: Test aws_lambda + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-aws_lambda" + - name: Test boto3 + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-boto3" + - name: Test chalice + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-chalice" + - name: Test cloud_resource_context + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-cloud_resource_context" + - name: Test gcp + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-gcp" + - name: Generate coverage XML (Python 3.6) + if: ${{ !cancelled() && matrix.python-version == '3.6' }} + run: | + export COVERAGE_RCFILE=.coveragerc36 + coverage combine .coverage-sentry-* + coverage xml --ignore-errors + - name: Generate coverage XML + if: ${{ !cancelled() && matrix.python-version != '3.6' }} + run: | + coverage combine .coverage-sentry-* + coverage xml + - name: Upload coverage to Codecov + if: ${{ !cancelled() }} + uses: codecov/codecov-action@v5.5.2 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: coverage.xml + # make sure no plugins alter our coverage reports + plugins: noop + verbose: true + - name: Upload test results to Codecov + if: ${{ !cancelled() }} + uses: codecov/test-results-action@v1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: .junitxml + verbose: true + check_required_tests: + name: All Cloud tests passed + needs: test-cloud + # Always run this, even if a dependent job failed + if: always() + runs-on: ubuntu-22.04 + steps: + - name: Check for failures + if: needs.test-cloud.result != 'success' + run: | + echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integrations-common.yml b/.github/workflows/test-integrations-common.yml new file mode 100644 index 0000000000..b87b8e56d2 --- /dev/null +++ b/.github/workflows/test-integrations-common.yml @@ -0,0 +1,94 @@ +# Do not edit this YAML file. This file is generated automatically by executing +# python scripts/split_tox_gh_actions/split_tox_gh_actions.py +# The template responsible for it is in +# scripts/split_tox_gh_actions/templates/base.jinja +name: Test Common +on: + push: + branches: + - master + - release/** + - major/** + pull_request: +# Cancel in progress workflows on pull_requests. +# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true +permissions: + contents: read +env: + BUILD_CACHE_KEY: ${{ github.sha }} + CACHED_BUILD_PATHS: | + ${{ github.workspace }}/dist-serverless +jobs: + test-common: + name: Common + timeout-minutes: 30 + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + python-version: ["3.6","3.7","3.8","3.9","3.10","3.11","3.12","3.13","3.14","3.14t"] + # python3.6 reached EOL and is no longer being supported on + # new versions of hosted runners on Github Actions + # ubuntu-20.04 is the last version that supported python3.6 + # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 + os: [ubuntu-22.04] + # Use Docker container only for Python 3.6 + container: ${{ matrix.python-version == '3.6' && 'python:3.6' || null }} + steps: + - uses: actions/checkout@v6.0.1 + - uses: actions/setup-python@v6 + if: ${{ matrix.python-version != '3.6' }} + with: + python-version: ${{ matrix.python-version }} + allow-prereleases: true + - name: Setup Test Env + run: | + pip install "coverage[toml]" tox + - name: Erase coverage + run: | + coverage erase + - name: Test common + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-common" + - name: Generate coverage XML (Python 3.6) + if: ${{ !cancelled() && matrix.python-version == '3.6' }} + run: | + export COVERAGE_RCFILE=.coveragerc36 + coverage combine .coverage-sentry-* + coverage xml --ignore-errors + - name: Generate coverage XML + if: ${{ !cancelled() && matrix.python-version != '3.6' }} + run: | + coverage combine .coverage-sentry-* + coverage xml + - name: Upload coverage to Codecov + if: ${{ !cancelled() }} + uses: codecov/codecov-action@v5.5.2 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: coverage.xml + # make sure no plugins alter our coverage reports + plugins: noop + verbose: true + - name: Upload test results to Codecov + if: ${{ !cancelled() }} + uses: codecov/test-results-action@v1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: .junitxml + verbose: true + check_required_tests: + name: All Common tests passed + needs: test-common + # Always run this, even if a dependent job failed + if: always() + runs-on: ubuntu-22.04 + steps: + - name: Check for failures + if: needs.test-common.result != 'success' + run: | + echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integrations-dbs.yml b/.github/workflows/test-integrations-dbs.yml new file mode 100644 index 0000000000..4638525be7 --- /dev/null +++ b/.github/workflows/test-integrations-dbs.yml @@ -0,0 +1,134 @@ +# Do not edit this YAML file. This file is generated automatically by executing +# python scripts/split_tox_gh_actions/split_tox_gh_actions.py +# The template responsible for it is in +# scripts/split_tox_gh_actions/templates/base.jinja +name: Test DBs +on: + push: + branches: + - master + - release/** + - major/** + pull_request: +# Cancel in progress workflows on pull_requests. +# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true +permissions: + contents: read +env: + BUILD_CACHE_KEY: ${{ github.sha }} + CACHED_BUILD_PATHS: | + ${{ github.workspace }}/dist-serverless +jobs: + test-dbs: + name: DBs + timeout-minutes: 30 + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + python-version: ["3.6","3.7","3.8","3.9","3.10","3.11","3.12","3.13","3.14","3.14t"] + # python3.6 reached EOL and is no longer being supported on + # new versions of hosted runners on Github Actions + # ubuntu-20.04 is the last version that supported python3.6 + # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 + os: [ubuntu-22.04] + services: + postgres: + image: postgres + env: + POSTGRES_PASSWORD: sentry + # Set health checks to wait until postgres has started + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + # Maps tcp port 5432 on service container to the host + ports: + - 5432:5432 + env: + SENTRY_PYTHON_TEST_POSTGRES_HOST: ${{ matrix.python-version == '3.6' && 'postgres' || 'localhost' }} + SENTRY_PYTHON_TEST_POSTGRES_USER: postgres + SENTRY_PYTHON_TEST_POSTGRES_PASSWORD: sentry + # Use Docker container only for Python 3.6 + container: ${{ matrix.python-version == '3.6' && 'python:3.6' || null }} + steps: + - uses: actions/checkout@v6.0.1 + - uses: actions/setup-python@v6 + if: ${{ matrix.python-version != '3.6' }} + with: + python-version: ${{ matrix.python-version }} + allow-prereleases: true + - name: "Setup ClickHouse Server" + uses: getsentry/action-clickhouse-in-ci@v1.6 + - name: Setup Test Env + run: | + pip install "coverage[toml]" tox + - name: Erase coverage + run: | + coverage erase + - name: Test asyncpg + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-asyncpg" + - name: Test clickhouse_driver + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-clickhouse_driver" + - name: Test pymongo + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-pymongo" + - name: Test redis + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-redis" + - name: Test redis_py_cluster_legacy + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-redis_py_cluster_legacy" + - name: Test sqlalchemy + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-sqlalchemy" + - name: Generate coverage XML (Python 3.6) + if: ${{ !cancelled() && matrix.python-version == '3.6' }} + run: | + export COVERAGE_RCFILE=.coveragerc36 + coverage combine .coverage-sentry-* + coverage xml --ignore-errors + - name: Generate coverage XML + if: ${{ !cancelled() && matrix.python-version != '3.6' }} + run: | + coverage combine .coverage-sentry-* + coverage xml + - name: Upload coverage to Codecov + if: ${{ !cancelled() }} + uses: codecov/codecov-action@v5.5.2 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: coverage.xml + # make sure no plugins alter our coverage reports + plugins: noop + verbose: true + - name: Upload test results to Codecov + if: ${{ !cancelled() }} + uses: codecov/test-results-action@v1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: .junitxml + verbose: true + check_required_tests: + name: All DBs tests passed + needs: test-dbs + # Always run this, even if a dependent job failed + if: always() + runs-on: ubuntu-22.04 + steps: + - name: Check for failures + if: needs.test-dbs.result != 'success' + run: | + echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integrations-flags.yml b/.github/workflows/test-integrations-flags.yml new file mode 100644 index 0000000000..9c3c647937 --- /dev/null +++ b/.github/workflows/test-integrations-flags.yml @@ -0,0 +1,106 @@ +# Do not edit this YAML file. This file is generated automatically by executing +# python scripts/split_tox_gh_actions/split_tox_gh_actions.py +# The template responsible for it is in +# scripts/split_tox_gh_actions/templates/base.jinja +name: Test Flags +on: + push: + branches: + - master + - release/** + - major/** + pull_request: +# Cancel in progress workflows on pull_requests. +# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true +permissions: + contents: read +env: + BUILD_CACHE_KEY: ${{ github.sha }} + CACHED_BUILD_PATHS: | + ${{ github.workspace }}/dist-serverless +jobs: + test-flags: + name: Flags + timeout-minutes: 30 + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + python-version: ["3.7","3.8","3.9","3.12","3.13","3.14","3.14t"] + # python3.6 reached EOL and is no longer being supported on + # new versions of hosted runners on Github Actions + # ubuntu-20.04 is the last version that supported python3.6 + # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 + os: [ubuntu-22.04] + # Use Docker container only for Python 3.6 + container: ${{ matrix.python-version == '3.6' && 'python:3.6' || null }} + steps: + - uses: actions/checkout@v6.0.1 + - uses: actions/setup-python@v6 + if: ${{ matrix.python-version != '3.6' }} + with: + python-version: ${{ matrix.python-version }} + allow-prereleases: true + - name: Setup Test Env + run: | + pip install "coverage[toml]" tox + - name: Erase coverage + run: | + coverage erase + - name: Test launchdarkly + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-launchdarkly" + - name: Test openfeature + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-openfeature" + - name: Test statsig + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-statsig" + - name: Test unleash + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-unleash" + - name: Generate coverage XML (Python 3.6) + if: ${{ !cancelled() && matrix.python-version == '3.6' }} + run: | + export COVERAGE_RCFILE=.coveragerc36 + coverage combine .coverage-sentry-* + coverage xml --ignore-errors + - name: Generate coverage XML + if: ${{ !cancelled() && matrix.python-version != '3.6' }} + run: | + coverage combine .coverage-sentry-* + coverage xml + - name: Upload coverage to Codecov + if: ${{ !cancelled() }} + uses: codecov/codecov-action@v5.5.2 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: coverage.xml + # make sure no plugins alter our coverage reports + plugins: noop + verbose: true + - name: Upload test results to Codecov + if: ${{ !cancelled() }} + uses: codecov/test-results-action@v1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: .junitxml + verbose: true + check_required_tests: + name: All Flags tests passed + needs: test-flags + # Always run this, even if a dependent job failed + if: always() + runs-on: ubuntu-22.04 + steps: + - name: Check for failures + if: needs.test-flags.result != 'success' + run: | + echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integrations-gevent.yml b/.github/workflows/test-integrations-gevent.yml new file mode 100644 index 0000000000..0d57e14224 --- /dev/null +++ b/.github/workflows/test-integrations-gevent.yml @@ -0,0 +1,94 @@ +# Do not edit this YAML file. This file is generated automatically by executing +# python scripts/split_tox_gh_actions/split_tox_gh_actions.py +# The template responsible for it is in +# scripts/split_tox_gh_actions/templates/base.jinja +name: Test Gevent +on: + push: + branches: + - master + - release/** + - major/** + pull_request: +# Cancel in progress workflows on pull_requests. +# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true +permissions: + contents: read +env: + BUILD_CACHE_KEY: ${{ github.sha }} + CACHED_BUILD_PATHS: | + ${{ github.workspace }}/dist-serverless +jobs: + test-gevent: + name: Gevent + timeout-minutes: 30 + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + python-version: ["3.6","3.8","3.10","3.11","3.12"] + # python3.6 reached EOL and is no longer being supported on + # new versions of hosted runners on Github Actions + # ubuntu-20.04 is the last version that supported python3.6 + # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 + os: [ubuntu-22.04] + # Use Docker container only for Python 3.6 + container: ${{ matrix.python-version == '3.6' && 'python:3.6' || null }} + steps: + - uses: actions/checkout@v6.0.1 + - uses: actions/setup-python@v6 + if: ${{ matrix.python-version != '3.6' }} + with: + python-version: ${{ matrix.python-version }} + allow-prereleases: true + - name: Setup Test Env + run: | + pip install "coverage[toml]" tox + - name: Erase coverage + run: | + coverage erase + - name: Test gevent + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-gevent" + - name: Generate coverage XML (Python 3.6) + if: ${{ !cancelled() && matrix.python-version == '3.6' }} + run: | + export COVERAGE_RCFILE=.coveragerc36 + coverage combine .coverage-sentry-* + coverage xml --ignore-errors + - name: Generate coverage XML + if: ${{ !cancelled() && matrix.python-version != '3.6' }} + run: | + coverage combine .coverage-sentry-* + coverage xml + - name: Upload coverage to Codecov + if: ${{ !cancelled() }} + uses: codecov/codecov-action@v5.5.2 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: coverage.xml + # make sure no plugins alter our coverage reports + plugins: noop + verbose: true + - name: Upload test results to Codecov + if: ${{ !cancelled() }} + uses: codecov/test-results-action@v1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: .junitxml + verbose: true + check_required_tests: + name: All Gevent tests passed + needs: test-gevent + # Always run this, even if a dependent job failed + if: always() + runs-on: ubuntu-22.04 + steps: + - name: Check for failures + if: needs.test-gevent.result != 'success' + run: | + echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integrations-graphql.yml b/.github/workflows/test-integrations-graphql.yml new file mode 100644 index 0000000000..8e27210148 --- /dev/null +++ b/.github/workflows/test-integrations-graphql.yml @@ -0,0 +1,106 @@ +# Do not edit this YAML file. This file is generated automatically by executing +# python scripts/split_tox_gh_actions/split_tox_gh_actions.py +# The template responsible for it is in +# scripts/split_tox_gh_actions/templates/base.jinja +name: Test GraphQL +on: + push: + branches: + - master + - release/** + - major/** + pull_request: +# Cancel in progress workflows on pull_requests. +# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true +permissions: + contents: read +env: + BUILD_CACHE_KEY: ${{ github.sha }} + CACHED_BUILD_PATHS: | + ${{ github.workspace }}/dist-serverless +jobs: + test-graphql: + name: GraphQL + timeout-minutes: 30 + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + python-version: ["3.6","3.8","3.9","3.10","3.11","3.12","3.13","3.14","3.14t"] + # python3.6 reached EOL and is no longer being supported on + # new versions of hosted runners on Github Actions + # ubuntu-20.04 is the last version that supported python3.6 + # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 + os: [ubuntu-22.04] + # Use Docker container only for Python 3.6 + container: ${{ matrix.python-version == '3.6' && 'python:3.6' || null }} + steps: + - uses: actions/checkout@v6.0.1 + - uses: actions/setup-python@v6 + if: ${{ matrix.python-version != '3.6' }} + with: + python-version: ${{ matrix.python-version }} + allow-prereleases: true + - name: Setup Test Env + run: | + pip install "coverage[toml]" tox + - name: Erase coverage + run: | + coverage erase + - name: Test ariadne + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-ariadne" + - name: Test gql + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-gql" + - name: Test graphene + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-graphene" + - name: Test strawberry + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-strawberry" + - name: Generate coverage XML (Python 3.6) + if: ${{ !cancelled() && matrix.python-version == '3.6' }} + run: | + export COVERAGE_RCFILE=.coveragerc36 + coverage combine .coverage-sentry-* + coverage xml --ignore-errors + - name: Generate coverage XML + if: ${{ !cancelled() && matrix.python-version != '3.6' }} + run: | + coverage combine .coverage-sentry-* + coverage xml + - name: Upload coverage to Codecov + if: ${{ !cancelled() }} + uses: codecov/codecov-action@v5.5.2 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: coverage.xml + # make sure no plugins alter our coverage reports + plugins: noop + verbose: true + - name: Upload test results to Codecov + if: ${{ !cancelled() }} + uses: codecov/test-results-action@v1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: .junitxml + verbose: true + check_required_tests: + name: All GraphQL tests passed + needs: test-graphql + # Always run this, even if a dependent job failed + if: always() + runs-on: ubuntu-22.04 + steps: + - name: Check for failures + if: needs.test-graphql.result != 'success' + run: | + echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integrations-mcp.yml b/.github/workflows/test-integrations-mcp.yml new file mode 100644 index 0000000000..e986d1e358 --- /dev/null +++ b/.github/workflows/test-integrations-mcp.yml @@ -0,0 +1,98 @@ +# Do not edit this YAML file. This file is generated automatically by executing +# python scripts/split_tox_gh_actions/split_tox_gh_actions.py +# The template responsible for it is in +# scripts/split_tox_gh_actions/templates/base.jinja +name: Test MCP +on: + push: + branches: + - master + - release/** + - major/** + pull_request: +# Cancel in progress workflows on pull_requests. +# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true +permissions: + contents: read +env: + BUILD_CACHE_KEY: ${{ github.sha }} + CACHED_BUILD_PATHS: | + ${{ github.workspace }}/dist-serverless +jobs: + test-mcp: + name: MCP + timeout-minutes: 30 + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + python-version: ["3.10","3.12","3.13","3.14","3.14t"] + # python3.6 reached EOL and is no longer being supported on + # new versions of hosted runners on Github Actions + # ubuntu-20.04 is the last version that supported python3.6 + # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 + os: [ubuntu-22.04] + # Use Docker container only for Python 3.6 + container: ${{ matrix.python-version == '3.6' && 'python:3.6' || null }} + steps: + - uses: actions/checkout@v6.0.1 + - uses: actions/setup-python@v6 + if: ${{ matrix.python-version != '3.6' }} + with: + python-version: ${{ matrix.python-version }} + allow-prereleases: true + - name: Setup Test Env + run: | + pip install "coverage[toml]" tox + - name: Erase coverage + run: | + coverage erase + - name: Test mcp + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-mcp" + - name: Test fastmcp + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-fastmcp" + - name: Generate coverage XML (Python 3.6) + if: ${{ !cancelled() && matrix.python-version == '3.6' }} + run: | + export COVERAGE_RCFILE=.coveragerc36 + coverage combine .coverage-sentry-* + coverage xml --ignore-errors + - name: Generate coverage XML + if: ${{ !cancelled() && matrix.python-version != '3.6' }} + run: | + coverage combine .coverage-sentry-* + coverage xml + - name: Upload coverage to Codecov + if: ${{ !cancelled() }} + uses: codecov/codecov-action@v5.5.2 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: coverage.xml + # make sure no plugins alter our coverage reports + plugins: noop + verbose: true + - name: Upload test results to Codecov + if: ${{ !cancelled() }} + uses: codecov/test-results-action@v1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: .junitxml + verbose: true + check_required_tests: + name: All MCP tests passed + needs: test-mcp + # Always run this, even if a dependent job failed + if: always() + runs-on: ubuntu-22.04 + steps: + - name: Check for failures + if: needs.test-mcp.result != 'success' + run: | + echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integrations-misc.yml b/.github/workflows/test-integrations-misc.yml new file mode 100644 index 0000000000..16d8a5f1a9 --- /dev/null +++ b/.github/workflows/test-integrations-misc.yml @@ -0,0 +1,126 @@ +# Do not edit this YAML file. This file is generated automatically by executing +# python scripts/split_tox_gh_actions/split_tox_gh_actions.py +# The template responsible for it is in +# scripts/split_tox_gh_actions/templates/base.jinja +name: Test Misc +on: + push: + branches: + - master + - release/** + - major/** + pull_request: +# Cancel in progress workflows on pull_requests. +# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true +permissions: + contents: read +env: + BUILD_CACHE_KEY: ${{ github.sha }} + CACHED_BUILD_PATHS: | + ${{ github.workspace }}/dist-serverless +jobs: + test-misc: + name: Misc + timeout-minutes: 30 + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + python-version: ["3.6","3.7","3.8","3.9","3.10","3.11","3.12","3.13","3.14","3.14t"] + # python3.6 reached EOL and is no longer being supported on + # new versions of hosted runners on Github Actions + # ubuntu-20.04 is the last version that supported python3.6 + # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 + os: [ubuntu-22.04] + # Use Docker container only for Python 3.6 + container: ${{ matrix.python-version == '3.6' && 'python:3.6' || null }} + steps: + - uses: actions/checkout@v6.0.1 + - uses: actions/setup-python@v6 + if: ${{ matrix.python-version != '3.6' }} + with: + python-version: ${{ matrix.python-version }} + allow-prereleases: true + - name: Setup Test Env + run: | + pip install "coverage[toml]" tox + - name: Erase coverage + run: | + coverage erase + - name: Test loguru + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-loguru" + - name: Test opentelemetry + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-opentelemetry" + - name: Test otlp + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-otlp" + - name: Test potel + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-potel" + - name: Test pure_eval + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-pure_eval" + - name: Test trytond + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-trytond" + - name: Test typer + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-typer" + - name: Test integration_deactivation + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-integration_deactivation" + - name: Test shadowed_module + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-shadowed_module" + - name: Generate coverage XML (Python 3.6) + if: ${{ !cancelled() && matrix.python-version == '3.6' }} + run: | + export COVERAGE_RCFILE=.coveragerc36 + coverage combine .coverage-sentry-* + coverage xml --ignore-errors + - name: Generate coverage XML + if: ${{ !cancelled() && matrix.python-version != '3.6' }} + run: | + coverage combine .coverage-sentry-* + coverage xml + - name: Upload coverage to Codecov + if: ${{ !cancelled() }} + uses: codecov/codecov-action@v5.5.2 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: coverage.xml + # make sure no plugins alter our coverage reports + plugins: noop + verbose: true + - name: Upload test results to Codecov + if: ${{ !cancelled() }} + uses: codecov/test-results-action@v1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: .junitxml + verbose: true + check_required_tests: + name: All Misc tests passed + needs: test-misc + # Always run this, even if a dependent job failed + if: always() + runs-on: ubuntu-22.04 + steps: + - name: Check for failures + if: needs.test-misc.result != 'success' + run: | + echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integrations-network.yml b/.github/workflows/test-integrations-network.yml new file mode 100644 index 0000000000..af0ed3cd09 --- /dev/null +++ b/.github/workflows/test-integrations-network.yml @@ -0,0 +1,102 @@ +# Do not edit this YAML file. This file is generated automatically by executing +# python scripts/split_tox_gh_actions/split_tox_gh_actions.py +# The template responsible for it is in +# scripts/split_tox_gh_actions/templates/base.jinja +name: Test Network +on: + push: + branches: + - master + - release/** + - major/** + pull_request: +# Cancel in progress workflows on pull_requests. +# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true +permissions: + contents: read +env: + BUILD_CACHE_KEY: ${{ github.sha }} + CACHED_BUILD_PATHS: | + ${{ github.workspace }}/dist-serverless +jobs: + test-network: + name: Network + timeout-minutes: 30 + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + python-version: ["3.6","3.7","3.8","3.9","3.10","3.11","3.12","3.13","3.14","3.14t"] + # python3.6 reached EOL and is no longer being supported on + # new versions of hosted runners on Github Actions + # ubuntu-20.04 is the last version that supported python3.6 + # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 + os: [ubuntu-22.04] + # Use Docker container only for Python 3.6 + container: ${{ matrix.python-version == '3.6' && 'python:3.6' || null }} + steps: + - uses: actions/checkout@v6.0.1 + - uses: actions/setup-python@v6 + if: ${{ matrix.python-version != '3.6' }} + with: + python-version: ${{ matrix.python-version }} + allow-prereleases: true + - name: Setup Test Env + run: | + pip install "coverage[toml]" tox + - name: Erase coverage + run: | + coverage erase + - name: Test grpc + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-grpc" + - name: Test httpx + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-httpx" + - name: Test requests + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-requests" + - name: Generate coverage XML (Python 3.6) + if: ${{ !cancelled() && matrix.python-version == '3.6' }} + run: | + export COVERAGE_RCFILE=.coveragerc36 + coverage combine .coverage-sentry-* + coverage xml --ignore-errors + - name: Generate coverage XML + if: ${{ !cancelled() && matrix.python-version != '3.6' }} + run: | + coverage combine .coverage-sentry-* + coverage xml + - name: Upload coverage to Codecov + if: ${{ !cancelled() }} + uses: codecov/codecov-action@v5.5.2 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: coverage.xml + # make sure no plugins alter our coverage reports + plugins: noop + verbose: true + - name: Upload test results to Codecov + if: ${{ !cancelled() }} + uses: codecov/test-results-action@v1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: .junitxml + verbose: true + check_required_tests: + name: All Network tests passed + needs: test-network + # Always run this, even if a dependent job failed + if: always() + runs-on: ubuntu-22.04 + steps: + - name: Check for failures + if: needs.test-network.result != 'success' + run: | + echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integrations-tasks.yml b/.github/workflows/test-integrations-tasks.yml new file mode 100644 index 0000000000..bf464d8f5c --- /dev/null +++ b/.github/workflows/test-integrations-tasks.yml @@ -0,0 +1,129 @@ +# Do not edit this YAML file. This file is generated automatically by executing +# python scripts/split_tox_gh_actions/split_tox_gh_actions.py +# The template responsible for it is in +# scripts/split_tox_gh_actions/templates/base.jinja +name: Test Tasks +on: + push: + branches: + - master + - release/** + - major/** + pull_request: +# Cancel in progress workflows on pull_requests. +# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true +permissions: + contents: read +env: + BUILD_CACHE_KEY: ${{ github.sha }} + CACHED_BUILD_PATHS: | + ${{ github.workspace }}/dist-serverless +jobs: + test-tasks: + name: Tasks + timeout-minutes: 30 + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + python-version: ["3.6","3.7","3.8","3.9","3.10","3.11","3.12","3.13","3.14","3.14t"] + # python3.6 reached EOL and is no longer being supported on + # new versions of hosted runners on Github Actions + # ubuntu-20.04 is the last version that supported python3.6 + # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 + os: [ubuntu-22.04] + # Use Docker container only for Python 3.6 + container: ${{ matrix.python-version == '3.6' && 'python:3.6' || null }} + steps: + - uses: actions/checkout@v6.0.1 + - uses: actions/setup-python@v6 + if: ${{ matrix.python-version != '3.6' }} + with: + python-version: ${{ matrix.python-version }} + allow-prereleases: true + - name: Start Redis + uses: supercharge/redis-github-action@v2 + - name: Install Java + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '21' + - name: Setup Test Env + run: | + pip install "coverage[toml]" tox + - name: Erase coverage + run: | + coverage erase + - name: Test arq + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-arq" + - name: Test beam + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-beam" + - name: Test celery + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-celery" + - name: Test dramatiq + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-dramatiq" + - name: Test huey + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-huey" + - name: Test ray + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-ray" + - name: Test rq + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-rq" + - name: Test spark + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-spark" + - name: Generate coverage XML (Python 3.6) + if: ${{ !cancelled() && matrix.python-version == '3.6' }} + run: | + export COVERAGE_RCFILE=.coveragerc36 + coverage combine .coverage-sentry-* + coverage xml --ignore-errors + - name: Generate coverage XML + if: ${{ !cancelled() && matrix.python-version != '3.6' }} + run: | + coverage combine .coverage-sentry-* + coverage xml + - name: Upload coverage to Codecov + if: ${{ !cancelled() }} + uses: codecov/codecov-action@v5.5.2 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: coverage.xml + # make sure no plugins alter our coverage reports + plugins: noop + verbose: true + - name: Upload test results to Codecov + if: ${{ !cancelled() }} + uses: codecov/test-results-action@v1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: .junitxml + verbose: true + check_required_tests: + name: All Tasks tests passed + needs: test-tasks + # Always run this, even if a dependent job failed + if: always() + runs-on: ubuntu-22.04 + steps: + - name: Check for failures + if: needs.test-tasks.result != 'success' + run: | + echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integrations-web-1.yml b/.github/workflows/test-integrations-web-1.yml new file mode 100644 index 0000000000..7f4c3d681f --- /dev/null +++ b/.github/workflows/test-integrations-web-1.yml @@ -0,0 +1,124 @@ +# Do not edit this YAML file. This file is generated automatically by executing +# python scripts/split_tox_gh_actions/split_tox_gh_actions.py +# The template responsible for it is in +# scripts/split_tox_gh_actions/templates/base.jinja +name: Test Web 1 +on: + push: + branches: + - master + - release/** + - major/** + pull_request: +# Cancel in progress workflows on pull_requests. +# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true +permissions: + contents: read +env: + BUILD_CACHE_KEY: ${{ github.sha }} + CACHED_BUILD_PATHS: | + ${{ github.workspace }}/dist-serverless +jobs: + test-web_1: + name: Web 1 + timeout-minutes: 30 + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + python-version: ["3.6","3.7","3.8","3.9","3.10","3.11","3.12","3.13","3.14","3.14t"] + # python3.6 reached EOL and is no longer being supported on + # new versions of hosted runners on Github Actions + # ubuntu-20.04 is the last version that supported python3.6 + # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 + os: [ubuntu-22.04] + services: + postgres: + image: postgres + env: + POSTGRES_PASSWORD: sentry + # Set health checks to wait until postgres has started + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + # Maps tcp port 5432 on service container to the host + ports: + - 5432:5432 + env: + SENTRY_PYTHON_TEST_POSTGRES_HOST: ${{ matrix.python-version == '3.6' && 'postgres' || 'localhost' }} + SENTRY_PYTHON_TEST_POSTGRES_USER: postgres + SENTRY_PYTHON_TEST_POSTGRES_PASSWORD: sentry + # Use Docker container only for Python 3.6 + container: ${{ matrix.python-version == '3.6' && 'python:3.6' || null }} + steps: + - uses: actions/checkout@v6.0.1 + - uses: actions/setup-python@v6 + if: ${{ matrix.python-version != '3.6' }} + with: + python-version: ${{ matrix.python-version }} + allow-prereleases: true + - name: Setup Test Env + run: | + pip install "coverage[toml]" tox + - name: Erase coverage + run: | + coverage erase + - name: Test django + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-django" + - name: Test flask + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-flask" + - name: Test starlette + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-starlette" + - name: Test fastapi + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-fastapi" + - name: Generate coverage XML (Python 3.6) + if: ${{ !cancelled() && matrix.python-version == '3.6' }} + run: | + export COVERAGE_RCFILE=.coveragerc36 + coverage combine .coverage-sentry-* + coverage xml --ignore-errors + - name: Generate coverage XML + if: ${{ !cancelled() && matrix.python-version != '3.6' }} + run: | + coverage combine .coverage-sentry-* + coverage xml + - name: Upload coverage to Codecov + if: ${{ !cancelled() }} + uses: codecov/codecov-action@v5.5.2 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: coverage.xml + # make sure no plugins alter our coverage reports + plugins: noop + verbose: true + - name: Upload test results to Codecov + if: ${{ !cancelled() }} + uses: codecov/test-results-action@v1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: .junitxml + verbose: true + check_required_tests: + name: All Web 1 tests passed + needs: test-web_1 + # Always run this, even if a dependent job failed + if: always() + runs-on: ubuntu-22.04 + steps: + - name: Check for failures + if: needs.test-web_1.result != 'success' + run: | + echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integrations-web-2.yml b/.github/workflows/test-integrations-web-2.yml new file mode 100644 index 0000000000..7de840df55 --- /dev/null +++ b/.github/workflows/test-integrations-web-2.yml @@ -0,0 +1,130 @@ +# Do not edit this YAML file. This file is generated automatically by executing +# python scripts/split_tox_gh_actions/split_tox_gh_actions.py +# The template responsible for it is in +# scripts/split_tox_gh_actions/templates/base.jinja +name: Test Web 2 +on: + push: + branches: + - master + - release/** + - major/** + pull_request: +# Cancel in progress workflows on pull_requests. +# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true +permissions: + contents: read +env: + BUILD_CACHE_KEY: ${{ github.sha }} + CACHED_BUILD_PATHS: | + ${{ github.workspace }}/dist-serverless +jobs: + test-web_2: + name: Web 2 + timeout-minutes: 30 + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + python-version: ["3.6","3.7","3.8","3.9","3.10","3.11","3.12","3.13","3.14","3.14t"] + # python3.6 reached EOL and is no longer being supported on + # new versions of hosted runners on Github Actions + # ubuntu-20.04 is the last version that supported python3.6 + # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 + os: [ubuntu-22.04] + # Use Docker container only for Python 3.6 + container: ${{ matrix.python-version == '3.6' && 'python:3.6' || null }} + steps: + - uses: actions/checkout@v6.0.1 + - uses: actions/setup-python@v6 + if: ${{ matrix.python-version != '3.6' }} + with: + python-version: ${{ matrix.python-version }} + allow-prereleases: true + - name: Setup Test Env + run: | + pip install "coverage[toml]" tox + - name: Erase coverage + run: | + coverage erase + - name: Test aiohttp + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-aiohttp" + - name: Test asgi + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-asgi" + - name: Test bottle + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-bottle" + - name: Test falcon + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-falcon" + - name: Test litestar + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-litestar" + - name: Test pyramid + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-pyramid" + - name: Test quart + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-quart" + - name: Test sanic + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-sanic" + - name: Test starlite + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-starlite" + - name: Test tornado + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-tornado" + - name: Generate coverage XML (Python 3.6) + if: ${{ !cancelled() && matrix.python-version == '3.6' }} + run: | + export COVERAGE_RCFILE=.coveragerc36 + coverage combine .coverage-sentry-* + coverage xml --ignore-errors + - name: Generate coverage XML + if: ${{ !cancelled() && matrix.python-version != '3.6' }} + run: | + coverage combine .coverage-sentry-* + coverage xml + - name: Upload coverage to Codecov + if: ${{ !cancelled() }} + uses: codecov/codecov-action@v5.5.2 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: coverage.xml + # make sure no plugins alter our coverage reports + plugins: noop + verbose: true + - name: Upload test results to Codecov + if: ${{ !cancelled() }} + uses: codecov/test-results-action@v1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: .junitxml + verbose: true + check_required_tests: + name: All Web 2 tests passed + needs: test-web_2 + # Always run this, even if a dependent job failed + if: always() + runs-on: ubuntu-22.04 + steps: + - name: Check for failures + if: needs.test-web_2.result != 'success' + run: | + echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/update-tox.yml b/.github/workflows/update-tox.yml new file mode 100644 index 0000000000..5d3931cf9e --- /dev/null +++ b/.github/workflows/update-tox.yml @@ -0,0 +1,111 @@ +name: Update test matrix + +on: + workflow_dispatch: + schedule: + # early Monday morning + - cron: '23 3 * * 1' + +jobs: + update-tox: + name: Update test matrix + runs-on: ubuntu-latest + timeout-minutes: 10 + + permissions: + contents: write + pull-requests: write + + steps: + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: 3.14t + + - name: Checkout repo + uses: actions/checkout@v6.0.1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Configure git + run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + + - name: Run generate-test-files.sh + run: | + set -e + sh scripts/generate-test-files.sh + + - name: Create branch + id: create-branch + run: | + COMMIT_TITLE="ci: 🤖 Update test matrix with new releases" + DATE=`date +%m/%d` + BRANCH_NAME="toxgen/update" + + git checkout -B "$BRANCH_NAME" + git add --all + git commit -m "$COMMIT_TITLE" + git push origin "$BRANCH_NAME" --force + + echo "branch_name=$BRANCH_NAME" >> $GITHUB_OUTPUT + echo "commit_title=$COMMIT_TITLE" >> $GITHUB_OUTPUT + echo "date=$DATE" >> $GITHUB_OUTPUT + + - name: Create pull request + uses: actions/github-script@v8 + with: + script: | + const branchName = '${{ steps.create-branch.outputs.branch_name }}'; + const commitTitle = '${{ steps.create-branch.outputs.commit_title }}'; + const date = '${{ steps.create-branch.outputs.date }}'; + const prBody = `Update our test matrix with new releases of integrated frameworks and libraries. + + ## How it works + - Scan PyPI for all supported releases of all frameworks we have a dedicated test suite for. + - Pick a representative sample of releases to run our test suite against. We always test the latest and oldest supported version. + - Update [tox.ini](https://github.com/getsentry/sentry-python/blob/master/tox.ini) with the new releases. + + ## Action required + - If CI passes on this PR, it's safe to approve and merge. It means our integrations can handle new versions of frameworks that got pulled in. + - If CI doesn't pass on this PR, this points to an incompatibility of either our integration or our test setup with a new version of a framework. + - Check what the failures look like and either fix them, or update the [test config](https://github.com/getsentry/sentry-python/blob/master/scripts/populate_tox/config.py) and rerun [scripts/generate-test-files.sh](https://github.com/getsentry/sentry-python/blob/master/scripts/generate-test-files.sh). See [scripts/populate_tox/README.md](https://github.com/getsentry/sentry-python/blob/master/scripts/populate_tox/README.md) for what configuration options are available. + + _____________________ + + _🤖 This PR was automatically created using [a GitHub action](https://github.com/getsentry/sentry-python/blob/master/.github/workflows/update-tox.yml)._`.replace(/^ {16}/gm, '') + + // Close existing toxgen PRs as they're now obsolete + + const { data: existingPRs } = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + head: `${context.repo.owner}:${branchName}`, + state: 'open' + }); + + for (const pr of existingPRs) { + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number, + state: 'closed' + }) + }; + + const { data: pr } = await github.rest.pulls.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: commitTitle + ' (' + date + ')', + head: branchName, + base: '${{ github.ref_name }}', + body: prBody, + }); + + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + labels: ['Component: CI', 'Component: Tests'] + }); diff --git a/.gitignore b/.gitignore index 9dcdf030d3..a37ad4a60f 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,10 @@ *.db *.pid .python-version -.coverage* +.coverage +.coverage-sentry* +coverage.xml +.junitxml* .DS_Store .tox pip-log.txt @@ -25,3 +28,8 @@ relay pip-wheel-metadata .mypy_cache .vscode/ +.claude/ +.tool-versions + +# for running AWS Lambda tests using AWS SAM +sam.template.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cb7882d38f..7ec62a8f37 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,24 +1,9 @@ # See https://pre-commit.com for more information # See https://pre-commit.com/hooks.html for more hooks repos: -- repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.3.0 +- repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.13.2 hooks: - - id: trailing-whitespace - - id: end-of-file-fixer - -- repo: https://github.com/psf/black - rev: 22.6.0 - hooks: - - id: black - -- repo: https://github.com/pycqa/flake8 - rev: 5.0.4 - hooks: - - id: flake8 - -# Disabled for now, because it lists a lot of problems. -#- repo: https://github.com/pre-commit/mirrors-mypy -# rev: 'v0.931' -# hooks: -# - id: mypy + - id: ruff-check + args: [--fix] + - id: ruff-format diff --git a/.tool-versions b/.tool-versions deleted file mode 100644 index d316e6d5f1..0000000000 --- a/.tool-versions +++ /dev/null @@ -1 +0,0 @@ -python 3.7.12 diff --git a/CHANGELOG.md b/CHANGELOG.md index 75ea45c4a0..8592487cb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,2384 @@ # Changelog +## 2.48.0 + +Middleware spans are now disabled by default in Django, Starlette and FastAPI integrations. Set the `middleware_spans` integration-level +option to capture individual spans per middleware layer. To record Django middleware spans, for example, configure as follows + +```python + import sentry_sdk + from sentry_sdk.integrations.django import DjangoIntegration + + sentry_sdk.init( + dsn="", + integrations=[ + DjangoIntegration(middleware_spans=True), + ], + ) +``` + +### New Features ✨ + +- feat(ai): add single message truncation by @shellmayr in [#5079](https://github.com/getsentry/sentry-python/pull/5079) + +- feat(django): Add span around `Task.enqueue` by @sentrivana in [#5209](https://github.com/getsentry/sentry-python/pull/5209) + +- feat(starlette): Set transaction name when middleware spans are disabled by @alexander-alderman-webb in [#5223](https://github.com/getsentry/sentry-python/pull/5223) + +- feat: Add "K_REVISION" to environment variable release check (exposed by cloud run) by @rpradal in [#5222](https://github.com/getsentry/sentry-python/pull/5222) + +#### Langgraph + +- feat(langgraph): Response model attribute on invocation spans by @alexander-alderman-webb in [#5212](https://github.com/getsentry/sentry-python/pull/5212) +- feat(langgraph): Usage attributes on invocation spans by @alexander-alderman-webb in [#5211](https://github.com/getsentry/sentry-python/pull/5211) + +#### OTLP + +- feat(otlp): Optionally capture exceptions from otel's Span.record_exception api by @sl0thentr0py in [#5235](https://github.com/getsentry/sentry-python/pull/5235) +- feat(otlp): Implement new Propagator.inject for OTLPIntegration by @sl0thentr0py in [#5221](https://github.com/getsentry/sentry-python/pull/5221) + +### Bug Fixes 🐛 + +#### Integrations + +- fix(django): Set active thread ID when middleware spans are disabled by @alexander-alderman-webb in [#5220](https://github.com/getsentry/sentry-python/pull/5220) +- fix(integrations): openai-agents fixing the input messages structure which was wrapped too much in some cases by @constantinius in [#5203](https://github.com/getsentry/sentry-python/pull/5203) +- fix(integrations): openai-agents fix multi-patching of `get_model` function by @constantinius in [#5195](https://github.com/getsentry/sentry-python/pull/5195) +- fix(integrations): add values for pydantic-ai and openai-agents to `_INTEGRATION_DEACTIVATES` to prohibit double span creation by @constantinius in [#5196](https://github.com/getsentry/sentry-python/pull/5196) + +- fix(logs): Set `span_id` instead of `sentry.trace.parent_span_id` attribute by @sentrivana in [#5241](https://github.com/getsentry/sentry-python/pull/5241) + +- fix(logs, metrics): Gate metrics, logs user attributes behind `send_default_pii` by @sentrivana in [#5240](https://github.com/getsentry/sentry-python/pull/5240) + +- fix(pydantic-ai): Stop capturing internal exceptions by @alexander-alderman-webb in [#5237](https://github.com/getsentry/sentry-python/pull/5237) + +- fix(ray): Actor class decorator with arguments by @alexander-alderman-webb in [#5230](https://github.com/getsentry/sentry-python/pull/5230) + +- fix: Don't log internal exception for tornado user auth by @sl0thentr0py in [#5208](https://github.com/getsentry/sentry-python/pull/5208) +- fix: Fix changelog config by @sentrivana in [#5192](https://github.com/getsentry/sentry-python/pull/5192) + +### Internal Changes 🔧 + +- chore(django): Disable middleware spans by default by @alexander-alderman-webb in [#5219](https://github.com/getsentry/sentry-python/pull/5219) + +- chore(starlette): Disable middleware spans by default by @alexander-alderman-webb in [#5224](https://github.com/getsentry/sentry-python/pull/5224) + +- ci: Unpin Python version for LiteLLM tests by @alexander-alderman-webb in [#5238](https://github.com/getsentry/sentry-python/pull/5238) +- ci: 🤖 Update test matrix with new releases (12/15) by @github-actions in [#5229](https://github.com/getsentry/sentry-python/pull/5229) +- chore: Ignore type annotation migration in blame by @alexander-alderman-webb in [#5234](https://github.com/getsentry/sentry-python/pull/5234) +- ref: Clean up get_active_propagation_context by @sl0thentr0py in [#5217](https://github.com/getsentry/sentry-python/pull/5217) +- ref: Cleanup outgoing propagation_context logic by @sl0thentr0py in [#5215](https://github.com/getsentry/sentry-python/pull/5215) +- ci: Pin Python version to at least 3.10 for LiteLLM by @alexander-alderman-webb in [#5202](https://github.com/getsentry/sentry-python/pull/5202) +- test: Remove skipped test by @sentrivana in [#5197](https://github.com/getsentry/sentry-python/pull/5197) +- Convert all remaining type annotations into the modern format by @zsol in [#5239](https://github.com/getsentry/sentry-python/pull/5239) +- Convert sentry_sdk type annotations into the modern format by @zsol in [#5206](https://github.com/getsentry/sentry-python/pull/5206) + +## 2.47.0 + +### Bug Fixes 🐛 + +- fix: Make PropagationContext.from_incoming_data always return a PropagationContext by @sl0thentr0py in [#5186](https://github.com/getsentry/sentry-python/pull/5186) +- fix(integrations): anthropic set `GEN_AI_OPERATION_NAME` by @constantinius in [#5185](https://github.com/getsentry/sentry-python/pull/5185) +- fix(spotlight): align behavior with SDK spec by @BYK in [#5169](https://github.com/getsentry/sentry-python/pull/5169) +- fix(integrations): do not exit early when config is not passed as it is not required and prohibits setting `gen_ai.request.messages` by @constantinius in [#5167](https://github.com/getsentry/sentry-python/pull/5167) +- fix(langchain): add gen_ai.response.model to chat spans by @shellmayr in [#5159](https://github.com/getsentry/sentry-python/pull/5159) +- fix(integrations): add the system prompt to the `gen_ai.request.messages` attribute by @constantinius in [#5161](https://github.com/getsentry/sentry-python/pull/5161) +- fix(ai): Handle Pydantic model classes in \_normalize_data by @skalinchuk in [#5143](https://github.com/getsentry/sentry-python/pull/5143) +- fix(openai-agents): Avoid double span exit on exception by @alexander-alderman-webb in [#5174](https://github.com/getsentry/sentry-python/pull/5174) +- fix(openai-agents): Store `invoke_agent` span on `agents.RunContextWrapper` by @alexander-alderman-webb in [#5165](https://github.com/getsentry/sentry-python/pull/5165) +- Add back span status by @sl0thentr0py in [#5147](https://github.com/getsentry/sentry-python/pull/5147) + +### New Features ✨ + +- feat(integrations): openai-agents: add usage and response model reporting for chat and invoke_agent spans by @constantinius in [#5157](https://github.com/getsentry/sentry-python/pull/5157) +- feat: Implement strict_trace_continuation by @sl0thentr0py in [#5178](https://github.com/getsentry/sentry-python/pull/5178) +- feat(integration): pydantic-ai: properly report token usage and response model for invoke_agent spans by @constantinius in [#5153](https://github.com/getsentry/sentry-python/pull/5153) +- feat(integrations): add support for embed_content methods in GoogleGenAI integration by @constantinius in [#5128](https://github.com/getsentry/sentry-python/pull/5128) +- feat(logs): Record discarded log bytes by @alexander-alderman-webb in [#5144](https://github.com/getsentry/sentry-python/pull/5144) +- feat: Add an initial changelog config by @sentrivana in [#5145](https://github.com/getsentry/sentry-python/pull/5145) +- feat(django): Instrument database rollbacks by @alexander-alderman-webb in [#5115](https://github.com/getsentry/sentry-python/pull/5115) +- feat(django): Instrument database commits by @alexander-alderman-webb in [#5100](https://github.com/getsentry/sentry-python/pull/5100) +- feat(openai-agents): Truncate long messages by @alexander-alderman-webb in [#5141](https://github.com/getsentry/sentry-python/pull/5141) +- Add org_id support by @sl0thentr0py in [#5166](https://github.com/getsentry/sentry-python/pull/5166) + +### Deprecations + +- Deprecate `continue_from_headers` by @sl0thentr0py in [#5160](https://github.com/getsentry/sentry-python/pull/5160) + +### Build / dependencies / internal 🔧 + +- Remove unsupported SPANSTATUS.(ERROR|UNSET) by @sl0thentr0py in [#5146](https://github.com/getsentry/sentry-python/pull/5146) +- Rename setup_otlp_exporter to setup_otlp_traces_exporter by @sl0thentr0py in [#5142](https://github.com/getsentry/sentry-python/pull/5142) +- Simplify continue_trace to reuse propagation_context values by @sl0thentr0py in [#5158](https://github.com/getsentry/sentry-python/pull/5158) +- Make PropagationContext hold baggage instead of dynamic_sampling_context by @sl0thentr0py in [#5156](https://github.com/getsentry/sentry-python/pull/5156) +- Cleanup PropagationContext.from_incoming_data by @sl0thentr0py in [#5155](https://github.com/getsentry/sentry-python/pull/5155) +- chore: Add `commit_patterns` to changelog config, remove auto-labeler by @sentrivana in [#5176](https://github.com/getsentry/sentry-python/pull/5176) +- build(deps): bump actions/github-script from 7 to 8 by @dependabot in [#5171](https://github.com/getsentry/sentry-python/pull/5171) +- build(deps): bump supercharge/redis-github-action from 1.8.1 to 2 by @dependabot in [#5172](https://github.com/getsentry/sentry-python/pull/5172) +- ci: 🤖 Update test matrix with new releases (12/01) by @github-actions in [#5173](https://github.com/getsentry/sentry-python/pull/5173) +- ci: Add auto-label GH action by @sentrivana in [#5163](https://github.com/getsentry/sentry-python/pull/5163) +- ci: Split up Test AI workflow by @alexander-alderman-webb in [#5148](https://github.com/getsentry/sentry-python/pull/5148) +- ci: Update test matrix with new releases (11/24) by @alexander-alderman-webb in [#5139](https://github.com/getsentry/sentry-python/pull/5139) +- test: Import integrations with empty shadow modules by @alexander-alderman-webb in [#5150](https://github.com/getsentry/sentry-python/pull/5150) +- Add deprecations to changelog categories by @sentrivana in [#5162](https://github.com/getsentry/sentry-python/pull/5162) + +## 2.46.0 + +### Various fixes & improvements + +- Preserve metadata on wrapped coroutines (#5105) by @alexander-alderman-webb +- Make imports defensive to avoid `ModuleNotFoundError` in Pydantic AI integration (#5135) by @alexander-alderman-webb +- Fix OpenAI agents integration mistakenly enabling itself (#5132) by @sentrivana +- Add instrumentation to embedding functions for various backends (#5120) by @constantinius +- Improve embeddings support for OpenAI (#5121) by @constantinius +- Enhance input handling for embeddings in LiteLLM integration (#5127) by @constantinius +- Expect exceptions when re-raised (#5125) by @alexander-alderman-webb +- Remove `MagicMock` from mocked `ModelResponse` (#5126) by @alexander-alderman-webb + +## 2.45.0 + +### Various fixes & improvements + +- OTLPIntegration (#4877) by @sl0thentr0py + + Enable the new OTLP integration with the code snippet below, and your OpenTelemetry instrumentation will be automatically sent to Sentry's OTLP ingestion endpoint. + + ```python + import sentry_sdk + from sentry_sdk.integrations.otlp import OTLPIntegration + + sentry_sdk.init( + dsn="", + # Add data like inputs and responses; + # see https://docs.sentry.io/platforms/python/data-management/data-collected/ for more info + send_default_pii=True, + integrations=[ + OTLPIntegration(), + ], + ) + ``` + + Under the hood, this will setup: + - A `SpanExporter` that will automatically set up the OTLP ingestion endpoint from your DSN + - A `Propagator` that ensures Distributed Tracing works + - Trace/Span linking for all other Sentry events such as Errors, Logs, Crons and Metrics + + If you were using the `SentrySpanProcessor` before, we recommend migrating over to `OTLPIntegration` since it's a much simpler setup. + +- feat(integrations): implement context management for invoke_agent spans (#5089) by @constantinius +- feat(loguru): Capture extra (#5096) by @sentrivana +- feat: Attach `server.address` to metrics (#5113) by @alexander-alderman-webb +- fix: Cast message and detail attributes before appending exception notes (#5114) by @alexander-alderman-webb +- fix(integrations): ensure that GEN_AI_AGENT_NAME is properly set for GEN_AI spans under an invoke_agent span (#5030) by @constantinius +- fix(logs): Update `sentry.origin` (#5112) by @sentrivana +- chore: Deprecate description truncation option for Redis spans (#5073) by @alexander-alderman-webb +- chore: Deprecate `max_spans` LangChain parameter (#5074) by @alexander-alderman-webb +- chore(toxgen): Check availability of pip and add detail to exceptions (#5076) by @alexander-alderman-webb +- chore: add MCP SDK Pydantic AI and OpenAI Agents to the list of auto enabled integrations (#5111) by @constantinius +- test: add tests for either FastMCP implementation (#5075) by @constantinius +- fix(ci): Re-enable skipped tests (#5104) by @sentrivana +- ci: 🤖 Update test matrix with new releases (11/17) (#5110) by @github-actions +- ci: Force coverage core ctrace for 3.14 (#5108) by @sl0thentr0py + +## 2.44.0 + +### Various fixes & improvements + +- fix(openai): Check response text is present to avoid AttributeError (#5081) by @alexander-alderman-webb +- fix(pydantic-ai): Do not fail on new `ToolManager._call_tool()` parameters (#5084) by @alexander-alderman-webb +- tests(huggingface): Avoid `None` version (#5083) by @alexander-alderman-webb +- ci: Pin `coverage` version for 3.14 Django tests (#5088) by @alexander-alderman-webb +- chore: X handle update (#5078) by @cleptric +- fix(openai-agents): add input messages to errored spans as well (#5077) by @shellmayr +- fix: Add hard limit to log batcher (#5069) by @alexander-alderman-webb +- fix: Add hard limit to metrics batcher (#5068) by @alexander-alderman-webb +- fix(integrations): properly handle exceptions in tool calls (#5065) by @constantinius +- feat: non-experimental `before_send_metric` option (#5064) by @alexander-alderman-webb +- feat: non-experimental `enable_metrics` option (#5056) by @alexander-alderman-webb +- fix(integrations): properly distinguish between network.transport and mcp.transport (#5063) by @constantinius +- feat(integrations): add ability to auto-deactivate lower-level integrations based on map (#5052) by @shellmayr +- Fix NOT_GIVEN check in anthropic (#5058) by @sl0thentr0py +- ci: 🤖 Update test matrix with new releases (11/03) (#5054) by @github-actions +- Add external_propagation_context support (#5051) by @sl0thentr0py +- chore: Remove `enable_metrics` option (#5046) by @alexander-alderman-webb +- Allow new integration setup on the instance with config options (#5047) by @sl0thentr0py +- ci: Run integration tests on Python 3.14t (#4995) by @alexander-alderman-webb +- docs: Elaborate on Strawberry autodetection in changelog (#5039) by @sentrivana + +## 2.43.0 + +### Various fixes & improvements + +- Pydantic AI integration (#4906) by @constantinius + + Enable the new Pydantic AI integration with the code snippet below, and you can use the Sentry AI dashboards to observe your AI calls: + + ```python + import sentry_sdk + from sentry_sdk.integrations.pydantic_ai import PydanticAIIntegration + sentry_sdk.init( + dsn="", + # Set traces_sample_rate to 1.0 to capture 100% + # of transactions for tracing. + traces_sample_rate=1.0, + # Add data like inputs and responses; + # see https://docs.sentry.io/platforms/python/data-management/data-collected/ for more info + send_default_pii=True, + integrations=[ + PydanticAIIntegration(), + ], + ) + ``` +- MCP Python SDK (#4964) by @constantinius + + Enable the new Python MCP integration with the code snippet below: + + ```python + import sentry_sdk + from sentry_sdk.integrations.mcp import MCPIntegration + sentry_sdk.init( + dsn="", + # Set traces_sample_rate to 1.0 to capture 100% + # of transactions for tracing. + traces_sample_rate=1.0, + # Add data like inputs and responses; + # see https://docs.sentry.io/platforms/python/data-management/data-collected/ for more info + send_default_pii=True, + integrations=[ + MCPIntegration(), + ], + ) + ``` +- fix(strawberry): Remove autodetection, always use sync extension (#4984) by @sentrivana + + Previously, `StrawberryIntegration` would try to guess whether it should install the sync or async version of itself. This auto-detection was very brittle and could lead to us auto-enabling async code in a sync context. With this change, `StrawberryIntegration` remains an auto-enabling integration, but it'll enable the sync version by default. If you want to enable the async version, pass the option explicitly: + + ```python + sentry_sdk.init( + # ... + integrations=[ + StrawberryIntegration( + async_execution=True + ), + ], + ) + ``` +- fix(google-genai): Set agent name (#5038) by @constantinius +- fix(integrations): hooking into error tracing function to find out if an execute tool span should be set to error (#4986) by @constantinius +- fix(django): Improve logic for classifying cache hits and misses (#5029) by @alexander-alderman-webb +- chore(metrics): Rename \_metrics to metrics (#5035) by @alexander-alderman-webb +- fix(tracemetrics): Bump metric buffer size to 1k (#5031) by @k-fish +- fix startlette deprecation warning (#5034) by @DeoLeung +- build(deps): bump actions/upload-artifact from 4 to 5 (#5032) by @dependabot +- fix(ai): truncate messages for google genai (#4992) by @shellmayr +- fix(ai): add message truncation to litellm (#4973) by @shellmayr +- feat(langchain): Support v1 (#4874) by @sentrivana +- ci: Run `common` test suite on Python 3.14t (#4969) by @alexander-alderman-webb +- feat: Officially support 3.14 & run integration tests on 3.14 (#4974) by @sentrivana +- Make logger template format safer to missing kwargs (#4981) by @sl0thentr0py +- tests(huggingface): Support 1.0.0rc7 (#4979) by @alexander-alderman-webb +- feat: Enable HTTP request code origin by default (#4967) by @alexander-alderman-webb +- ci: Run `common` test suite on Python 3.14 (#4896) by @sentrivana + +## 2.42.1 + +### Various fixes & improvements + +- fix(gcp): Inject scopes in TimeoutThread exception with GCP (#4959) by @alexander-alderman-webb +- fix(aws): Inject scopes in TimeoutThread exception with AWS lambda (#4914) by @alexander-alderman-webb +- fix(ai): add message trunction to anthropic (#4953) by @shellmayr +- fix(ai): add message truncation to langgraph (#4954) by @shellmayr +- fix: Default breadcrumbs value for events without breadcrumbs (#4952) by @alexander-alderman-webb +- fix(ai): add message truncation in langchain (#4950) by @shellmayr +- fix(ai): correct size calculation, rename internal property for message truncation & add test (#4949) by @shellmayr +- fix(ai): introduce message truncation for openai (#4946) by @shellmayr +- fix(openai): Use non-deprecated Pydantic method to extract response text (#4942) by @JasonLovesDoggo +- ci: 🤖 Update test matrix with new releases (10/16) (#4945) by @github-actions +- Handle ValueError in scope resets (#4928) by @sl0thentr0py +- fix(litellm): Classify embeddings correctly (#4918) by @alexander-alderman-webb +- Generalize NOT_GIVEN check with omit for openai (#4926) by @sl0thentr0py +- ⚡️ Speed up function `_get_db_span_description` (#4924) by @misrasaurabh1 + +## 2.42.0 + +### Various fixes & improvements + +- feat: Add source information for slow outgoing HTTP requests (#4902) by @alexander-alderman-webb +- tests: Update tox (#4913) by @sentrivana +- fix(Ray): Retain the original function name when patching Ray tasks (#4858) by @svartalf +- feat(ai): Add `python-genai` integration (#4891) by @vgrozdanic + Enable the new Google GenAI integration with the code snippet below, and you can use the Sentry AI dashboards to observe your AI calls: + + ```python + import sentry_sdk + from sentry_sdk.integrations.google_genai import GoogleGenAIIntegration + sentry_sdk.init( + dsn="", + # Set traces_sample_rate to 1.0 to capture 100% + # of transactions for tracing. + traces_sample_rate=1.0, + # Add data like inputs and responses; + # see https://docs.sentry.io/platforms/python/data-management/data-collected/ for more info + send_default_pii=True, + integrations=[ + GoogleGenAIIntegration(), + ], + ) + ``` + +## 2.41.0 + +### Various fixes & improvements + +- feat: Add `concurrent.futures` patch to threading integration (#4770) by @alexander-alderman-webb + + The SDK now makes sure to automatically preserve span relationships when using `ThreadPoolExecutor`. +- chore: Remove old metrics code (#4899) by @sentrivana + + Removed all code related to the deprecated experimental metrics feature (`sentry_sdk.metrics`). +- ref: Remove "experimental" from log function name (#4901) by @sentrivana +- fix(ai): Add mapping for gen_ai message roles (#4884) by @shellmayr +- feat(metrics): Add trace metrics behind an experiments flag (#4898) by @k-fish + +## 2.40.0 + +### Various fixes & improvements + +- Add LiteLLM integration (#4864) by @constantinius + Once you've enabled the [new LiteLLM integration](https://docs.sentry.io/platforms/python/integrations/litellm/), you can use the Sentry AI Agents Monitoring, a Sentry dashboard that helps you understand what's going on with your AI requests: + + ```python + import sentry_sdk + from sentry_sdk.integrations.litellm import LiteLLMIntegration + sentry_sdk.init( + dsn="", + # Set traces_sample_rate to 1.0 to capture 100% + # of transactions for tracing. + traces_sample_rate=1.0, + # Add data like inputs and responses; + # see https://docs.sentry.io/platforms/python/data-management/data-collected/ for more info + send_default_pii=True, + integrations=[ + LiteLLMIntegration(), + ], + ) + ``` + +- Litestar: Copy request info to prevent cookies mutation (#4883) by @alexander-alderman-webb +- Add tracing to `DramatiqIntegration` (#4571) by @Igreh +- Also emit spans for MCP tool calls done by the LLM (#4875) by @constantinius +- Option to not trace HTTP requests based on status codes (#4869) by @alexander-alderman-webb + You can now disable transactions for incoming requests with specific HTTP status codes. The [new `trace_ignore_status_codes` option](https://docs.sentry.io/platforms/python/configuration/options/#trace_ignore_status_codes) accepts a `set` of status codes as integers. If a transaction wraps a request that results in one of the provided status codes, the transaction will be unsampled. + + ```python + import sentry_sdk + + sentry_sdk.init( + trace_ignore_status_codes={301, 302, 303, *range(305, 400), 404}, + ) + ``` + +- Move `_set_agent_data` call to `ai_client_span` function (#4876) by @constantinius +- Add script to determine lowest supported versions (#4867) by @sentrivana +- Update `CONTRIBUTING.md` (#4870) by @sentrivana + +## 2.39.0 + +### Various fixes & improvements + +- Fix(AI): Make agents integrations set the span status in case of error (#4820) by @antonpirker +- Fix(dedupe): Use weakref in dedupe where possible (#4834) by @sl0thentr0py +- Fix(Django): Avoid evaluating complex Django object in span.data/span.attributes (#4804) by @antonpirker +- Fix(Langchain): Don't record tool call output if not include_prompt / should_send_default_pii (#4836) by @shellmayr +- Fix(OpenAI): Don't swallow userland exceptions in openai (#4861) by @sl0thentr0py +- Docs: Update contributing guidelines with instructions to run tests with tox (#4857) by @alexander-alderman-webb +- Test(Spark): Improve `test_spark` speed (#4822) by @mgaligniana + +Note: This is my last release. So long, and thanks for all the fish! by @antonpirker + + +## 2.38.0 + +### Various fixes & improvements + +- Feat(huggingface_hub): Update HuggingFace Hub integration (#4746) by @antonpirker +- Feat(Anthropic): Add proper tool calling data to Anthropic integration (#4769) by @antonpirker +- Feat(openai-agents): Add input and output to `invoke_agent` span. (#4785) by @antonpirker +- Feat(AI): Create transaction in AI agents framworks, when no transaction is running. (#4758) by @constantinius +- Feat(GraphQL): Support gql 4.0-style execute (#4779) by @sentrivana +- Fix(logs): Expect `log_item` as rate limit category (#4798) by @sentrivana +- Fix: CI for mypy, gevent (#4790) by @sentrivana +- Fix: Correctly check for a running transaction (#4791) by @antonpirker +- Fix: Use float for sample rand (#4677) by @sentrivana +- Fix: Avoid reporting false-positive StopAsyncIteration in the asyncio integration (#4741) by @vmarkovtsev +- Fix: Add log message when `DedupeIntegration` is dropping an error. (#4788) by @antonpirker +- Fix(profiling): Re-init continuous profiler (#4772) by @Zylphrex +- Chore: Reexport module `profiler` (#4535) by @zen-xu +- Tests: Update tox.ini (#4799) by @sentrivana +- Build(deps): bump actions/create-github-app-token from 2.1.1 to 2.1.4 (#4795) by @dependabot +- Build(deps): bump actions/setup-python from 5 to 6 (#4774) by @dependabot +- Build(deps): bump codecov/codecov-action from 5.5.0 to 5.5.1 (#4773) by @dependabot + +## 2.37.1 + +### Various fixes & improvements + +- Fix(langchain): Make Langchain integration work with just langchain-core (#4783) by @shellmayr +- Tests: Move quart under toxgen (#4775) by @sentrivana +- Tests: Update tox.ini (#4777) by @sentrivana +- Tests: Move chalice under toxgen (#4766) by @sentrivana + +## 2.37.0 + +- **New Integration (BETA):** Add support for `langgraph` (#4727) by @shellmayr + + We can now instrument AI agents that are created with [LangGraph](https://www.langchain.com/langgraph) out of the box. + + For more information see the [LangGraph integrations documentation](https://docs.sentry.io/platforms/python/integrations/langgraph/). + +- AI Agents: Improve rendering of input and output messages in AI agents integrations. (#4750) by @shellmayr +- AI Agents: Format span attributes in AI integrations (#4762) by @antonpirker +- CI: Fix celery (#4765) by @sentrivana +- Tests: Move asyncpg under toxgen (#4757) by @sentrivana +- Tests: Move beam under toxgen (#4759) by @sentrivana +- Tests: Move boto3 tests under toxgen (#4761) by @sentrivana +- Tests: Remove openai pin and update tox (#4748) by @sentrivana + +## 2.36.0 + +### Various fixes & improvements + +- **New integration:** Unraisable exceptions (#4733) by @alexander-alderman-webb + + Add the unraisable exception integration to your sentry_sdk.init call: +```python +import sentry_sdk +from sentry_sdk.integrations.unraisablehook import UnraisablehookIntegration + +sentry_sdk.init( + dsn="...", + integrations=[ + UnraisablehookIntegration(), + ] +) +``` + +- meta: Update instructions on release process (#4755) by @sentrivana +- tests: Move arq under toxgen (#4739) by @sentrivana +- tests: Support dashes in test suite names (#4740) by @sentrivana +- Don't fail if there is no `_context_manager_state` (#4698) by @sentrivana +- Wrap span restoration in `__exit__` in `capture_internal_exceptions` (#4719) by @sentrivana +- fix: Constrain types of ai_track decorator (#4745) by @alexander-alderman-webb +- Fix `openai_agents` in CI (#4742) by @sentrivana +- Remove old langchain test suites from ignore list (#4737) by @sentrivana +- tests: Trigger Pytest failure when an unraisable exception occurs (#4738) by @alexander-alderman-webb +- fix(openai): Avoid double exit causing an unraisable exception (#4736) by @alexander-alderman-webb +- tests: Move langchain under toxgen (#4734) by @sentrivana +- toxgen: Add variants & move OpenAI under toxgen (#4730) by @sentrivana +- Update tox.ini (#4731) by @sentrivana + +## 2.35.2 + +### Various fixes & improvements + +- fix(logs): Do not attach template if there are no parameters (#4728) by @sentrivana + +## 2.35.1 + +### Various fixes & improvements + +- OpenAI Agents: Isolate agent run (#4720) by @sentrivana +- Tracing: Do not attach stacktrace to transaction (#4713) by @Zylphrex + +## 2.35.0 + +### Various fixes & improvements + +- [Langchain Integration](https://docs.sentry.io/platforms/python/integrations/langchain/) now supports the Sentry [AI dashboard](https://docs.sentry.io/product/insights/ai/agents/dashboard/). (#4678) by @shellmayr +- [Anthropic Integration](https://docs.sentry.io/platforms/python/integrations/anthropic/) now supports the Sentry [AI dashboard](https://docs.sentry.io/product/insights/ai/agents/dashboard/). (#4674) by @constantinius +- AI Agents templates for `@trace` decorator (#4676) by @antonpirker +- Sentry Logs: Add `enable_logs`, `before_send_log` as top-level `sentry_sdk.init()` options (#4644) by @sentrivana +- Tracing: Improve `@trace` decorator. Allows to set `span.op`, `span.name`, and `span.attributes` (#4648) by @antonpirker +- Tracing: Add convenience function `sentry_sdk.update_current_span`. (#4673) by @antonpirker +- Tracing: Add `Span.update_data()` to update multiple `span.data` items at once. (#4666) by @antonpirker +- GNU-integration: make path optional (#4688) by @MeredithAnya +- Clickhouse: Don't eat the generator data (#4669) by @szokeasaurusrex +- Clickhouse: List `send_data` parameters (#4667) by @szokeasaurusrex +- Update `gen_ai.*` and `ai.*` attributes (#4665) by @antonpirker +- Better checking for empty tools list (#4647) by @antonpirker +- Remove performance paper cuts (#4675) by @sentrivana +- Help for debugging Cron problems (#4686) by @antonpirker +- Fix Redis CI (#4691) by @sentrivana +- Fix plugins key codecov (#4655) by @sl0thentr0py +- Fix Mypy (#4649) by @sentrivana +- Update tox.ini (#4689) by @sentrivana +- build(deps): bump actions/create-github-app-token from 2.0.6 to 2.1.0 (#4684) by @dependabot + +## 2.34.1 + +### Various fixes & improvements + +- Fix: Make sure Span data in AI instrumentations is always a primitive data type (#4643) by @antonpirker +- Fix: Typo in CHANGELOG.md (#4640) by @jgillard + +## 2.34.0 + +### Various fixes & improvements + +- Considerably raise `DEFAULT_MAX_VALUE_LENGTH` (#4632) by @sentrivana + + We have increased the string trimming limit considerably, allowing you to see more data + without it being truncated. Note that this might, in rare cases, result in issue regrouping, + for example if you're capturing message events with very long messages (longer than the + default 1024 characters/bytes). + + If you want to adjust the limit, you can set a + [`max_value_length`](https://docs.sentry.io/platforms/python/configuration/options/#max_value_length) + in your `sentry_sdk.init()`. + +- `OpenAI` integration update (#4612) by @antonpirker + + The `OpenAIIntegration` now supports [OpenAI Responses API](https://platform.openai.com/docs/api-reference/responses). + + The data captured will also show up in the new [AI Agents Dashboard](https://docs.sentry.io/product/insights/agents/dashboard/). + + This works out of the box, nothing to do on your side. + +- Expose `set_transaction_name` (#4634) by @sl0thentr0py +- Fix(Celery): Latency should be in milliseconds, not seconds (#4637) by @sentrivana +- Fix(Django): Treat `django.template.context.BasicContext` as sequence in serializer (#4621) by @sl0thentr0py +- Fix(Huggingface): Fix `huggingface_hub` CI tests. (#4619) by @antonpirker +- Fix: Ignore deliberate thread exception warnings (#4611) by @sl0thentr0py +- Fix: Socket tests to not use example.com (#4627) by @sl0thentr0py +- Fix: Threading run patch (#4610) by @sl0thentr0py +- Tests: Simplify celery double patching test (#4626) by @sl0thentr0py +- Tests: Remove remote example.com calls (#4622) by @sl0thentr0py +- Tests: tox.ini update (#4635) by @sentrivana +- Tests: Update tox (#4609) by @sentrivana + +## 2.33.2 + +### Various fixes & improvements + +- ref(spotlight): Do not import `sentry_sdk.spotlight` unless enabled (#4607) by @sentrivana +- ref(gnu-integration): update clickhouse stacktrace parsing (#4598) by @MeredithAnya + +## 2.33.1 + +### Various fixes & improvements + +- fix(integrations): allow explicit op parameter in `ai_track` (#4597) by @mshavliuk +- fix: Fix `abs_path` bug in `serialize_frame` (#4599) by @szokeasaurusrex +- Remove pyrsistent from test dependencies (#4588) by @musicinmybrain +- Remove explicit `__del__`'s in threaded classes (#4590) by @sl0thentr0py +- Remove forked from test_transport, separate gevent tests and generalize capturing_server to be module level (#4577) by @sl0thentr0py +- Improve token usage recording (#4566) by @antonpirker + +## 2.33.0 + +### Various fixes & improvements + +- feat(langchain): Support `BaseCallbackManager` (#4486) by @szokeasaurusrex +- Use `span.data` instead of `measurements` for token usage (#4567) by @antonpirker +- Fix custom model name (#4569) by @antonpirker +- fix: shut down "session flusher" more promptly (#4561) by @bukzor +- chore: Remove Lambda urllib3 pin on Python 3.10+ (#4549) by @sentrivana + +## 2.32.0 + +### Various fixes & improvements + +- feat(sessions): Add top-level start- and end session methods (#4474) by @szokeasaurusrex +- feat(openai-agents): Set tool span to failed if an error is raised in the tool (#4527) by @antonpirker +- fix(integrations/ray): Correctly pass keyword arguments to ray.remote function (#4430) by @svartalf +- fix(langchain): Make `span_map` an instance variable (#4476) by @szokeasaurusrex +- fix(langchain): Ensure no duplicate `SentryLangchainCallback` (#4485) by @szokeasaurusrex +- fix(Litestar): Apply `failed_request_status_codes` to exceptions raised in middleware (#4074) by @vrslev + +## 2.31.0 + +### Various fixes & improvements + +- **New Integration (BETA):** Add support for `openai-agents` (#4437) by @antonpirker + + We can now instrument AI agents that are created with the [OpenAI Agents SDK](https://openai.github.io/openai-agents-python/) out of the box. + +```python +import sentry_sdk +from sentry_sdk.integrations.openai_agents import OpenAIAgentsIntegration + +# Add the OpenAIAgentsIntegration to your sentry_sdk.init call: +sentry_sdk.init( + dsn="...", + integrations=[ + OpenAIAgentsIntegration(), + ] +) +``` + +For more information see the [OpenAI Agents integrations documentation](https://docs.sentry.io/platforms/python/integrations/openai-agents/). + +- Logs: Add support for `dict` arguments (#4478) by @AbhiPrasad +- Add Cursor generated rules (#4493) by @sl0thentr0py +- Greatly simplify Langchain integrations `_wrap_configure` (#4479) by @szokeasaurusrex +- Fix(ci): Remove tracerite pin (almost) (#4504) by @sentrivana +- Fix(profiling): Ensure profiler thread exits when needed (#4497) by @Zylphrex +- Fix(ci): Do not install newest `tracerite` (#4494) by @sentrivana +- Fix(scope): Handle token reset `LookupError`s gracefully (#4481) by @sentrivana +- Tests: Tox update (#4509) by @sentrivana +- Tests: Upper bound on fakeredis on old Python versions (#4482) by @sentrivana +- Tests: Regenerate tox (#4457) by @sentrivana + +## 2.30.0 + +### Various fixes & improvements + +- **New beta feature:** Sentry logs for Loguru (#4445) by @sentrivana + + We can now capture Loguru logs and send them to Sentry. + +```python +import sentry_sdk +from sentry_sdk.integrations.loguru import LoguruIntegration + +# Setup Sentry SDK to send Loguru log messages with a level of "error" or higher to Sentry +sentry_sdk.init( + _experiments={ + "enable_logs": True, + }, + integrations=[ + LoguruIntegration(sentry_logs_level=logging.ERROR), + ] +) +``` + +- fix(logs): Don't gate user behind `send_default_pii` (#4453) by @AbhiPrasad +- fix(logging): Strip log `record.name` for more robust matching (#4411) by @romaingd-spi +- Migrate to modern threading interface (#4452) by @emmanuel-ferdman +- ref: Remove `_capture_experimental_log` `scope` parameter (#4424) by @szokeasaurusrex +- feat(logs): Add user attributes to logs (#4423) by @szokeasaurusrex +- fix: fix ARQ integration error (#4427) (#4428) by @ninoseki +- fix(grpc): Fix AttributeError when instrumenting with OTel (#4405) by @sentrivana +- fix(redis): Use `command_queue` instead of `command_stack` if available (#4404) by @sentrivana +- fix: Handle invalid `SENTRY_DEBUG` values properly (#4400) by @szokeasaurusrex +- Increase test coverage (#4393) by @mgaligniana +- tests(logs): avoid failures when running with integrations enabled (#4388) by @rominf +- Fix CI, adapt to new redis-py release (#4431) by @sentrivana +- tests: Regenerate toxgen (#4403) by @sentrivana +- tests: Regenerate tox.ini & fix CI (#4435) by @sentrivana +- build(deps): bump codecov/codecov-action from 5.4.2 to 5.4.3 (#4397) by @dependabot + +## 2.29.1 + +### Various fixes & improvements + +- fix(logs): send `severity_text`: `warn` instead of `warning` (#4396) by @lcian + +## 2.29.0 + +### Various fixes & improvements + +- fix(loguru): Move integration setup from `__init__` to `setup_once` (#4399) by @sentrivana +- feat: Allow configuring `keep_alive` via environment variable (#4366) by @szokeasaurusrex +- fix(celery): Do not send extra check-in (#4395) by @sentrivana +- fix(typing): Add before_send_log to Experiments (#4383) by @sentrivana +- ci: Fix pyspark test suite (#4382) by @sentrivana +- fix(logs): Make `sentry.message.parameters` singular as per spec (#4387) by @AbhiPrasad +- apidocs: Remove snowballstemmer pin (#4379) by @sentrivana + +## 2.28.0 + +### Various fixes & improvements + +- fix(logs): Forward `extra` from logger as attributes (#4374) by @AbhiPrasad +- fix(logs): Canonicalize paths from the logger integration (#4336) by @colin-sentry +- fix(logs): Use new transport (#4317) by @colin-sentry +- fix: Deprecate `set_measurement()` API. (#3934) by @antonpirker +- fix: Put feature flags on isolation scope (#4363) by @antonpirker +- fix: Make use of `SPANDATA` consistent (#4373) by @antonpirker +- fix: Discord link (#4371) by @sentrivana +- tests: Pin snowballstemmer for now (#4372) by @sentrivana +- tests: Regular tox update (#4367) by @sentrivana +- tests: Bump test timeout for recursion stacktrace extract to 2s (#4351) by @booxter +- tests: Fix test_stacktrace_big_recursion failure due to argv (#4346) by @booxter +- tests: Move anthropic under toxgen (#4348) by @sentrivana +- tests: Update tox.ini (#4347) by @sentrivana +- chore: Update GH issue templates for Linear compatibility (#4328) by @stephanie-anderson +- chore: Bump actions/create-github-app-token from 2.0.2 to 2.0.6 (#4358) by @dependabot + +## 2.27.0 + +### Various fixes & improvements + +- fix: Make sure to use the default decimal context in our code (#4231) by @antonpirker +- fix(integrations): ASGI integration not capture transactions in Websocket (#4293) by @guodong000 +- feat(typing): Make all relevant types public (#4315) by @antonpirker +- feat(spans): Record flag evaluations as span attributes (#4280) by @cmanallen +- test(logs): Avoid failure when running with integrations enabled (#4316) by @rominf +- tests: Remove unused code and rerun (#4313) by @sentrivana +- tests: Add cohere to toxgen (#4304) by @sentrivana +- tests: Migrate fastapi to toxgen (#4302) by @sentrivana +- tests: Add huggingface_hub to toxgen (#4299) by @sentrivana +- tests: Add huey to toxgen (#4298) by @sentrivana +- tests: Update tox.ini (#4297) by @sentrivana +- tests: Move aiohttp under toxgen (#4319) by @sentrivana +- tests: Fix version picking in toxgen (#4323) by @sentrivana +- build(deps): bump codecov/codecov-action from 5.4.0 to 5.4.2 (#4318) by @dependabot + +## 2.26.1 + +### Various fixes & improvements + +- fix(threading): Data leak in ThreadingIntegration between threads (#4281) by @antonpirker +- fix(logging): Clarify separate warnings case is for Python <3.11 (#4296) by @szokeasaurusrex +- fix(logging): Add formatted message to log events (#4292) by @szokeasaurusrex +- fix(logging): Send raw logging parameters (#4291) by @szokeasaurusrex +- fix: Revert "chore: Deprecate `same_process_as_parent` (#4244)" (#4290) by @sentrivana + +## 2.26.0 + +### Various fixes & improvements + +- fix(debug): Do not consider parent loggers for debug logging (#4286) by @szokeasaurusrex +- test(tracing): Simplify static/classmethod tracing tests (#4278) by @szokeasaurusrex +- feat(transport): Add a timeout (#4252) by @sentrivana +- meta: Change CODEOWNERS back to Python SDK owners (#4269) by @sentrivana +- feat(logs): Add sdk name and version as log attributes (#4262) by @AbhiPrasad +- feat(logs): Add server.address to logs (#4257) by @AbhiPrasad +- chore: Deprecate `same_process_as_parent` (#4244) by @sentrivana +- feat(logs): Add sentry.origin attribute for log handler (#4250) by @AbhiPrasad +- feat(tests): Add optional cutoff to toxgen (#4243) by @sentrivana +- toxgen: Retry & fail if we fail to fetch PyPI data (#4251) by @sentrivana +- build(deps): bump actions/create-github-app-token from 1.12.0 to 2.0.2 (#4248) by @dependabot +- Trying to prevent the grpc setup from being flaky (#4233) by @antonpirker +- feat(breadcrumbs): add `_meta` information for truncation of breadcrumbs (#4007) by @shellmayr +- tests: Move django under toxgen (#4238) by @sentrivana +- fix: Handle JSONDecodeError gracefully in StarletteRequestExtractor (#4226) by @moodix +- fix(asyncio): Remove shutdown handler (#4237) by @sentrivana + +## 2.25.1 + +### Various fixes & improvements + +- fix(logs): Add a class which batches groups of logs together. (#4229) by @colin-sentry +- fix(logs): Use repr instead of json for message and arguments (#4227) by @colin-sentry +- fix(logs): Debug output from Sentry logs should always be `debug` level. (#4224) by @antonpirker +- fix(ai): Do not consume anthropic streaming stop (#4232) by @colin-sentry +- fix(spotlight): Do not spam sentry_sdk.warnings logger w/ Spotlight (#4219) by @BYK +- fix(docs): fixed code snippet (#4218) by @antonpirker +- build(deps): bump actions/create-github-app-token from 1.11.7 to 1.12.0 (#4214) by @dependabot + +## 2.25.0 + +### Various fixes & improvements + +- **New Beta Feature** Enable Sentry logs in `logging` Integration (#4143) by @colin-sentry + + You can now send existing log messages to the new Sentry Logs feature. + + For more information see: https://github.com/getsentry/sentry/discussions/86804 + + This is how you can use it (Sentry Logs is in beta right now so the API can still change): + + ```python + import logging + + import sentry_sdk + from sentry_sdk.integrations.logging import LoggingIntegration + + # Setup Sentry SDK to send log messages with a level of "error" or higher to Sentry. + sentry_sdk.init( + dsn="...", + _experiments={ + "enable_logs": True + } + integrations=[ + LoggingIntegration(sentry_logs_level=logging.ERROR), + ] + ) + + # Your existing logging setup + some_logger = logging.Logger("some-logger") + + some_logger.info('In this example info events will not be sent to Sentry logs. my_value=%s', my_value) + some_logger.error('But error events will be sent to Sentry logs. my_value=%s', my_value) + ``` + +- Spotlight: Sample everything 100% w/ Spotlight & no DSN set (#4207) by @BYK +- Dramatiq: use set_transaction_name (#4175) by @timdrijvers +- toxgen: Make it clearer which suites can be migrated (#4196) by @sentrivana +- Move Litestar under toxgen (#4197) by @sentrivana +- Added flake8 plugings to pre-commit call of flake8 (#4190) by @antonpirker +- Deprecate Scope.user (#4194) by @sentrivana +- Fix hanging when capturing long stacktrace (#4191) by @szokeasaurusrex +- Fix GraphQL failures (#4208) by @sentrivana +- Fix flaky test (#4198) by @sentrivana +- Update Ubuntu in Github test runners (#4204) by @antonpirker + +## 2.24.1 + +### Various fixes & improvements + +- Always set `_spotlight_url` (#4186) by @BYK +- Broader except in Django `parsed_body` (#4189) by @orhanhenrik +- Add platform header to the `chunk` item-type in the envelope (#4178) by @viglia +- Move `mypy` config into `pyproject.toml` (#4181) by @antonpirker +- Move `flake8` config into `pyproject.toml` (#4185) by @antonpirker +- Move `pytest` config into `pyproject.toml` (#4184) by @antonpirker +- Bump `actions/create-github-app-token` from `1.11.6` to `1.11.7` (#4188) by @dependabot +- Add `CODEOWNERS` (#4182) by @sentrivana + +## 2.24.0 + +### Various fixes & improvements + +- fix(tracing): Fix `InvalidOperation` (#4179) by @szokeasaurusrex +- Fix memory leak by not piling up breadcrumbs forever in Spark workers. (#4167) by @antonpirker +- Update scripts sources (#4166) by @emmanuel-ferdman +- Fixed flaky test (#4165) by @antonpirker +- chore(profiler): Add deprecation warning for session functions (#4171) by @sentrivana +- feat(profiling): reverse profile_session start/stop methods deprecation (#4162) by @viglia +- Reset `DedupeIntegration`'s `last-seen` if `before_send` dropped the event (#4142) by @sentrivana +- style(integrations): Fix captured typo (#4161) by @pimuzzo +- Handle loguru msg levels that are not supported by Sentry (#4147) by @antonpirker +- feat(tests): Update tox.ini (#4146) by @sentrivana +- Support Starlette/FastAPI `app.host` (#4157) by @sentrivana + +## 2.23.1 + +### Various fixes & improvements + +- Fix import problem in release 2.23.0 (#4140) by @antonpirker + +## 2.23.0 + +### Various fixes & improvements + +- Feat(profiling): Add new functions to start/stop continuous profiler (#4056) by @Zylphrex +- Feat(profiling): Export start/stop profile session (#4079) by @Zylphrex +- Feat(tracing): Backfill missing `sample_rand` on `PropagationContext` (#4038) by @szokeasaurusrex +- Feat(logs): Add alpha version of Sentry logs (#4126) by @colin-sentry +- Security(gha): fix potential for shell injection (#4099) by @mdtro +- Docs: Add `init()` parameters to ApiDocs. (#4100) by @antonpirker +- Docs: Document that caller must check `mutable` (#4010) by @szokeasaurusrex +- Fix(Anthropic): Add partial json support to streams (#3674) +- Fix(ASGI): Fix KeyError if transaction does not exist (#4095) by @kevinji +- Fix(asyncio): Improve asyncio integration error handling. (#4129) by @antonpirker +- Fix(AWS Lambda): Fix capturing errors during AWS Lambda INIT phase (#3943) +- Fix(Bottle): Prevent internal error on 404 (#4131) by @sentrivana +- Fix(CI): Fix API doc failure in CI (#4075) by @sentrivana +- Fix(ClickHouse) ClickHouse in test suite (#4087) by @antonpirker +- Fix(cloudresourcecontext): Added timeout to HTTP requests in CloudResourceContextIntegration (#4120) by @antonpirker +- Fix(crons): Fixed bug when `cron_jobs` is set to `None` in arq integration (#4115) by @antonpirker +- Fix(debug): Take into account parent handlers for debug logger (#4133) by @sentrivana +- Fix(FastAPI/Starlette): Fix middleware with positional arguments. (#4118) by @antonpirker +- Fix(featureflags): add LRU update/dedupe test coverage (#4082) +- Fix(logging): Coerce None values into strings in logentry params. (#4121) by @antonpirker +- Fix(pyspark): Grab `attemptId` more defensively (#4130) by @sentrivana +- Fix(Quart): Support `quart_flask_patch` (#4132) by @sentrivana +- Fix(tests): A way to locally run AWS Lambda functions (#4128) by @antonpirker +- Fix(tests): Add concurrency testcase for arq (#4125) by @sentrivana +- Fix(tests): Add fail_on_changes to toxgen by @sentrivana +- Fix(tests): Run AWS Lambda tests locally (#3988) by @antonpirker +- Fix(tests): Test relevant prereleases and allow to ignore releases +- Fix(tracing): Move `TRANSACTION_SOURCE_*` constants to `Enum` (#3889) by @mgaligniana +- Fix(typing): Add more typing info to Scope.update_from_kwargs's "contexts" (#4080) +- Fix(typing): Set correct type for `set_context` everywhere (#4123) by @sentrivana +- Chore(tests): Regenerate tox.ini (#4108) by @sentrivana +- Build(deps): bump actions/create-github-app-token from 1.11.5 to 1.11.6 (#4113) by @dependabot +- Build(deps): bump codecov/codecov-action from 5.3.1 to 5.4.0 (#4112) by @dependabot + +## 2.22.0 + +### Various fixes & improvements + +- **New integration:** Add [Statsig](https://statsig.com/) integration (#4022) by @aliu39 + + For more information, see the documentation for the [StatsigIntegration](https://docs.sentry.io/platforms/python/integrations/statsig/). + +- Profiling: Continuous profiling lifecycle (#4017) by @Zylphrex +- Fix: Revert "feat(tracing): Add `propagate_traces` deprecation warning (#3899)" (#4055) by @cmanallen +- Tests: Generate Web 1 group tox entries by toxgen script (#3980) by @sentrivana +- Tests: Generate Web 2 group tox entries by toxgen script (#3981) by @sentrivana +- Tests: Generate Tasks group tox entries by toxgen script (#3976) by @sentrivana +- Tests: Generate AI group tox entries by toxgen script (#3977) by @sentrivana +- Tests: Generate DB group tox entries by toxgen script (#3978) by @sentrivana +- Tests: Generate Misc group tox entries by toxgen script (#3982) by @sentrivana +- Tests: Generate Flags group tox entries by toxgen script (#3974) by @sentrivana +- Tests: Generate gRPC tox entries by toxgen script (#3979) by @sentrivana +- Tests: Remove toxgen cutoff, add statsig (#4048) by @sentrivana +- Tests: Reduce continuous profiling test flakiness (#4052) by @Zylphrex +- Tests: Fix Clickhouse test (#4053) by @sentrivana +- Tests: Fix flaky HTTPS test (#4057) by @Zylphrex +- Update sample rate in DSC (#4018) by @sentrivana +- Move the GraphQL group over to the tox gen script (#3975) by @sentrivana +- Update changelog with `profile_session_sample_rate` (#4046) by @sentrivana + +## 2.21.0 + +### Various fixes & improvements + +- Fix incompatibility with new Strawberry version (#4026) by @sentrivana +- Add `failed_request_status_codes` to Litestar (#4021) by @vrslev + + See https://docs.sentry.io/platforms/python/integrations/litestar/ for details. +- Deprecate `enable_tracing` option (#3935) by @antonpirker + + The `enable_tracing` option is now deprecated. Please use `traces_sample_rate` instead. See https://docs.sentry.io/platforms/python/configuration/options/#traces_sample_rate for more information. +- Explicitly use `None` default when checking metadata (#4039) by @mpurnell1 +- Fix bug where concurrent accesses to the flags property could raise a `RuntimeError` (#4034) by @cmanallen +- Add more min versions of frameworks (#3973) by @sentrivana +- Set level based on status code for HTTP client breadcrumbs (#4004) by @sentrivana +- Don't set transaction status to error on `sys.exit(0)` (#4025) by @sentrivana +- Continuous profiling sample rate (#4002) by @Zylphrex + + Set `profile_session_sample_rate=1.0` in your `init()` to collect continuous profiles for 100% of profile sessions. See https://docs.sentry.io/platforms/python/profiling/#enable-continuous-profiling for more information. +- Track and report spans that were dropped (#4005) by @constantinius +- Change continuous profile buffer size (#3987) by @Zylphrex +- Handle `MultiPartParserError` to avoid internal sentry crash (#4001) by @orhanhenrik +- Handle `None` lineno in `get_source_context` (#3925) by @sentrivana +- Add support for Python 3.12 and 3.13 to AWS Lambda integration (#3965) by @antonpirker +- Add `propagate_traces` deprecation warning (#3899) by @mgaligniana +- Check that `__module__` is `str` (#3942) by @szokeasaurusrex +- Add `__repr__` to `Baggage` (#4043) by @szokeasaurusrex +- Fix a typo (#3923) by @antonpirker +- Fix various CI errors on master (#4009) by @Zylphrex +- Split gevent tests off (#3964) by @sentrivana +- Add tox generation script, but don't use it yet (#3971) by @sentrivana +- Use `httpx_mock` in `test_httpx` (#3967) by @sl0thentr0py +- Fix typo in test name (#4036) by @szokeasaurusrex +- Fix mypy (#4019) by @sentrivana +- Test Celery's latest RC (#3938) by @sentrivana +- Bump `actions/create-github-app-token` from `1.11.2` to `1.11.3` (#4023) by @dependabot +- Bump `actions/create-github-app-token` from `1.11.1` to `1.11.2` (#4015) by @dependabot +- Bump `codecov/codecov-action` from `5.1.2` to `5.3.1` (#3995) by @dependabot + +## 2.20.0 + +- **New integration:** Add [Typer](https://typer.tiangolo.com/) integration (#3869) by @patrick91 + + For more information, see the documentation for the [TyperIntegration](https://docs.sentry.io/platforms/python/integrations/typer/). + +- **New integration:** Add [Unleash](https://www.getunleash.io/) feature flagging integration (#3888) by @aliu39 + + For more information, see the documentation for the [UnleashIntegration](https://docs.sentry.io/platforms/python/integrations/unleash/). + +- Add custom tracking of feature flag evaluations (#3860) by @aliu39 +- Feature Flags: Register LD hook in setup instead of init, and don't check for initialization (#3890) by @aliu39 +- Feature Flags: Moved adding of `flags` context into Scope (#3917) by @antonpirker +- Create a separate group for feature flag test suites (#3911) by @sentrivana +- Fix flaky LaunchDarkly tests (#3896) by @aliu39 +- Fix LRU cache copying (#3883) by @ffelixg +- Fix cache pollution from mutable reference (#3887) by @cmanallen +- Centralize minimum version checking (#3910) by @sentrivana +- Support SparkIntegration activation after SparkContext created (#3411) by @seyoon-lim +- Preserve ARQ enqueue_job __kwdefaults__ after patching (#3903) by @danmr +- Add Github workflow to comment on issues when a fix was released (#3866) by @antonpirker +- Update test matrix for Sanic (#3904) by @antonpirker +- Rename scripts (#3885) by @sentrivana +- Fix CI (#3878) by @sentrivana +- Treat `potel-base` as release branch in CI (#3912) by @sentrivana +- build(deps): bump actions/create-github-app-token from 1.11.0 to 1.11.1 (#3893) by @dependabot +- build(deps): bump codecov/codecov-action from 5.0.7 to 5.1.1 (#3867) by @dependabot +- build(deps): bump codecov/codecov-action from 5.1.1 to 5.1.2 (#3892) by @dependabot + +## 2.19.2 + +### Various fixes & improvements + +- Deepcopy and ensure get_all function always terminates (#3861) by @cmanallen +- Cleanup chalice test environment (#3858) by @antonpirker + +## 2.19.1 + +### Various fixes & improvements + +- Fix errors when instrumenting Django cache (#3855) by @BYK +- Copy `scope.client` reference as well (#3857) by @sl0thentr0py +- Don't give up on Spotlight on 3 errors (#3856) by @BYK +- Add missing stack frames (#3673) by @antonpirker +- Fix wrong metadata type in async gRPC interceptor (#3205) by @fdellekart +- Rename launch darkly hook to match JS SDK (#3743) by @aliu39 +- Script for checking if our instrumented libs are Python 3.13 compatible (#3425) by @antonpirker +- Improve Ray tests (#3846) by @antonpirker +- Test with Celery `5.5.0rc3` (#3842) by @sentrivana +- Fix asyncio testing setup (#3832) by @sl0thentr0py +- Bump `codecov/codecov-action` from `5.0.2` to `5.0.7` (#3821) by @dependabot +- Fix CI (#3834) by @sentrivana +- Use new ClickHouse GH action (#3826) by @antonpirker + +## 2.19.0 + +### Various fixes & improvements + +- New: introduce `rust_tracing` integration. See https://docs.sentry.io/platforms/python/integrations/rust_tracing/ (#3717) by @matt-codecov +- Auto enable Litestar integration (#3540) by @provinzkraut +- Deprecate `sentry_sdk.init` context manager (#3729) by @szokeasaurusrex +- feat(spotlight): Send PII to Spotlight when no DSN is set (#3804) by @BYK +- feat(spotlight): Add info logs when Sentry is enabled (#3735) by @BYK +- feat(spotlight): Inject Spotlight button on Django (#3751) by @BYK +- feat(spotlight): Auto enable cache_spans for Spotlight on DEBUG (#3791) by @BYK +- fix(logging): Handle parameter `stack_info` for the `LoggingIntegration` (#3745) by @gmcrocetti +- fix(pure-eval): Make sentry-sdk[pure-eval] installable with pip==24.0 (#3757) by @sentrivana +- fix(rust_tracing): include_tracing_fields arg to control unvetted data in rust_tracing integration (#3780) by @matt-codecov +- fix(aws) Fix aws lambda tests (by reducing event size) (#3770) by @antonpirker +- fix(arq): fix integration with Worker settings as a dict (#3742) by @saber-solooki +- fix(httpx): Prevent Sentry baggage duplication (#3728) by @szokeasaurusrex +- fix(falcon): Don't exhaust request body stream (#3768) by @szokeasaurusrex +- fix(integrations): Check `retries_left` before capturing exception (#3803) by @malkovro +- fix(openai): Use name instead of description (#3807) by @sourceful-rob +- test(gcp): Only run GCP tests when they should (#3721) by @szokeasaurusrex +- chore: Shorten CI workflow names (#3805) by @sentrivana +- chore: Test with pyspark prerelease (#3760) by @sentrivana +- build(deps): bump codecov/codecov-action from 4.6.0 to 5.0.2 (#3792) by @dependabot +- build(deps): bump actions/checkout from 4.2.1 to 4.2.2 (#3691) by @dependabot + +## 2.18.0 + +### Various fixes & improvements + +- **New integration:** Add [LaunchDarkly](https://launchdarkly.com/) integration (#3648) by @cmanallen + + For more information, see the documentation for the [LaunchDarklyIntegration](https://docs.sentry.io/platforms/python/integrations/launchdarkly/). + +- **New integration:** Add [OpenFeature](https://openfeature.dev/) feature flagging integration (#3648) by @cmanallen + + For more information, see the documentation for the [OpenFeatureIntegration](https://docs.sentry.io/platforms/python/integrations/openfeature/). + +- Add LaunchDarkly and OpenFeature integration (#3648) by @cmanallen +- Correct typo in a comment (#3726) by @szokeasaurusrex +- End `http.client` span on timeout (#3723) by @Zylphrex +- Check for `h2` existence in HTTP/2 transport (#3690) by @BYK +- Use `type()` instead when extracting frames (#3716) by @Zylphrex +- Prefer `python_multipart` import over `multipart` (#3710) by @musicinmybrain +- Update active thread for asgi (#3669) by @Zylphrex +- Only enable HTTP2 when DSN is HTTPS (#3678) by @BYK +- Prepare for upstream Strawberry extension removal (#3649) by @DoctorJohn +- Enhance README with improved clarity and developer-friendly examples (#3667) by @UTSAVS26 +- Run license compliance action on all PRs (#3699) by @szokeasaurusrex +- Run CodeQL action on all PRs (#3698) by @szokeasaurusrex +- Fix UTC assuming test (#3722) by @BYK +- Exclude fakeredis 2.26.0 on py3.6 and 3.7 (#3695) by @szokeasaurusrex +- Unpin `pytest` for `tornado-latest` tests (#3714) by @szokeasaurusrex +- Install `pytest-asyncio` for `redis` tests (Python 3.12-13) (#3706) by @szokeasaurusrex +- Clarify that only pinned tests are required (#3713) by @szokeasaurusrex +- Remove accidentally-committed print (#3712) by @szokeasaurusrex +- Disable broken RQ test in newly-released RQ 2.0 (#3708) by @szokeasaurusrex +- Unpin `pytest` for `celery` tests (#3701) by @szokeasaurusrex +- Unpin `pytest` on Python 3.8+ `gevent` tests (#3700) by @szokeasaurusrex +- Unpin `pytest` for Python 3.8+ `common` tests (#3697) by @szokeasaurusrex +- Remove `pytest` pin in `requirements-devenv.txt` (#3696) by @szokeasaurusrex +- Test with Falcon 4.0 (#3684) by @sentrivana + +## 2.17.0 + +### Various fixes & improvements + +- Add support for async calls in Anthropic and OpenAI integration (#3497) by @vetyy +- Allow custom transaction names in ASGI (#3664) by @sl0thentr0py +- Langchain: Handle case when parent span wasn't traced (#3656) by @rbasoalto +- Fix Anthropic integration when using tool calls (#3615) by @kwnath +- More defensive Django Spotlight middleware injection (#3665) by @BYK +- Remove `ensure_integration_enabled_async` (#3632) by @sentrivana +- Test with newer Falcon version (#3644, #3653, #3662) by @sentrivana +- Fix mypy (#3657) by @sentrivana +- Fix flaky transport test (#3666) by @sentrivana +- Remove pin on `sphinx` (#3650) by @sentrivana +- Bump `actions/checkout` from `4.2.0` to `4.2.1` (#3651) by @dependabot + +## 2.16.0 + +### Integrations + +- Bottle: Add `failed_request_status_codes` (#3618) by @szokeasaurusrex + + You can now define a set of integers that will determine which status codes + should be reported to Sentry. + + ```python + sentry_sdk.init( + integrations=[ + BottleIntegration( + failed_request_status_codes={403, *range(500, 600)}, + ) + ] + ) + ``` + + Examples of valid `failed_request_status_codes`: + + - `{500}` will only send events on HTTP 500. + - `{400, *range(500, 600)}` will send events on HTTP 400 as well as the 5xx range. + - `{500, 503}` will send events on HTTP 500 and 503. + - `set()` (the empty set) will not send events for any HTTP status code. + + The default is `{*range(500, 600)}`, meaning that all 5xx status codes are reported to Sentry. + +- Bottle: Delete never-reached code (#3605) by @szokeasaurusrex +- Redis: Remove flaky test (#3626) by @sentrivana +- Django: Improve getting `psycopg3` connection info (#3580) by @nijel +- Django: Add `SpotlightMiddleware` when Spotlight is enabled (#3600) by @BYK +- Django: Open relevant error when `SpotlightMiddleware` is on (#3614) by @BYK +- Django: Support `http_methods_to_capture` in ASGI Django (#3607) by @sentrivana + + ASGI Django now also supports the `http_methods_to_capture` integration option. This is a configurable tuple of HTTP method verbs that should create a transaction in Sentry. The default is `("CONNECT", "DELETE", "GET", "PATCH", "POST", "PUT", "TRACE",)`. `OPTIONS` and `HEAD` are not included by default. + + Here's how to use it: + + ```python + sentry_sdk.init( + integrations=[ + DjangoIntegration( + http_methods_to_capture=("GET", "POST"), + ), + ], + ) + ``` + +### Miscellaneous + +- Add 3.13 to setup.py (#3574) by @sentrivana +- Add 3.13 to basepython (#3589) by @sentrivana +- Fix type of `sample_rate` in DSC (and add explanatory tests) (#3603) by @antonpirker +- Add `httpcore` based `HTTP2Transport` (#3588) by @BYK +- Add opportunistic Brotli compression (#3612) by @BYK +- Add `__notes__` support (#3620) by @szokeasaurusrex +- Remove useless makefile targets (#3604) by @antonpirker +- Simplify tox version spec (#3609) by @sentrivana +- Consolidate contributing docs (#3606) by @antonpirker +- Bump `codecov/codecov-action` from `4.5.0` to `4.6.0` (#3617) by @dependabot + +## 2.15.0 + +### Integrations + +- Configure HTTP methods to capture in ASGI/WSGI middleware and frameworks (#3531) by @antonpirker + + We've added a new option to the Django, Flask, Starlette and FastAPI integrations called `http_methods_to_capture`. This is a configurable tuple of HTTP method verbs that should create a transaction in Sentry. The default is `("CONNECT", "DELETE", "GET", "PATCH", "POST", "PUT", "TRACE",)`. `OPTIONS` and `HEAD` are not included by default. + + Here's how to use it (substitute Flask for your framework integration): + + ```python + sentry_sdk.init( + integrations=[ + FlaskIntegration( + http_methods_to_capture=("GET", "POST"), + ), + ], + ) + ``` + +- Django: Allow ASGI to use `drf_request` in `DjangoRequestExtractor` (#3572) by @PakawiNz +- Django: Don't let `RawPostDataException` bubble up (#3553) by @sentrivana +- Django: Add `sync_capable` to `SentryWrappingMiddleware` (#3510) by @szokeasaurusrex +- AIOHTTP: Add `failed_request_status_codes` (#3551) by @szokeasaurusrex + + You can now define a set of integers that will determine which status codes + should be reported to Sentry. + + ```python + sentry_sdk.init( + integrations=[ + AioHttpIntegration( + failed_request_status_codes={403, *range(500, 600)}, + ) + ] + ) + ``` + + Examples of valid `failed_request_status_codes`: + + - `{500}` will only send events on HTTP 500. + - `{400, *range(500, 600)}` will send events on HTTP 400 as well as the 5xx range. + - `{500, 503}` will send events on HTTP 500 and 503. + - `set()` (the empty set) will not send events for any HTTP status code. + + The default is `{*range(500, 600)}`, meaning that all 5xx status codes are reported to Sentry. + +- AIOHTTP: Delete test which depends on AIOHTTP behavior (#3568) by @szokeasaurusrex +- AIOHTTP: Handle invalid responses (#3554) by @szokeasaurusrex +- FastAPI/Starlette: Support new `failed_request_status_codes` (#3563) by @szokeasaurusrex + + The format of `failed_request_status_codes` has changed from a list + of integers and containers to a set: + + ```python + sentry_sdk.init( + integrations=StarletteIntegration( + failed_request_status_codes={403, *range(500, 600)}, + ), + ) + ``` + + The old way of defining `failed_request_status_codes` will continue to work + for the time being. Examples of valid new-style `failed_request_status_codes`: + + - `{500}` will only send events on HTTP 500. + - `{400, *range(500, 600)}` will send events on HTTP 400 as well as the 5xx range. + - `{500, 503}` will send events on HTTP 500 and 503. + - `set()` (the empty set) will not send events for any HTTP status code. + + The default is `{*range(500, 600)}`, meaning that all 5xx status codes are reported to Sentry. + +- FastAPI/Starlette: Fix `failed_request_status_codes=[]` (#3561) by @szokeasaurusrex +- FastAPI/Starlette: Remove invalid `failed_request_status_code` tests (#3560) by @szokeasaurusrex +- FastAPI/Starlette: Refactor shared test parametrization (#3562) by @szokeasaurusrex + +### Miscellaneous + +- Deprecate `sentry_sdk.metrics` (#3512) by @szokeasaurusrex +- Add `name` parameter to `start_span()` and deprecate `description` parameter (#3524 & #3525) by @antonpirker +- Fix `add_query_source` with modules outside of project root (#3313) by @rominf +- Test more integrations on 3.13 (#3578) by @sentrivana +- Fix trailing whitespace (#3579) by @sentrivana +- Improve `get_integration` typing (#3550) by @szokeasaurusrex +- Make import-related tests stable (#3548) by @BYK +- Fix breadcrumb sorting (#3511) by @sentrivana +- Fix breadcrumb timestamp casting and its tests (#3546) by @BYK +- Don't use deprecated `logger.warn` (#3552) by @sentrivana +- Fix Cohere API change (#3549) by @BYK +- Fix deprecation message (#3536) by @antonpirker +- Remove experimental `explain_plan` feature. (#3534) by @antonpirker +- X-fail one of the Lambda tests (#3592) by @antonpirker +- Update Codecov config (#3507) by @antonpirker +- Update `actions/upload-artifact` to `v4` with merge (#3545) by @joshuarli +- Bump `actions/checkout` from `4.1.7` to `4.2.0` (#3585) by @dependabot + +## 2.14.0 + +### Various fixes & improvements + +- New `SysExitIntegration` (#3401) by @szokeasaurusrex + + For more information, see the documentation for the [SysExitIntegration](https://docs.sentry.io/platforms/python/integrations/sys_exit). + +- Add `SENTRY_SPOTLIGHT` env variable support (#3443) by @BYK +- Support Strawberry `0.239.2` (#3491) by @szokeasaurusrex +- Add separate `pii_denylist` to `EventScrubber` and run it always (#3463) by @sl0thentr0py +- Celery: Add wrapper for `Celery().send_task` to support behavior as `Task.apply_async` (#2377) by @divaltor +- Django: SentryWrappingMiddleware.__init__ fails if super() is object (#2466) by @cameron-simpson +- Fix data_category for sessions envelope items (#3473) by @sl0thentr0py +- Fix non-UTC timestamps (#3461) by @szokeasaurusrex +- Remove obsolete object as superclass (#3480) by @sentrivana +- Replace custom `TYPE_CHECKING` with stdlib `typing.TYPE_CHECKING` (#3447) by @dev-satoshi +- Refactor `tracing_utils.py` (#3452) by @rominf +- Explicitly export symbol in subpackages instead of ignoring (#3400) by @hartungstenio +- Better test coverage reports (#3498) by @antonpirker +- Fixed config for old coverage versions (#3504) by @antonpirker +- Fix AWS Lambda tests (#3495) by @antonpirker +- Remove broken Bottle tests (#3505) by @sentrivana + +## 2.13.0 + +### Various fixes & improvements + +- **New integration:** [Ray](https://docs.sentry.io/platforms/python/integrations/ray/) (#2400) (#2444) by @glowskir + + Usage: (add the RayIntegration to your `sentry_sdk.init()` call and make sure it is called in the worker processes) + ```python + import ray + + import sentry_sdk + from sentry_sdk.integrations.ray import RayIntegration + + def init_sentry(): + sentry_sdk.init( + dsn="...", + traces_sample_rate=1.0, + integrations=[RayIntegration()], + ) + + init_sentry() + + ray.init( + runtime_env=dict(worker_process_setup_hook=init_sentry), + ) + ``` + For more information, see the documentation for the [Ray integration](https://docs.sentry.io/platforms/python/integrations/ray/). + +- **New integration:** [Litestar](https://docs.sentry.io/platforms/python/integrations/litestar/) (#2413) (#3358) by @KellyWalker + + Usage: (add the LitestarIntegration to your `sentry_sdk.init()`) + ```python + from litestar import Litestar, get + + import sentry_sdk + from sentry_sdk.integrations.litestar import LitestarIntegration + + sentry_sdk.init( + dsn="...", + traces_sample_rate=1.0, + integrations=[LitestarIntegration()], + ) + + @get("/") + async def index() -> str: + return "Hello, world!" + + app = Litestar(...) + ``` + For more information, see the documentation for the [Litestar integration](https://docs.sentry.io/platforms/python/integrations/litestar/). + +- **New integration:** [Dramatiq](https://docs.sentry.io/platforms/python/integrations/dramatiq/) from @jacobsvante (#3397) by @antonpirker + Usage: (add the DramatiqIntegration to your `sentry_sdk.init()`) + ```python + import dramatiq + + import sentry_sdk + from sentry_sdk.integrations.dramatiq import DramatiqIntegration + + sentry_sdk.init( + dsn="...", + traces_sample_rate=1.0, + integrations=[DramatiqIntegration()], + ) + + @dramatiq.actor(max_retries=0) + def dummy_actor(x, y): + return x / y + + dummy_actor.send(12, 0) + ``` + + For more information, see the documentation for the [Dramatiq integration](https://docs.sentry.io/platforms/python/integrations/dramatiq/). + +- **New config option:** Expose `custom_repr` function that precedes `safe_repr` invocation in serializer (#3438) by @sl0thentr0py + + See: https://docs.sentry.io/platforms/python/configuration/options/#custom-repr + +- Profiling: Add client SDK info to profile chunk (#3386) by @Zylphrex +- Serialize vars early to avoid living references (#3409) by @sl0thentr0py +- Deprecate hub-based `sessions.py` logic (#3419) by @szokeasaurusrex +- Deprecate `is_auto_session_tracking_enabled` (#3428) by @szokeasaurusrex +- Add note to generated yaml files (#3423) by @sentrivana +- Slim down PR template (#3382) by @sentrivana +- Use new banner in readme (#3390) by @sentrivana + +## 2.12.0 + +### Various fixes & improvements + +- API: Expose the scope getters to top level API and use them everywhere (#3357) by @sl0thentr0py +- API: `push_scope` deprecation warning (#3355) (#3355) by @szokeasaurusrex +- API: Replace `push_scope` (#3353, #3354) by @szokeasaurusrex +- API: Deprecate, avoid, or stop using `configure_scope` (#3348, #3349, #3350, #3351) by @szokeasaurusrex +- OTel: Remove experimental autoinstrumentation (#3239) by @sentrivana +- Graphene: Add span for grapqhl operation (#2788) by @czyber +- AI: Add async support for `ai_track` decorator (#3376) by @czyber +- CI: Workaround bug preventing Django test runs (#3371) by @szokeasaurusrex +- CI: Remove Django setuptools pin (#3378) by @szokeasaurusrex +- Tests: Test with Django 5.1 RC (#3370) by @sentrivana +- Broaden `add_attachment` type (#3342) by @szokeasaurusrex +- Add span data to the transactions trace context (#3374) by @antonpirker +- Gracefully fail attachment path not found case (#3337) by @sl0thentr0py +- Document attachment parameters (#3342) by @szokeasaurusrex +- Bump checkouts/data-schemas from `0feb234` to `6d2c435` (#3369) by @dependabot +- Bump checkouts/data-schemas from `88273a9` to `0feb234` (#3252) by @dependabot + +## 2.11.0 + +### Various fixes & improvements + +- Add `disabled_integrations` (#3328) by @sentrivana + + Disabling individual integrations is now much easier. + Instead of disabling all automatically enabled integrations and specifying the ones + you want to keep, you can now use the new + [`disabled_integrations`](https://docs.sentry.io/platforms/python/configuration/options/#auto-enabling-integrations) + config option to provide a list of integrations to disable: + + ```python + import sentry_sdk + from sentry_sdk.integrations.flask import FlaskIntegration + + sentry_sdk.init( + # Do not use the Flask integration even if Flask is installed. + disabled_integrations=[ + FlaskIntegration(), + ], + ) + ``` + +- Use operation name as transaction name in Strawberry (#3294) by @sentrivana +- WSGI integrations respect `SCRIPT_NAME` env variable (#2622) by @sarvaSanjay +- Make Django DB spans have origin `auto.db.django` (#3319) by @antonpirker +- Sort breadcrumbs by time before sending (#3307) by @antonpirker +- Fix `KeyError('sentry-monitor-start-timestamp-s')` (#3278) by @Mohsen-Khodabakhshi +- Set MongoDB tags directly on span data (#3290) by @0Calories +- Lower logger level for some messages (#3305) by @sentrivana and @antonpirker +- Emit deprecation warnings from `Hub` API (#3280) by @szokeasaurusrex +- Clarify that `instrumenter` is internal-only (#3299) by @szokeasaurusrex +- Support Django 5.1 (#3207) by @sentrivana +- Remove apparently unnecessary `if` (#3298) by @szokeasaurusrex +- Preliminary support for Python 3.13 (#3200) by @sentrivana +- Move `sentry_sdk.init` out of `hub.py` (#3276) by @szokeasaurusrex +- Unhardcode integration list (#3240) by @rominf +- Allow passing of PostgreSQL port in tests (#3281) by @rominf +- Add tests for `@ai_track` decorator (#3325) by @colin-sentry +- Do not include type checking code in coverage report (#3327) by @antonpirker +- Fix test_installed_modules (#3309) by @szokeasaurusrex +- Fix typos and grammar in a comment (#3293) by @szokeasaurusrex +- Fixed failed tests setup (#3303) by @antonpirker +- Only assert warnings we are interested in (#3314) by @szokeasaurusrex + +## 2.10.0 + +### Various fixes & improvements + +- Add client cert and key support to `HttpTransport` (#3258) by @grammy-jiang + + Add `cert_file` and `key_file` to your `sentry_sdk.init` to use a custom client cert and key. Alternatively, the environment variables `CLIENT_CERT_FILE` and `CLIENT_KEY_FILE` can be used as well. + +- OpenAI: Lazy initialize tiktoken to avoid http at import time (#3287) by @colin-sentry +- OpenAI, Langchain: Make tiktoken encoding name configurable + tiktoken usage opt-in (#3289) by @colin-sentry + + Fixed a bug where having certain packages installed along the Sentry SDK caused an HTTP request to be made to OpenAI infrastructure when the Sentry SDK was initialized. The request was made when the `tiktoken` package and at least one of the `openai` or `langchain` packages were installed. + + The request was fetching a `tiktoken` encoding in order to correctly measure token usage in some OpenAI and Langchain calls. This behavior is now opt-in. The choice of encoding to use was made configurable as well. To opt in, set the `tiktoken_encoding_name` parameter in the OpenAPI or Langchain integration. + + ```python + sentry_sdk.init( + integrations=[ + OpenAIIntegration(tiktoken_encoding_name="cl100k_base"), + LangchainIntegration(tiktoken_encoding_name="cl100k_base"), + ], + ) + ``` + +- PyMongo: Send query description as valid JSON (#3291) by @0Calories +- Remove Python 2 compatibility code (#3284) by @szokeasaurusrex +- Fix `sentry_sdk.init` type hint (#3283) by @szokeasaurusrex +- Deprecate `hub` in `Profile` (#3270) by @szokeasaurusrex +- Stop using `Hub` in `init` (#3275) by @szokeasaurusrex +- Delete `_should_send_default_pii` (#3274) by @szokeasaurusrex +- Remove `Hub` usage in `conftest` (#3273) by @szokeasaurusrex +- Rename debug logging filter (#3260) by @szokeasaurusrex +- Update `NoOpSpan.finish` signature (#3267) by @szokeasaurusrex +- Remove `Hub` in `Transaction.finish` (#3267) by @szokeasaurusrex +- Remove Hub from `capture_internal_exception` logic (#3264) by @szokeasaurusrex +- Improve `Scope._capture_internal_exception` type hint (#3264) by @szokeasaurusrex +- Correct `ExcInfo` type (#3266) by @szokeasaurusrex +- Stop using `Hub` in `tracing_utils` (#3269) by @szokeasaurusrex + +## 2.9.0 + +### Various fixes & improvements + +- ref(transport): Improve event data category typing (#3243) by @szokeasaurusrex +- ref(tracing): Improved handling of span status (#3261) by @antonpirker +- test(client): Add tests for dropped span client reports (#3244) by @szokeasaurusrex +- test(transport): Test new client report features (#3244) by @szokeasaurusrex +- feat(tracing): Record lost spans in client reports (#3244) by @szokeasaurusrex +- test(sampling): Replace custom logic with `capture_record_lost_event_calls` (#3257) by @szokeasaurusrex +- test(transport): Non-order-dependent discarded events assertion (#3255) by @szokeasaurusrex +- test(core): Introduce `capture_record_lost_event_calls` fixture (#3254) by @szokeasaurusrex +- test(core): Fix non-idempotent test (#3253) by @szokeasaurusrex + +## 2.8.0 + +### Various fixes & improvements + +- `profiler_id` uses underscore (#3249) by @Zylphrex +- Don't send full env to subprocess (#3251) by @kmichel-aiven +- Stop using `Hub` in `HttpTransport` (#3247) by @szokeasaurusrex +- Remove `ipdb` from test requirements (#3237) by @rominf +- Avoid propagation of empty baggage (#2968) by @hartungstenio +- Add entry point for `SentryPropagator` (#3086) by @mender +- Bump checkouts/data-schemas from `8c13457` to `88273a9` (#3225) by @dependabot + +## 2.7.1 + +### Various fixes & improvements + +- fix(otel): Fix missing baggage (#3218) by @sentrivana +- This is the config file of asdf-vm which we do not use. (#3215) by @antonpirker +- Added option to disable middleware spans in Starlette (#3052) by @antonpirker +- build: Update tornado version in setup.py to match code check. (#3206) by @aclemons + +## 2.7.0 + +- Add `origin` to spans and transactions (#3133) by @antonpirker +- OTel: Set up typing for OTel (#3168) by @sentrivana +- OTel: Auto instrumentation skeleton (#3143) by @sentrivana +- OpenAI: If there is an internal error, still return a value (#3192) by @colin-sentry +- MongoDB: Add MongoDB collection span tag (#3182) by @0Calories +- MongoDB: Change span operation from `db.query` to `db` (#3186) by @0Calories +- MongoDB: Remove redundant command name in query description (#3189) by @0Calories +- Apache Spark: Fix spark driver integration (#3162) by @seyoon-lim +- Apache Spark: Add Spark test suite to tox.ini and to CI (#3199) by @sentrivana +- Codecov: Add failed test commits in PRs (#3190) by @antonpirker +- Update library, Python versions in tests (#3202) by @sentrivana +- Remove Hub from our test suite (#3197) by @antonpirker +- Use env vars for default CA cert bundle location (#3160) by @DragoonAethis +- Create a separate test group for AI (#3198) by @sentrivana +- Add additional stub packages for type checking (#3122) by @Daverball +- Proper naming of requirements files (#3191) by @antonpirker +- Pinning pip because new version does not work with some versions of Celery and Httpx (#3195) by @antonpirker +- build(deps): bump supercharge/redis-github-action from 1.7.0 to 1.8.0 (#3193) by @dependabot +- build(deps): bump actions/checkout from 4.1.6 to 4.1.7 (#3171) by @dependabot +- build(deps): update pytest-asyncio requirement (#3087) by @dependabot + +## 2.6.0 + +- Introduce continuous profiling mode (#2830) by @Zylphrex +- Profiling: Add deprecation comment for profiler internals (#3167) by @sentrivana +- Profiling: Move thread data to trace context (#3157) by @Zylphrex +- Explicitly export cron symbols for typecheckers (#3072) by @spladug +- Cleaning up ASGI tests for Django (#3180) by @antonpirker +- Celery: Add Celery receive latency (#3174) by @antonpirker +- Metrics: Update type hints for tag values (#3156) by @elramen +- Django: Fix psycopg3 reconnect error (#3111) by @szokeasaurusrex +- Tracing: Keep original function signature when decorated (#3178) by @sentrivana +- Reapply "Refactor the Celery Beat integration (#3105)" (#3144) (#3175) by @antonpirker +- Added contributor image to readme (#3183) by @antonpirker +- bump actions/checkout from 4.1.4 to 4.1.6 (#3147) by @dependabot +- bump checkouts/data-schemas from `59f9683` to `8c13457` (#3146) by @dependabot + +## 2.5.1 + +This change fixes a regression in our cron monitoring feature, which caused cron checkins not to be sent. The regression appears to have been introduced in version 2.4.0. + +**We recommend that all users, who use Cron monitoring and are currently running sentry-python ≥2.4.0, upgrade to this release as soon as possible!** + +### Other fixes & improvements + +- feat(tracing): Warn if not-started transaction entered (#3003) by @szokeasaurusrex +- test(scope): Ensure `last_event_id` cleared (#3124) by @szokeasaurusrex +- fix(scope): Clear last_event_id on scope clear (#3124) by @szokeasaurusrex + +## 2.5.0 + +### Various fixes & improvements + +- Allow to configure status codes to report to Sentry in Starlette and FastAPI (#3008) by @sentrivana + + By passing a new option to the FastAPI and Starlette integrations, you're now able to configure what + status codes should be sent as events to Sentry. Here's how it works: + + ```python + from sentry_sdk.integrations.starlette import StarletteIntegration + from sentry_sdk.integrations.fastapi import FastApiIntegration + + sentry_sdk.init( + # ... + integrations=[ + StarletteIntegration( + failed_request_status_codes=[403, range(500, 599)], + ), + FastApiIntegration( + failed_request_status_codes=[403, range(500, 599)], + ), + ] + ) + ``` + + `failed_request_status_codes` expects a list of integers or containers (objects that allow membership checks via `in`) + of integers. Examples of valid `failed_request_status_codes`: + + - `[500]` will only send events on HTTP 500. + - `[400, range(500, 599)]` will send events on HTTP 400 as well as the 500-599 range. + - `[500, 503]` will send events on HTTP 500 and 503. + + The default is `[range(500, 599)]`. + + See the [FastAPI](https://docs.sentry.io/platforms/python/integrations/fastapi/) and [Starlette](https://docs.sentry.io/platforms/python/integrations/starlette/) integration docs for more details. + +- Support multiple keys with `cache_prefixes` (#3136) by @sentrivana +- Support integer Redis keys (#3132) by @sentrivana +- Update SDK version in CONTRIBUTING.md (#3129) by @sentrivana +- Bump actions/checkout from 4.1.4 to 4.1.5 (#3067) by @dependabot + +## 2.4.0 + +### Various fixes & improvements + +- Celery: Made `cache.key` span data field a list (#3110) by @antonpirker +- Celery Beat: Refactor the Celery Beat integration (#3105) by @antonpirker +- GRPC: Add None check for grpc.aio interceptor (#3109) by @ordinary-jamie +- Docs: Remove `last_event_id` from migration guide (#3126) by @szokeasaurusrex +- fix(django): Proper transaction names for i18n routes (#3104) by @sentrivana +- fix(scope): Copy `_last_event_id` in `Scope.__copy__` (#3123) by @szokeasaurusrex +- fix(tests): Adapt to new Anthropic version (#3119) by @sentrivana +- build(deps): bump checkouts/data-schemas from `4381a97` to `59f9683` (#3066) by @dependabot + +## 2.3.1 + +### Various fixes & improvements + +- Handle also byte arras as strings in Redis caches (#3101) by @antonpirker +- Do not crash exceptiongroup (by patching excepthook and keeping the name of the function) (#3099) by @antonpirker + +## 2.3.0 + +### Various fixes & improvements + +- NEW: Redis integration supports now Sentry Caches module. See https://docs.sentry.io/product/performance/caches/ (#3073) by @antonpirker +- NEW: Django integration supports now Sentry Caches module. See https://docs.sentry.io/product/performance/caches/ (#3009) by @antonpirker +- Fix `cohere` testsuite for new release of `cohere` (#3098) by @antonpirker +- Fix ClickHouse integration where `_sentry_span` might be missing (#3096) by @sentrivana + +## 2.2.1 + +### Various fixes & improvements + +- Add conditional check for delivery_info's existence (#3083) by @cmanallen +- Updated deps for latest langchain version (#3092) by @antonpirker +- Fixed grpcio extras to work as described in the docs (#3081) by @antonpirker +- Use pythons venv instead of virtualenv to create virtual envs (#3077) by @antonpirker +- Celery: Add comment about kwargs_headers (#3079) by @szokeasaurusrex +- Celery: Queues module producer implementation (#3079) by @szokeasaurusrex +- Fix N803 flake8 failures (#3082) by @szokeasaurusrex + +## 2.2.0 + +### New features + +- Celery integration now sends additional data to Sentry to enable new features to guage the health of your queues +- Added a new integration for Cohere +- Reintroduced the `last_event_id` function, which had been removed in 2.0.0 + +### Other fixes & improvements + +- Add tags + data passing functionality to @ai_track (#3071) by @colin-sentry +- Only propagate headers from spans within transactions (#3070) by @szokeasaurusrex +- Improve type hints for set metrics (#3048) by @elramen +- Fix `get_client` typing (#3063) by @szokeasaurusrex +- Auto-enable Anthropic integration + gate imports (#3054) by @colin-sentry +- Made `MeasurementValue.unit` NotRequired (#3051) by @antonpirker + +## 2.1.1 + +- Fix trace propagation in Celery tasks started by Celery Beat. (#3047) by @antonpirker + +## 2.1.0 + +- fix(quart): Fix Quart integration (#3043) by @szokeasaurusrex + +- **New integration:** [Langchain](https://docs.sentry.io/platforms/python/integrations/langchain/) (#2911) by @colin-sentry + + Usage: (Langchain is auto enabling, so you do not need to do anything special) + ```python + from langchain_openai import ChatOpenAI + import sentry_sdk + + sentry_sdk.init( + dsn="...", + enable_tracing=True, + traces_sample_rate=1.0, + ) + + llm = ChatOpenAI(model="gpt-3.5-turbo-0125", temperature=0) + ``` + + Check out [the LangChain docs](https://docs.sentry.io/platforms/python/integrations/langchain/) for details. + +- **New integration:** [Anthropic](https://docs.sentry.io/platforms/python/integrations/anthropic/) (#2831) by @czyber + + Usage: (add the AnthropicIntegration to your `sentry_sdk.init()` call) + ```python + from anthropic import Anthropic + + import sentry_sdk + + sentry_sdk.init( + dsn="...", + enable_tracing=True, + traces_sample_rate=1.0, + integrations=[AnthropicIntegration()], + ) + + client = Anthropic() + ``` + Check out [the Anthropic docs](https://docs.sentry.io/platforms/python/integrations/anthropic/) for details. + +- **New integration:** [Huggingface Hub](https://docs.sentry.io/platforms/python/integrations/huggingface/) (#3033) by @colin-sentry + + Usage: (Huggingface Hub is auto enabling, so you do not need to do anything special) + + ```python + import sentry_sdk + from huggingface_hub import InferenceClient + + sentry_sdk.init( + dsn="...", + enable_tracing=True, + traces_sample_rate=1.0, + ) + + client = InferenceClient("some-model") + ``` + + Check out [the Huggingface docs](https://docs.sentry.io/platforms/python/integrations/huggingface/) for details. (comming soon!) + +- fix(huggingface): Reduce API cross-section for huggingface in test (#3042) by @colin-sentry +- fix(django): Fix Django ASGI integration on Python 3.12 (#3027) by @bellini666 +- feat(perf): Add ability to put measurements directly on spans. (#2967) by @colin-sentry +- fix(tests): Fix trytond tests (#3031) by @sentrivana +- fix(tests): Update `pytest-asyncio` to fix CI (#3030) by @sentrivana +- fix(docs): Link to respective migration guides directly (#3020) by @sentrivana +- docs(scope): Add docstring to `Scope.set_tags` (#2978) by @szokeasaurusrex +- test(scope): Fix typos in assert error message (#2978) by @szokeasaurusrex +- feat(scope): New `set_tags` function (#2978) by @szokeasaurusrex +- test(scope): Add unit test for `Scope.set_tags` (#2978) by @szokeasaurusrex +- feat(scope): Add `set_tags` to top-level API (#2978) by @szokeasaurusrex +- test(scope): Add unit test for top-level API `set_tags` (#2978) by @szokeasaurusrex +- feat(tests): Parallelize tox (#3025) by @sentrivana +- build(deps): Bump checkouts/data-schemas from `4aa14a7` to `4381a97` (#3028) by @dependabot +- meta(license): Bump copyright year (#3029) by @szokeasaurusrex + +## 2.0.1 + +### Various fixes & improvements + +- Fix: Do not use convenience decorator (#3022) by @sentrivana +- Refactoring propagation context (#2970) by @antonpirker +- Use `pid` for test database name in Django tests (#2998) by @antonpirker +- Remove outdated RC mention in docs (#3018) by @sentrivana +- Delete inaccurate comment from docs (#3002) by @szokeasaurusrex +- Add Lambda function that deletes test Lambda functions (#2960) by @antonpirker +- Correct discarded transaction debug message (#3002) by @szokeasaurusrex +- Add tests for discarded transaction debug messages (#3002) by @szokeasaurusrex +- Fix comment typo in metrics (#2992) by @szokeasaurusrex +- build(deps): bump actions/checkout from 4.1.1 to 4.1.4 (#3011) by @dependabot +- build(deps): bump checkouts/data-schemas from `1e17eb5` to `4aa14a7` (#2997) by @dependabot + +## 2.0.0 + +This is the first major update in a *long* time! + +We dropped support for some ancient languages and frameworks (Yes, Python 2.7 is no longer supported). Additionally we refactored a big part of the foundation of the SDK (how data inside the SDK is handled). + +We hope you like it! + +For a shorter version of what you need to do, to upgrade to Sentry SDK 2.0 see: https://docs.sentry.io/platforms/python/migration/1.x-to-2.x + +### New Features + +- Additional integrations will now be activated automatically if the SDK detects the respective package is installed: Ariadne, ARQ, asyncpg, Chalice, clickhouse-driver, GQL, Graphene, huey, Loguru, PyMongo, Quart, Starlite, Strawberry. +- Added new API for custom instrumentation: `new_scope`, `isolation_scope`. See the [Deprecated](#deprecated) section to see how they map to the existing APIs. + +### Changed +(These changes are all backwards-incompatible. **Breaking Change** (if you are just skimming for that phrase)) + +- The Pyramid integration will not capture errors that might happen in `authenticated_userid()` in a custom `AuthenticationPolicy` class. +- The method `need_code_loation` of the `MetricsAggregator` was renamed to `need_code_location`. +- The `BackgroundWorker` thread used to process events was renamed from `raven-sentry.BackgroundWorker` to `sentry-sdk.BackgroundWorker`. +- The `reraise` function was moved from `sentry_sdk._compat` to `sentry_sdk.utils`. +- The `_ScopeManager` was moved from `sentry_sdk.hub` to `sentry_sdk.scope`. +- Moved the contents of `tracing_utils_py3.py` to `tracing_utils.py`. The `start_child_span_decorator` is now in `sentry_sdk.tracing_utils`. +- The actual implementation of `get_current_span` was moved to `sentry_sdk.tracing_utils`. `sentry_sdk.get_current_span` is still accessible as part of the top-level API. +- `sentry_sdk.tracing_utils.add_query_source()`: Removed the `hub` parameter. It is not necessary anymore. +- `sentry_sdk.tracing_utils.record_sql_queries()`: Removed the `hub` parameter. It is not necessary anymore. +- `sentry_sdk.tracing_utils.get_current_span()` does now take a `scope` instead of a `hub` as parameter. +- `sentry_sdk.tracing_utils.should_propagate_trace()` now takes a `Client` instead of a `Hub` as first parameter. +- `sentry_sdk.utils.is_sentry_url()` now takes a `Client` instead of a `Hub` as first parameter. +- `sentry_sdk.utils._get_contextvars` does not return a tuple with three values, but a tuple with two values. The `copy_context` was removed. +- If you create a transaction manually and later mutate the transaction in a `configure_scope` block this does not work anymore. Here is a recipe on how to change your code to make it work: + Your existing implementation: + ```python + transaction = sentry_sdk.transaction(...) + + # later in the code execution: + + with sentry_sdk.configure_scope() as scope: + scope.set_transaction_name("new-transaction-name") + ``` + + needs to be changed to this: + ```python + transaction = sentry_sdk.transaction(...) + + # later in the code execution: + + scope = sentry_sdk.get_current_scope() + scope.set_transaction_name("new-transaction-name") + ``` +- The classes listed in the table below are now abstract base classes. Therefore, they can no longer be instantiated. Subclasses can only be instantiated if they implement all of the abstract methods. +
+ Show table + + | Class | Abstract methods | + | ------------------------------------- | -------------------------------------- | + | `sentry_sdk.integrations.Integration` | `setup_once` | + | `sentry_sdk.metrics.Metric` | `add`, `serialize_value`, and `weight` | + | `sentry_sdk.profiler.Scheduler` | `setup` and `teardown` | + | `sentry_sdk.transport.Transport` | `capture_envelope` | + +
+ +### Removed +(These changes are all backwards-incompatible. **Breaking Change** (if you are just skimming for that phrase)) + +- Removed support for Python 2 and Python 3.5. The SDK now requires at least Python 3.6. +- Removed support for Celery 3.\*. +- Removed support for Django 1.8, 1.9, 1.10. +- Removed support for Flask 0.\*. +- Removed support for gRPC < 1.39. +- Removed support for Tornado < 6. +- Removed `last_event_id()` top level API. The last event ID is still returned by `capture_event()`, `capture_exception()` and `capture_message()` but the top level API `sentry_sdk.last_event_id()` has been removed. +- Removed support for sending events to the `/store` endpoint. Everything is now sent to the `/envelope` endpoint. If you're on SaaS you don't have to worry about this, but if you're running Sentry yourself you'll need version `20.6.0` or higher of self-hosted Sentry. +- The deprecated `with_locals` configuration option was removed. Use `include_local_variables` instead. See https://docs.sentry.io/platforms/python/configuration/options/#include-local-variables. +- The deprecated `request_bodies` configuration option was removed. Use `max_request_body_size`. See https://docs.sentry.io/platforms/python/configuration/options/#max-request-body-size. +- Removed support for `user.segment`. It was also removed from the trace header as well as from the dynamic sampling context. +- Removed support for the `install` method for custom integrations. Please use `setup_once` instead. +- Removed `sentry_sdk.tracing.Span.new_span`. Use `sentry_sdk.tracing.Span.start_child` instead. +- Removed `sentry_sdk.tracing.Transaction.new_span`. Use `sentry_sdk.tracing.Transaction.start_child` instead. +- Removed support for creating transactions via `sentry_sdk.tracing.Span(transaction=...)`. To create a transaction, please use `sentry_sdk.tracing.Transaction(name=...)`. +- Removed `sentry_sdk.utils.Auth.store_api_url`. +- `sentry_sdk.utils.Auth.get_api_url`'s now accepts a `sentry_sdk.consts.EndpointType` enum instead of a string as its only parameter. We recommend omitting this argument when calling the function, since the parameter's default value is the only possible `sentry_sdk.consts.EndpointType` value. The parameter exists for future compatibility. +- Removed `tracing_utils_py2.py`. The `start_child_span_decorator` is now in `sentry_sdk.tracing_utils`. +- Removed the `sentry_sdk.profiler.Scheduler.stop_profiling` method. Any calls to this method can simply be removed, since this was a no-op method. + +### Deprecated + +- Using the `Hub` directly as well as using hub-based APIs has been deprecated. Where available, use [the top-level API instead](sentry_sdk/api.py); otherwise use the [scope API](sentry_sdk/scope.py) or the [client API](sentry_sdk/client.py). + + Before: + + ```python + with hub.start_span(...): + # do something + ``` + + After: + + ```python + import sentry_sdk + + with sentry_sdk.start_span(...): + # do something + ``` + +- Hub cloning is deprecated. + + Before: + + ```python + with Hub(Hub.current) as hub: + # do something with the cloned hub + ``` + + After: + + ```python + import sentry_sdk + + with sentry_sdk.isolation_scope() as scope: + # do something with the forked scope + ``` + +- `configure_scope` is deprecated. Use the new isolation scope directly via `get_isolation_scope()` instead. + + Before: + + ```python + with configure_scope() as scope: + # do something with `scope` + ``` + + After: + + ```python + from sentry_sdk import get_isolation_scope + + scope = get_isolation_scope() + # do something with `scope` + ``` + +- `push_scope` is deprecated. Use the new `new_scope` context manager to fork the necessary scopes. + + Before: + + ```python + with push_scope() as scope: + # do something with `scope` + ``` + + After: + + ```python + import sentry_sdk + + with sentry_sdk.new_scope() as scope: + # do something with `scope` + ``` + +- Accessing the client via the hub has been deprecated. Use the top-level `sentry_sdk.get_client()` to get the current client. +- `profiler_mode` and `profiles_sample_rate` have been deprecated as `_experiments` options. Use them as top level options instead: + ```python + sentry_sdk.init( + ..., + profiler_mode="thread", + profiles_sample_rate=1.0, + ) + ``` +- Deprecated `sentry_sdk.transport.Transport.capture_event`. Please use `sentry_sdk.transport.Transport.capture_envelope`, instead. +- Passing a function to `sentry_sdk.init`'s `transport` keyword argument has been deprecated. If you wish to provide a custom transport, please pass a `sentry_sdk.transport.Transport` instance or a subclass. +- The parameter `propagate_hub` in `ThreadingIntegration()` was deprecated and renamed to `propagate_scope`. + +## 1.45.0 + +This is the final 1.x release for the forseeable future. Development will continue on the 2.x release line. The first 2.x version will be available in the next few weeks. + +### Various fixes & improvements + +- Allow to upsert monitors (#2929) by @sentrivana + + It's now possible to provide `monitor_config` to the `monitor` decorator/context manager directly: + + ```python + from sentry_sdk.crons import monitor + + # All keys except `schedule` are optional + monitor_config = { + "schedule": {"type": "crontab", "value": "0 0 * * *"}, + "timezone": "Europe/Vienna", + "checkin_margin": 10, + "max_runtime": 10, + "failure_issue_threshold": 5, + "recovery_threshold": 5, + } + + @monitor(monitor_slug='', monitor_config=monitor_config) + def tell_the_world(): + print('My scheduled task...') + ``` + + Check out [the cron docs](https://docs.sentry.io/platforms/python/crons/) for details. + +- Add Django `signals_denylist` to filter signals that are attached to by `signals_spans` (#2758) by @lieryan + + If you want to exclude some Django signals from performance tracking, you can use the new `signals_denylist` Django option: + + ```python + import django.db.models.signals + import sentry_sdk + + sentry_sdk.init( + ... + integrations=[ + DjangoIntegration( + ... + signals_denylist=[ + django.db.models.signals.pre_init, + django.db.models.signals.post_init, + ], + ), + ], + ) + ``` + +- `increment` for metrics (#2588) by @mitsuhiko + + `increment` and `inc` are equivalent, so you can pick whichever you like more. + +- Add `value`, `unit` to `before_emit_metric` (#2958) by @sentrivana + + If you add a custom `before_emit_metric`, it'll now accept 4 arguments (the `key`, `value`, `unit` and `tags`) instead of just `key` and `tags`. + + ```python + def before_emit(key, value, unit, tags): + if key == "removed-metric": + return False + tags["extra"] = "foo" + del tags["release"] + return True + + sentry_sdk.init( + ... + _experiments={ + "before_emit_metric": before_emit, + } + ) + ``` + +- Remove experimental metric summary options (#2957) by @sentrivana + + The `_experiments` options `metrics_summary_sample_rate` and `should_summarize_metric` have been removed. + +- New normalization rules for metric keys, names, units, tags (#2946) by @sentrivana +- Change `data_category` from `statsd` to `metric_bucket` (#2954) by @cleptric +- Accessing `__mro__` might throw a `ValueError` (#2952) by @sentrivana +- Suppress prompt spawned by subprocess when using `pythonw` (#2936) by @collinbanko +- Handle `None` in GraphQL query #2715 (#2762) by @czyber +- Do not send "quiet" Sanic exceptions to Sentry (#2821) by @hamedsh +- Implement `metric_bucket` rate limits (#2933) by @cleptric +- Fix type hints for `monitor` decorator (#2944) by @szokeasaurusrex +- Remove deprecated `typing` imports in crons (#2945) by @szokeasaurusrex +- Make `monitor_config` a `TypedDict` (#2931) by @sentrivana +- Add `devenv-requirements.txt` and update env setup instructions (#2761) by @arr-ee +- Bump `types-protobuf` from `4.24.0.20240311` to `4.24.0.20240408` (#2941) by @dependabot +- Disable Codecov check run annotations (#2537) by @eliatcodecov + +## 1.44.1 + +### Various fixes & improvements + +- Make `monitor` async friendly (#2912) by @sentrivana + + You can now decorate your async functions with the `monitor` + decorator and they will correctly report their duration + and completion status. + +- Fixed `Event | None` runtime `TypeError` (#2928) by @szokeasaurusrex + + +## 1.44.0 + +### Various fixes & improvements + +- ref: Define types at runtime (#2914) by @szokeasaurusrex +- Explicit reexport of types (#2866) (#2913) by @szokeasaurusrex +- feat(profiling): Add thread data to spans (#2843) by @Zylphrex + +## 1.43.0 + +### Various fixes & improvements + +- Add optional `keep_alive` (#2842) by @sentrivana + + If you're experiencing frequent network issues between the SDK and Sentry, + you can try turning on TCP keep-alive: + + ```python + import sentry_sdk + + sentry_sdk.init( + # ...your usual settings... + keep_alive=True, + ) + ``` + +- Add support for Celery Redbeat cron tasks (#2643) by @kwigley + + The SDK now supports the Redbeat scheduler in addition to the default + Celery Beat scheduler for auto instrumenting crons. See + [the docs](https://docs.sentry.io/platforms/python/integrations/celery/crons/) + for more information about how to set this up. + +- `aws_event` can be an empty list (#2849) by @sentrivana +- Re-export `Event` in `types.py` (#2829) by @szokeasaurusrex +- Small API docs improvement (#2828) by @antonpirker +- Fixed OpenAI tests (#2834) by @antonpirker +- Bump `checkouts/data-schemas` from `ed078ed` to `8232f17` (#2832) by @dependabot + + +## 1.42.0 + +### Various fixes & improvements + +- **New integration:** [OpenAI integration](https://docs.sentry.io/platforms/python/integrations/openai/) (#2791) by @colin-sentry + + We added an integration for OpenAI to capture errors and also performance data when using the OpenAI Python SDK. + + Useage: + + This integrations is auto-enabling, so if you have the `openai` package in your project it will be enabled. Just initialize Sentry before you create your OpenAI client. + + ```python + from openai import OpenAI + + import sentry_sdk + + sentry_sdk.init( + dsn="___PUBLIC_DSN___", + enable_tracing=True, + traces_sample_rate=1.0, + ) + + client = OpenAI() + ``` + + For more information, see the documentation for [OpenAI integration](https://docs.sentry.io/platforms/python/integrations/openai/). + +- Discard open OpenTelemetry spans after 10 minutes (#2801) by @antonpirker +- Propagate sentry-trace and baggage headers to Huey tasks (#2792) by @cnschn +- Added Event type (#2753) by @szokeasaurusrex +- Improve scrub_dict typing (#2768) by @szokeasaurusrex +- Dependencies: bump types-protobuf from 4.24.0.20240302 to 4.24.0.20240311 (#2797) by @dependabot + +## 1.41.0 + +### Various fixes & improvements + +- Add recursive scrubbing to `EventScrubber` (#2755) by @Cheapshot003 + + By default, the `EventScrubber` will not search your events for potential + PII recursively. With this release, you can enable this behavior with: + + ```python + import sentry_sdk + from sentry_sdk.scrubber import EventScrubber + + sentry_sdk.init( + # ...your usual settings... + event_scrubber=EventScrubber(recursive=True), + ) + ``` + +- Expose `socket_options` (#2786) by @sentrivana + + If the SDK is experiencing connection issues (connection resets, server + closing connection without response, etc.) while sending events to Sentry, + tweaking the default `urllib3` socket options to the following can help: + + ```python + import socket + from urllib3.connection import HTTPConnection + import sentry_sdk + + sentry_sdk.init( + # ...your usual settings... + socket_options=HTTPConnection.default_socket_options + [ + (socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1), + # note: skip the following line if you're on MacOS since TCP_KEEPIDLE doesn't exist there + (socket.SOL_TCP, socket.TCP_KEEPIDLE, 45), + (socket.SOL_TCP, socket.TCP_KEEPINTVL, 10), + (socket.SOL_TCP, socket.TCP_KEEPCNT, 6), + ], + ) + ``` + +- Allow to configure merge target for releases (#2777) by @sentrivana +- Allow empty character in metric tags values (#2775) by @viglia +- Replace invalid tag values with an empty string instead of _ (#2773) by @markushi +- Add documentation comment to `scrub_list` (#2769) by @szokeasaurusrex +- Fixed regex to parse version in lambda package file (#2767) by @antonpirker +- xfail broken AWS Lambda tests for now (#2794) by @sentrivana +- Removed print statements because it messes with the tests (#2789) by @antonpirker +- Bump `types-protobuf` from 4.24.0.20240129 to 4.24.0.20240302 (#2782) by @dependabot +- Bump `checkouts/data-schemas` from `eb941c2` to `ed078ed` (#2781) by @dependabot + +## 1.40.6 + +### Various fixes & improvements + +- Fix compatibility with `greenlet`/`gevent` (#2756) by @sentrivana +- Fix query source relative filepath (#2717) by @gggritso +- Support `clickhouse-driver==0.2.7` (#2752) by @sentrivana +- Bump `checkouts/data-schemas` from `6121fd3` to `eb941c2` (#2747) by @dependabot + +## 1.40.5 + +### Various fixes & improvements + +- Deprecate `last_event_id()`. (#2749) by @antonpirker +- Warn if uWSGI is set up without proper thread support (#2738) by @sentrivana + + uWSGI has to be run in threaded mode for the SDK to run properly. If this is + not the case, the consequences could range from features not working unexpectedly + to uWSGI workers crashing. + + Please make sure to run uWSGI with both `--enable-threads` and `--py-call-uwsgi-fork-hooks`. + +- `parsed_url` can be `None` (#2734) by @sentrivana +- Python 3.7 is not supported anymore by Lambda, so removed it and added 3.12 (#2729) by @antonpirker + +## 1.40.4 + +### Various fixes & improvements + +- Only start metrics flusher thread on demand (#2727) by @sentrivana +- Bump checkouts/data-schemas from `aa7058c` to `6121fd3` (#2724) by @dependabot + +## 1.40.3 + +### Various fixes & improvements + +- Turn off metrics for uWSGI (#2720) by @sentrivana +- Minor improvements (#2714) by @antonpirker + +## 1.40.2 + +### Various fixes & improvements + +- test: Fix `pytest` error (#2712) by @szokeasaurusrex +- build(deps): bump types-protobuf from 4.24.0.4 to 4.24.0.20240129 (#2691) by @dependabot + +## 1.40.1 + +### Various fixes & improvements + +- Fix uWSGI workers hanging (#2694) by @sentrivana +- Make metrics work with `gevent` (#2694) by @sentrivana +- Guard against `engine.url` being `None` (#2708) by @sentrivana +- Fix performance regression in `sentry_sdk.utils._generate_installed_modules` (#2703) by @GlenWalker +- Guard against Sentry initialization mid SQLAlchemy cursor (#2702) by @apmorton +- Fix yaml generation script (#2695) by @sentrivana +- Fix AWS Lambda workflow (#2710) by @sentrivana +- Bump `codecov/codecov-action` from 3 to 4 (#2706) by @dependabot +- Bump `actions/cache` from 3 to 4 (#2661) by @dependabot +- Bump `actions/checkout` from 3.1.0 to 4.1.1 (#2561) by @dependabot +- Bump `github/codeql-action` from 2 to 3 (#2603) by @dependabot +- Bump `actions/setup-python` from 4 to 5 (#2577) by @dependabot + +## 1.40.0 + +### Various fixes & improvements + +- Enable metrics related settings by default (#2685) by @iambriccardo +- Fix `UnicodeDecodeError` on Python 2 (#2657) by @sentrivana +- Enable DB query source by default (#2629) by @sentrivana +- Fix query source duration check (#2675) by @sentrivana +- Reformat with `black==24.1.0` (#2680) by @sentrivana +- Cleaning up existing code to prepare for new Scopes API (#2611) by @antonpirker +- Moved redis related tests to databases (#2674) by @antonpirker +- Improve `sentry_sdk.trace` type hints (#2633) by @szokeasaurusrex +- Bump `checkouts/data-schemas` from `e9f7d58` to `aa7058c` (#2639) by @dependabot + +## 1.39.2 + +### Various fixes & improvements + +- Fix timestamp in transaction created by OTel (#2627) by @antonpirker +- Fix relative path in DB query source (#2624) by @antonpirker +- Run more CI checks on 2.0 branch (#2625) by @sentrivana +- Fix tracing `TypeError` for static and class methods (#2559) by @szokeasaurusrex +- Fix missing `ctx` in Arq integration (#2600) by @ivanovart +- Change `data_category` from `check_in` to `monitor` (#2598) by @sentrivana + +## 1.39.1 + +### Various fixes & improvements + +- Fix psycopg2 detection in the Django integration (#2593) by @sentrivana +- Filter out empty string releases (#2591) by @sentrivana +- Fixed local var not present when there is an error in a user's `error_sampler` function (#2511) by @antonpirker +- Fixed typing in `aiohttp` (#2590) by @antonpirker + +## 1.39.0 + +### Various fixes & improvements + +- Add support for cluster clients from Redis SDK (#2394) by @md384 +- Improve location reporting for timer metrics (#2552) by @mitsuhiko +- Fix Celery `TypeError` with no-argument `apply_async` (#2575) by @szokeasaurusrex +- Fix Lambda integration with EventBridge source (#2546) by @davidcroda +- Add max tries to Spotlight (#2571) by @hazAT +- Handle `os.path.devnull` access issues (#2579) by @sentrivana +- Change `code.filepath` frame picking logic (#2568) by @sentrivana +- Trigger AWS Lambda tests on label (#2538) by @sentrivana +- Run permissions step on pull_request_target but not push (#2548) by @sentrivana +- Hash AWS Lambda test functions based on current revision (#2557) by @sentrivana +- Update Django version in tests (#2562) by @sentrivana +- Make metrics tests non-flaky (#2572) by @antonpirker + +## 1.38.0 + +### Various fixes & improvements + +- Only add trace context to checkins and do not run `event_processors` for checkins (#2536) by @antonpirker +- Metric span summaries (#2522) by @mitsuhiko +- Add source context to code locations (#2539) by @jan-auer +- Use in-app filepath instead of absolute path (#2541) by @antonpirker +- Switch to `jinja2` for generating CI yamls (#2534) by @sentrivana + +## 1.37.1 + +### Various fixes & improvements + +- Fix `NameError` on `parse_version` with eventlet (#2532) by @sentrivana +- build(deps): bump checkouts/data-schemas from `68def1e` to `e9f7d58` (#2501) by @dependabot + +## 1.37.0 + +### Various fixes & improvements + +- Move installed modules code to utils (#2429) by @sentrivana + + Note: We moved the internal function `_get_installed_modules` from `sentry_sdk.integrations.modules` to `sentry_sdk.utils`. + So if you use this function you have to update your imports + +- Add code locations for metrics (#2526) by @jan-auer +- Add query source to DB spans (#2521) by @antonpirker +- Send events to Spotlight sidecar (#2524) by @HazAT +- Run integration tests with newest `pytest` (#2518) by @sentrivana +- Bring tests up to date (#2512) by @sentrivana +- Fix: Prevent global var from being discarded at shutdown (#2530) by @antonpirker +- Fix: Scope transaction source not being updated in scope.span setter (#2519) by @sl0thentr0py + +## 1.36.0 + +### Various fixes & improvements + +- Django: Support Django 5.0 (#2490) by @sentrivana +- Django: Handling ASGI body in the right way. (#2513) by @antonpirker +- Flask: Test with Flask 3.0 (#2506) by @sentrivana +- Celery: Do not create a span when task is triggered by Celery Beat (#2510) by @antonpirker +- Redis: Ensure `RedisIntegration` is disabled, unless `redis` is installed (#2504) by @szokeasaurusrex +- Quart: Fix Quart integration for Quart 0.19.4 (#2516) by @antonpirker +- gRPC: Make async gRPC less noisy (#2507) by @jyggen + +## 1.35.0 + +### Various fixes & improvements + +- **Updated gRPC integration:** Asyncio interceptors and easier setup (#2369) by @fdellekart + + Our gRPC integration now instruments incoming unary-unary grpc requests and outgoing unary-unary, unary-stream grpc requests using grpcio channels. Everything works now for sync and async code. + + Before this release you had to add Sentry interceptors by hand to your gRPC code, now the only thing you need to do is adding the `GRPCIntegration` to you `sentry_sdk_init()` call. (See [documentation](https://docs.sentry.io/platforms/python/integrations/grpc/) for more information): + + ```python + import sentry_sdk + from sentry_sdk.integrations.grpc import GRPCIntegration + + sentry_sdk.init( + dsn="___PUBLIC_DSN___", + enable_tracing=True, + integrations=[ + GRPCIntegration(), + ], + ) + ``` + The old way still works, but we strongly encourage you to update your code to the way described above. + +- Python 3.12: Replace deprecated datetime functions (#2502) by @sentrivana +- Metrics: Unify datetime format (#2409) by @mitsuhiko +- Celery: Set correct data in `check_in`s (#2500) by @antonpirker +- Celery: Read timezone for Crons monitors from `celery_schedule` if existing (#2497) by @antonpirker +- Django: Removing redundant code in Django tests (#2491) by @vagi8 +- Django: Make reading the request body work in Django ASGI apps. (#2495) by @antonpirker +- FastAPI: Use wraps on fastapi request call wrapper (#2476) by @nkaras +- Fix: Probe for psycopg2 and psycopg3 parameters function. (#2492) by @antonpirker +- Fix: Remove unnecessary TYPE_CHECKING alias (#2467) by @rafrafek + +## 1.34.0 + +### Various fixes & improvements +- Added Python 3.12 support (#2471, #2483) +- Handle missing `connection_kwargs` in `patch_redis_client` (#2482) by @szokeasaurusrex +- Run common test suite on Python 3.12 (#2479) by @sentrivana + +## 1.33.1 + +### Various fixes & improvements + +- Make parse_version work in utils.py itself. (#2474) by @antonpirker + +## 1.33.0 + +### Various fixes & improvements + +- New: Added `error_sampler` option (#2456) by @szokeasaurusrex +- Python 3.12: Detect interpreter in shutdown state on thread spawn (#2468) by @mitsuhiko +- Patch eventlet under Sentry SDK (#2464) by @szokeasaurusrex +- Mitigate CPU spikes when sending lots of events with lots of data (#2449) by @antonpirker +- Make `debug` option also configurable via environment (#2450) by @antonpirker +- Make sure `get_dsn_parameters` is an actual function (#2441) by @sentrivana +- Bump pytest-localserver, add compat comment (#2448) by @sentrivana +- AWS Lambda: Update compatible runtimes for AWS Lambda layer (#2453) by @antonpirker +- AWS Lambda: Load AWS Lambda secrets in Github CI (#2153) by @antonpirker +- Redis: Connection attributes in `redis` database spans (#2398) by @antonpirker +- Falcon: Falcon integration checks response status before reporting error (#2465) by @szokeasaurusrex +- Quart: Support Quart 0.19 onwards (#2403) by @pgjones +- Sanic: Sanic integration initial version (#2419) by @szokeasaurusrex +- Django: Fix parsing of Django `path` patterns (#2452) by @sentrivana +- Django: Add Django 4.2 to test suite (#2462) by @sentrivana +- Polish changelog (#2434) by @sentrivana +- Update CONTRIBUTING.md (#2443) by @krishvsoni +- Update README.md (#2435) by @sentrivana + ## 1.32.0 ### Various fixes & improvements @@ -837,7 +3216,7 @@ By: @mgaligniana (#1773) import sentry_sdk from sentry_sdk.integrations.arq import ArqIntegration - from sentry_sdk.tracing import TRANSACTION_SOURCE_COMPONENT + from sentry_sdk.tracing import TransactionSource sentry_sdk.init( dsn="...", @@ -857,7 +3236,7 @@ By: @mgaligniana (#1773) await ctx['session'].aclose() async def main(): - with sentry_sdk.start_transaction(name="testing_arq_tasks", source=TRANSACTION_SOURCE_COMPONENT): + with sentry_sdk.start_transaction(name="testing_arq_tasks", source=TransactionSource.COMPONENT): redis = await create_pool(RedisSettings()) for url in ('https://facebook.com', 'https://microsoft.com', 'https://github.com', "asdf" ): @@ -931,7 +3310,7 @@ By: @mgaligniana (#1773) import sentry_sdk from sentry_sdk.integrations.huey import HueyIntegration - from sentry_sdk.tracing import TRANSACTION_SOURCE_COMPONENT, Transaction + from sentry_sdk.tracing import TransactionSource, Transaction def main(): @@ -943,7 +3322,7 @@ By: @mgaligniana (#1773) traces_sample_rate=1.0, ) - with sentry_sdk.start_transaction(name="testing_huey_tasks", source=TRANSACTION_SOURCE_COMPONENT): + with sentry_sdk.start_transaction(name="testing_huey_tasks", source=TransactionSource.COMPONENT): r = add_numbers(1, 2) if __name__ == "__main__": 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 eca35206bc..753b169214 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,7 +8,6 @@ This file outlines the process to contribute to the SDK itself. For contributing Please search the [issue tracker](https://github.com/getsentry/sentry-python/issues) before creating a new issue (a problem or an improvement request). Please also ask in our [Sentry Community on Discord](https://discord.com/invite/Ww9hbqr) before submitting a new issue. There are a ton of great people in our Discord community ready to help you! - ## Submitting Changes - Fork the `sentry-python` repo and prepare your changes. @@ -33,7 +32,7 @@ Before you can contribute, you will need to [fork the `sentry-python` repository ### Create a Virtual Environment To keep your Python development environment and packages separate from the ones -used by your operation system, create a virtual environment: +used by your operation system, create a [virtual environment](https://docs.python.org/3/tutorial/venv.html): ```bash cd sentry-python @@ -64,7 +63,7 @@ This will make sure that your commits will have the correct coding style. ```bash cd sentry-python -pip install -r linter-requirements.txt +pip install -r requirements-devenv.txt pip install pre-commit @@ -75,50 +74,55 @@ That's it. You should be ready to make changes, run tests, and make commits! If ## Running Tests -To run the tests, first setup your development environment according to the instructions above. Then, install the required packages for running tests with the following command: +We test against a number of Python language and library versions, which are automatically generated and stored in the [tox.ini](tox.ini) file. The `envlist` defines the environments you can choose from when running tests, and correspond to package versions and environment variables. The `TESTPATH` environment variable, in turn, determines which tests are run. + +The tox CLI tool is required to run the tests locally. Follow [the installation instructions](https://tox.wiki/en/latest/installation.html) for tox. Dependencies are installed for you when you run the command below, but _you_ need to bring an appropriate Python interpreter. + +[Pyenv](https://github.com/pyenv/pyenv) is a cross-platform utility for managing Python versions. You can also use a conventional package manager, but not all versions may be distributed in the package manager of your choice. For macOS, versions 3.8 and up can be installed with Homebrew. + +An environment consists of the Python major and minor version and the library name and version. The exception to the rule is that you can provide `common` instead of the library information. The environments tied to a specific library usually run the corresponding test suite, while `common` targets all tests but skips those that require uninstalled dependencies. + +To run Celery tests for version v5.5.3 of its Python library using a 3.12 interpreter, use + ```bash -pip install -r test-requirements.txt +tox -p auto -o -e py3.12-celery-v5.5.3 ``` -Once the requirements are installed, you can run all tests with the following command: +or to use the `common` environment, run + ```bash -pytest tests/ +tox -p auto -o -e py3.12-common ``` -If you would like to run the tests for a specific integration, use a command similar to the one below: +To select specific tests, you can forward arguments to `pytest` like so ```bash -pytest -rs tests/integrations/flask/ # Replace "flask" with the specific integration you wish to test +tox -p auto -o -e py3.12-celery-v5.5.3 -- -k test_transaction_events ``` -**Hint:** Tests of integrations need additional dependencies. The switch `-rs` will show you why tests were skipped and what dependencies you need to install for the tests to run. (You can also consult the [tox.ini](tox.ini) file to see what dependencies are installed for each integration) +In general, you use + +```bash +tox -p auto -o -e -- +``` ## Adding a New Integration 1. Write the integration. - - - Instrument all application instances by default. Prefer global signals/patches instead of configuring a specific instance. Don't make the user pass anything to your integration for anything to work. Aim for zero configuration. - - - Everybody monkeypatches. That means: - - - Make sure to think about conflicts with other monkeypatches when monkeypatching. - - - You don't need to feel bad about it. - + - Instrument all application instances by default. Prefer global signals/patches. + - Don't make the user pass anything to your integration for anything to work. Aim for zero configuration. + - Everybody monkeypatches. That means you don't need to feel bad about it. - Make sure your changes don't break end user contracts. The SDK should never alter the expected behavior of the underlying library or framework from the user's perspective and it shouldn't have any side effects. - - - Avoid modifying the hub, registering a new client or the like. The user drives the client, and the client owns integrations. - - - Allow the user to turn off the integration by changing the client. Check `Hub.current.get_integration(MyIntegration)` from within your signal handlers to see if your integration is still active before you do anything impactful (such as sending an event). + - Be defensive. Don't assume the code you're patching will stay the same forever, especially if it's an internal function. Allow for future variability whenever it makes sense. + - Avoid registering a new client or the like. The user drives the client, and the client owns integrations. + - Allow the user to turn off the integration by changing the client. Check `sentry_sdk.get_client().get_integration(MyIntegration)` from within your signal handlers to see if your integration is still active before you do anything impactful (such as sending an event). 2. Write tests. - - - Consider the minimum versions supported, and test each version in a separate env in `tox.ini`. - + - Consider the minimum versions supported, and document in `_MIN_VERSIONS` in `integrations/__init__.py`. - Create a new folder in `tests/integrations/`, with an `__init__` file that skips the entire suite if the package is not installed. + - Add the test suite to the script generating our test matrix. See [`scripts/populate_tox/README.md`](https://github.com/getsentry/sentry-python/blob/master/scripts/populate_tox/README.md#add-a-new-test-suite). 3. Update package metadata. - - We use `extras_require` in `setup.py` to communicate minimum version requirements for integrations. People can use this in combination with tools like Poetry or Pipenv to detect conflicts between our supported versions and their used versions programmatically. Do not set upper bounds on version requirements as people are often faster in adopting new versions of a web framework than we are in adding them to the test matrix or our package metadata. @@ -127,11 +131,9 @@ pytest -rs tests/integrations/flask/ # Replace "flask" with the specific integr 5. Merge docs after new version has been released. The docs are built and deployed after each merge, so your changes should go live in a few minutes. -6. (optional, if possible) Update data in [`sdk_updates.py`](https://github.com/getsentry/sentry/blob/master/src/sentry/sdk_updates.py) to give users in-app suggestions to use your integration. This step will only apply to some integrations. - ## Releasing a New Version -_(only relevant for Sentry employees)_ +_(only relevant for Python SDK core team)_ ### Prerequisites @@ -143,18 +145,18 @@ _(only relevant for Sentry employees)_ - On GitHub in the `sentry-python` repository, go to "Actions" and select the "Release" workflow. - Click on "Run workflow" on the right side, and make sure the `master` branch is selected. -- Set the "Version to release" input field. Here you decide if it is a major, minor or patch release. (See "Versioning Policy" below) +- Set the "Version to release" input field. Here you decide if it is a major, minor or patch release (see "Versioning Policy" below). - Click "Run Workflow". -This will trigger [Craft](https://github.com/getsentry/craft) to prepare everything needed for a release. (For more information, see [craft prepare](https://github.com/getsentry/craft#craft-prepare-preparing-a-new-release).) At the end of this process a release issue is created in the [Publish](https://github.com/getsentry/publish) repository. (Example release issue: https://github.com/getsentry/publish/issues/815) +This will trigger [Craft](https://github.com/getsentry/craft) to prepare everything needed for a release. (For more information, see [craft prepare](https://github.com/getsentry/craft#craft-prepare-preparing-a-new-release).) At the end of this process a release issue is created in the [Publish](https://github.com/getsentry/publish) repository (example issue: https://github.com/getsentry/publish/issues/815). -Now one of the persons with release privileges (most probably your engineering manager) will review this issue and then add the `accepted` label to the issue. +At the same time, the action will create a release branch in the `sentry-python` repository called `release/`. You may want to check out this branch and polish the auto-generated `CHANGELOG.md` before proceeding by including code snippets, descriptions, reordering and reformatting entries, in order to make the changelog as useful and actionable to users as possible. -There are always two persons involved in a release. +CI must be passing on the release branch; if there's any failure, Craft will not create a release. -If you are in a hurry and the release should be out immediately, there is a Slack channel called `#proj-release-approval` where you can see your release issue and where you can ping people to please have a look immediately. +Once the release branch is ready and green, notify your team (or your manager). They will need to add the `accepted` label to the issue in the `publish` repo. There are always two people involved in a release. Do not accept your own releases. -When the release issue is labeled `accepted`, [Craft](https://github.com/getsentry/craft) is triggered again to publish the release to all the right platforms. (See [craft publish](https://github.com/getsentry/craft#craft-publish-publishing-the-release) for more information.) At the end of this process the release issue on GitHub will be closed and the release is completed! Congratulations! +When the release issue is labeled `accepted`, [Craft](https://github.com/getsentry/craft) is triggered again to publish the release to all the right platforms. See [craft publish](https://github.com/getsentry/craft#craft-publish-publishing-the-release) for more information. At the end of this process, the release issue on GitHub will be closed and the release is completed! Congratulations! There is a sequence diagram visualizing all this in the [README.md](https://github.com/getsentry/publish) of the `Publish` repository. @@ -168,12 +170,33 @@ This project follows [semver](https://semver.org/), with three additions: - Certain features (e.g. integrations) may be explicitly called out as "experimental" or "unstable" in the documentation. They come with their own versioning policy described in the documentation. -We recommend to pin your version requirements against `1.x.*` or `1.x.y`. +We recommend to pin your version requirements against `2.x.*` or `2.x.y`. Either one of the following is fine: ``` -sentry-sdk>=1.0.0,<2.0.0 -sentry-sdk==1.5.0 +sentry-sdk>=2.0.0,<3.0.0 +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/aws-deploy-local-layer.sh` + + This script [scripts/aws/aws-deploy-local-layer.sh](scripts/aws/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/aws-attach-layer-to-lambda-function.sh` + + You can use this script [scripts/aws/aws-attach-layer-to-lambda-function.sh](scripts/aws/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. diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md new file mode 100644 index 0000000000..53396a37ba --- /dev/null +++ b/MIGRATION_GUIDE.md @@ -0,0 +1,192 @@ +# Sentry SDK 2.0 Migration Guide + +Looking to upgrade from Sentry SDK 1.x to 2.x? Here's a comprehensive list of what's changed. Looking for a more digestable summary? See the [guide in the docs](https://docs.sentry.io/platforms/python/migration/1.x-to-2.x) with the most common migration patterns. + +## New Features + +- Additional integrations will now be activated automatically if the SDK detects the respective package is installed: Ariadne, ARQ, asyncpg, Chalice, clickhouse-driver, GQL, Graphene, huey, Loguru, PyMongo, Quart, Starlite, Strawberry. +- While refactoring the [inner workings](https://docs.sentry.io/platforms/python/enriching-events/scopes/) of the SDK we added new top-level APIs for custom instrumentation called `new_scope` and `isolation_scope`. See the [Deprecated](#deprecated) section to see how they map to the existing APIs. + +## Changed + +- The Pyramid integration will not capture errors that might happen in `authenticated_userid()` in a custom `AuthenticationPolicy` class. +- The method `need_code_loation` of the `MetricsAggregator` was renamed to `need_code_location`. +- The `BackgroundWorker` thread used to process events was renamed from `raven-sentry.BackgroundWorker` to `sentry-sdk.BackgroundWorker`. +- The `reraise` function was moved from `sentry_sdk._compat` to `sentry_sdk.utils`. +- The `_ScopeManager` was moved from `sentry_sdk.hub` to `sentry_sdk.scope`. +- The signature for the metrics callback function set with `before_emit_metric` has changed from `before_emit_metric(key, tags)` to `before_emit_metric(key, value, unit, tags)` +- Moved the contents of `tracing_utils_py3.py` to `tracing_utils.py`. The `start_child_span_decorator` is now in `sentry_sdk.tracing_utils`. +- The actual implementation of `get_current_span` was moved to `sentry_sdk.tracing_utils`. `sentry_sdk.get_current_span` is still accessible as part of the top-level API. +- `sentry_sdk.tracing_utils.add_query_source()`: Removed the `hub` parameter. It is not necessary anymore. +- `sentry_sdk.tracing_utils.record_sql_queries()`: Removed the `hub` parameter. It is not necessary anymore. +- `sentry_sdk.tracing_utils.get_current_span()` does now take a `scope` instead of a `hub` as parameter. +- `sentry_sdk.tracing_utils.should_propagate_trace()` now takes a `Client` instead of a `Hub` as first parameter. +- `sentry_sdk.utils.is_sentry_url()` now takes a `Client` instead of a `Hub` as first parameter. +- `sentry_sdk.utils._get_contextvars` does not return a tuple with three values, but a tuple with two values. The `copy_context` was removed. +- You no longer have to use `configure_scope` to mutate a transaction. Instead, you simply get the current scope to mutate the transaction. Here is a recipe on how to change your code to make it work: + Your existing implementation: + + ```python + transaction = sentry_sdk.transaction(...) + + # later in the code execution: + + with sentry_sdk.configure_scope() as scope: + scope.set_transaction_name("new-transaction-name") + ``` + + needs to be changed to this: + + ```python + transaction = sentry_sdk.transaction(...) + + # later in the code execution: + + scope = sentry_sdk.get_current_scope() + scope.set_transaction_name("new-transaction-name") + ``` + +- The classes listed in the table below are now abstract base classes. Therefore, they can no longer be instantiated. Subclasses can only be instantiated if they implement all of the abstract methods. +
+ Show table + + | Class | Abstract methods | + | ------------------------------------- | -------------------------------------- | + | `sentry_sdk.integrations.Integration` | `setup_once` | + | `sentry_sdk.metrics.Metric` | `add`, `serialize_value`, and `weight` | + | `sentry_sdk.profiler.Scheduler` | `setup` and `teardown` | + | `sentry_sdk.transport.Transport` | `capture_envelope` | + +
+ +## Removed + +- Removed support for Python 2 and Python 3.5. The SDK now requires at least Python 3.6. +- Removed support for Celery 3.\*. +- Removed support for Django 1.8, 1.9, 1.10. +- Removed support for Flask 0.\*. +- Removed support for gRPC < 1.39. +- Removed support for Tornado < 6. +- Removed support for sending events to the `/store` endpoint. Everything is now sent to the `/envelope` endpoint. If you're on SaaS you don't have to worry about this, but if you're running Sentry yourself you'll need version `20.6.0` or higher of self-hosted Sentry. +- The deprecated `with_locals` configuration option was removed. Use `include_local_variables` instead. See https://docs.sentry.io/platforms/python/configuration/options/#include-local-variables. +- The deprecated `request_bodies` configuration option was removed. Use `max_request_body_size`. See https://docs.sentry.io/platforms/python/configuration/options/#max-request-body-size. +- Removed support for `user.segment`. It was also removed from the trace header as well as from the dynamic sampling context. +- Removed support for the `install` method for custom integrations. Please use `setup_once` instead. +- Removed `sentry_sdk.tracing.Span.new_span`. Use `sentry_sdk.tracing.Span.start_child` instead. +- Removed `sentry_sdk.tracing.Transaction.new_span`. Use `sentry_sdk.tracing.Transaction.start_child` instead. +- Removed support for creating transactions via `sentry_sdk.tracing.Span(transaction=...)`. To create a transaction, please use `sentry_sdk.tracing.Transaction(name=...)`. +- Removed `sentry_sdk.utils.Auth.store_api_url`. +- `sentry_sdk.utils.Auth.get_api_url`'s now accepts a `sentry_sdk.consts.EndpointType` enum instead of a string as its only parameter. We recommend omitting this argument when calling the function, since the parameter's default value is the only possible `sentry_sdk.consts.EndpointType` value. The parameter exists for future compatibility. +- Removed `tracing_utils_py2.py`. The `start_child_span_decorator` is now in `sentry_sdk.tracing_utils`. +- Removed the `sentry_sdk.profiler.Scheduler.stop_profiling` method. Any calls to this method can simply be removed, since this was a no-op method. +- Removed the experimental `metrics_summary_sample_rate` config option. +- Removed the experimental `should_summarize_metric` config option. + +## Deprecated + +- Using the `Hub` directly as well as using hub-based APIs has been deprecated. Where available, use [the top-level API instead](sentry_sdk/api.py); otherwise use the [scope API](sentry_sdk/scope.py) or the [client API](sentry_sdk/client.py). + + Before: + + ```python + with hub.start_span(...): + # do something + ``` + + After: + + ```python + import sentry_sdk + + with sentry_sdk.start_span(...): + # do something + ``` + +- Hub cloning is deprecated. + + Before: + + ```python + with Hub(Hub.current) as hub: + # do something with the cloned hub + ``` + + After: + + ```python + import sentry_sdk + + with sentry_sdk.isolation_scope() as scope: + # do something with the forked scope + ``` + +- `configure_scope` is deprecated. Modify the current or isolation scope directly instead. + + Before: + + ```python + with configure_scope() as scope: + # do something with `scope` + ``` + + After: + + ```python + from sentry_sdk import get_current_scope + + scope = get_current_scope() + # do something with `scope` + ``` + + Or: + + ```python + from sentry_sdk import get_isolation_scope + + scope = get_isolation_scope() + # do something with `scope` + ``` + + When to use `get_current_scope()` and `get_isolation_scope()` depends on how long the change to the scope should be in effect. If you want the changed scope to affect the whole request-response cycle or the whole execution of task, use the isolation scope. If it's more localized, use the current scope. + +- `push_scope` is deprecated. Fork the current or the isolation scope instead. + + Before: + + ```python + with push_scope() as scope: + # do something with `scope` + ``` + + After: + + ```python + import sentry_sdk + + with sentry_sdk.new_scope() as scope: + # do something with `scope` + ``` + + Or: + + ```python + import sentry_sdk + + with sentry_sdk.isolation_scope() as scope: + # do something with `scope` + ``` + + `new_scope()` will fork the current scope, while `isolation_scope()` will fork the isolation scope. The lifecycle of a single isolation scope roughly translates to the lifecycle of a transaction in most cases, so if you're looking to create a new separated scope for a whole request-response cycle or task execution, go for `isolation_scope()`. If you want to wrap a smaller unit code, fork the current scope instead with `new_scope()`. + +- Accessing the client via the hub has been deprecated. Use the top-level `sentry_sdk.get_client()` to get the current client. +- `profiler_mode` and `profiles_sample_rate` have been deprecated as `_experiments` options. Use them as top level options instead: + ```python + sentry_sdk.init( + ..., + profiler_mode="thread", + profiles_sample_rate=1.0, + ) + ``` +- Deprecated `sentry_sdk.transport.Transport.capture_event`. Please use `sentry_sdk.transport.Transport.capture_envelope`, instead. +- Passing a function to `sentry_sdk.init`'s `transport` keyword argument has been deprecated. If you wish to provide a custom transport, please pass a `sentry_sdk.transport.Transport` instance or a subclass. +- The parameter `propagate_hub` in `ThreadingIntegration()` was deprecated and renamed to `propagate_scope`. diff --git a/Makefile b/Makefile index 2011b1b63e..fb5900e5ea 100644 --- a/Makefile +++ b/Makefile @@ -5,62 +5,31 @@ 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: - virtualenv -ppython3 $(VENV_PATH) + python -m venv $(VENV_PATH) $(VENV_PATH)/bin/pip install tox dist: .venv rm -rf dist dist-serverless build - $(VENV_PATH)/bin/pip install wheel + $(VENV_PATH)/bin/pip install wheel setuptools $(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.9 -.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 ./docs-requirements.txt + @$(VENV_PATH)/bin/pip install -U -r ./requirements-docs.txt + rm -rf docs/_build @$(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 urllib3 - $(VENV_PATH)/bin/pip install certifi + $(VENV_PATH)/bin/pip install -r requirements-aws-lambda-layer.txt $(VENV_PATH)/bin/python -m scripts.build_aws_lambda_layer .PHONY: aws-lambda-layer diff --git a/README.md b/README.md index e9d661eee8..6c1db3b25f 100644 --- a/README.md +++ b/README.md @@ -1,103 +1,125 @@ -

- - Sentry - -

+ + Sentry for Python + +
+ +_Bad software is everywhere, and we're tired of it. Sentry is on a mission to help developers write better software faster, so we can get back to enjoying technology. If you want to join us +[**Check out our open positions**](https://sentry.io/careers/)_. + +[![Discord](https://img.shields.io/discord/621778831602221064?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.com/invite/Ww9hbqr) +[![X Follow](https://img.shields.io/twitter/follow/sentry?label=sentry&style=social)](https://x.com/intent/follow?screen_name=sentry) +[![PyPi page link -- version](https://img.shields.io/pypi/v/sentry-sdk.svg)](https://pypi.python.org/pypi/sentry-sdk) +python +[![Build Status](https://github.com/getsentry/sentry-python/actions/workflows/ci.yml/badge.svg)](https://github.com/getsentry/sentry-python/actions/workflows/ci.yml) + +
+ +
-_Bad software is everywhere, and we're tired of it. Sentry is on a mission to help developers write better software faster, so we can get back to enjoying technology. If you want to join us [**Check out our open positions**](https://sentry.io/careers/)_ # Official Sentry SDK for Python -[![Build Status](https://github.com/getsentry/sentry-python/actions/workflows/ci.yml/badge.svg)](https://github.com/getsentry/sentry-python/actions/workflows/ci.yml) -[![PyPi page link -- version](https://img.shields.io/pypi/v/sentry-sdk.svg)](https://pypi.python.org/pypi/sentry-sdk) -[![Discord](https://img.shields.io/discord/621778831602221064)](https://discord.gg/cWnMQeA) +Welcome to the official Python SDK for **[Sentry](http://sentry.io/)**. + -This is the official Python SDK for [Sentry](http://sentry.io/) +## 📦 Getting Started ---- +### Prerequisites -## Getting Started +You need a Sentry [account](https://sentry.io/signup/) and [project](https://docs.sentry.io/product/projects/). -### Install +### Installation + +Getting Sentry into your project is straightforward. Just run this command in your terminal: ```bash pip install --upgrade sentry-sdk ``` -### Configuration +### Basic Configuration + +Here's a quick configuration example to get Sentry up and running: ```python import sentry_sdk sentry_sdk.init( - "https://12927b5f211046b575ee51fd8b1ac34f@o1.ingest.sentry.io/1", + "https://12927b5f211046b575ee51fd8b1ac34f@o1.ingest.sentry.io/1", # Your DSN here # Set traces_sample_rate to 1.0 to capture 100% - # of transactions for performance monitoring. + # of traces for performance monitoring. traces_sample_rate=1.0, ) ``` -### Usage +With this configuration, Sentry will monitor for exceptions and performance issues. + +### Quick Usage Example + +To generate some events that will show up in Sentry, you can log messages or capture errors: ```python -from sentry_sdk import capture_message -capture_message("Hello World") # Will create an event in Sentry. +import sentry_sdk +sentry_sdk.init(...) # same as above -raise ValueError() # Will also create an event in Sentry. +sentry_sdk.capture_message("Hello Sentry!") # You'll see this in your Sentry dashboard. + +raise ValueError("Oops, something went wrong!") # This will create an error event in Sentry. ``` -- To learn more about how to use the SDK [refer to our docs](https://docs.sentry.io/platforms/python/). -- Are you coming from `raven-python`? [Use this migration guide](https://docs.sentry.io/platforms/python/migration/). -- To learn about internals use the [API Reference](https://getsentry.github.io/sentry-python/). -## Integrations +## 📚 Documentation + +For more details on advanced usage, integrations, and customization, check out the full documentation on [https://docs.sentry.io](https://docs.sentry.io/). + + +## 🧩 Integrations + +Sentry integrates with a ton of popular Python libraries and frameworks, including [FastAPI](https://docs.sentry.io/platforms/python/integrations/fastapi/), [Django](https://docs.sentry.io/platforms/python/integrations/django/), [Celery](https://docs.sentry.io/platforms/python/integrations/celery/), [OpenAI](https://docs.sentry.io/platforms/python/integrations/openai/) and many, many more. Check out the [full list of integrations](https://docs.sentry.io/platforms/python/integrations/) to get the full picture. + + +## 🚧 Migrating Between Versions? + +### From `1.x` to `2.x` + +If you're using the older `1.x` version of the SDK, now's the time to upgrade to `2.x`. It includes significant upgrades and new features. Check our [migration guide](https://docs.sentry.io/platforms/python/migration/1.x-to-2.x) for assistance. + +### From `raven-python` + +Using the legacy `raven-python` client? It's now in maintenance mode, and we recommend migrating to the new SDK for an improved experience. Get all the details in our [migration guide](https://docs.sentry.io/platforms/python/migration/raven-to-sentry-sdk/). + + +## 🙌 Want to Contribute? -(If you want to create a new integration, have a look at the [Adding a new integration checklist](https://github.com/getsentry/sentry-python/blob/master/CONTRIBUTING.md#adding-a-new-integration).) +We'd love your help in improving the Sentry SDK! Whether it's fixing bugs, adding features, writing new integrations, or enhancing documentation, every contribution is valuable. -See [the documentation](https://docs.sentry.io/platforms/python/integrations/) for an up-to-date list of libraries and frameworks we support. Here are some examples: +For details on how to contribute, please read our [contribution guide](CONTRIBUTING.md) and explore the [open issues](https://github.com/getsentry/sentry-python/issues). -- [Django](https://docs.sentry.io/platforms/python/integrations/django/) -- [Flask](https://docs.sentry.io/platforms/python/integrations/flask/) -- [FastAPI](https://docs.sentry.io/platforms/python/integrations/fastapi/) -- [AIOHTTP](https://docs.sentry.io/platforms/python/integrations/aiohttp/) -- [SQLAlchemy](https://docs.sentry.io/platforms/python/integrations/sqlalchemy/) -- [asyncpg](https://docs.sentry.io/platforms/python/integrations/asyncpg/) -- [Redis](https://docs.sentry.io/platforms/python/integrations/redis/) -- [Celery](https://docs.sentry.io/platforms/python/integrations/celery/) -- [Apache Airflow](https://docs.sentry.io/platforms/python/integrations/airflow/) -- [Apache Spark](https://docs.sentry.io/platforms/python/integrations/pyspark/) -- [asyncio](https://docs.sentry.io/platforms/python/integrations/asyncio/) -- [Graphene](https://docs.sentry.io/platforms/python/integrations/graphene/) -- [Logging](https://docs.sentry.io/platforms/python/integrations/logging/) -- [Loguru](https://docs.sentry.io/platforms/python/integrations/loguru/) -- [HTTPX](https://docs.sentry.io/platforms/python/integrations/httpx/) -- [AWS Lambda](https://docs.sentry.io/platforms/python/integrations/aws-lambda/) -- [Google Cloud Functions](https://docs.sentry.io/platforms/python/integrations/gcp-functions/) +## 🛟 Need Help? -## Migrating From `raven-python` +If you encounter issues or need help setting up or configuring the SDK, don't hesitate to reach out to the [Sentry Community on Discord](https://discord.com/invite/Ww9hbqr). There is a ton of great people there ready to help! -The old `raven-python` client has entered maintenance mode and was moved [here](https://github.com/getsentry/raven-python). -If you're using `raven-python`, we recommend you to migrate to this new SDK. You can find the benefits of migrating and how to do it in our [migration guide](https://docs.sentry.io/platforms/python/migration/). +## 🔗 Resources -## Contributing to the SDK +Here are all resources to help you make the most of Sentry: -Please refer to [CONTRIBUTING.md](CONTRIBUTING.md). +- [Documentation](https://docs.sentry.io/platforms/python/) - Official documentation to get started. +- [Discord](https://discord.com/invite/Ww9hbqr) - Join our Discord community. +- [X/Twitter](https://x.com/intent/follow?screen_name=sentry) - Follow us on X (Twitter) for updates. +- [Stack Overflow](https://stackoverflow.com/questions/tagged/sentry) - Questions and answers related to Sentry. -## Getting Help/Support + +## 📃 License -If you need help setting up or configuring the Python SDK (or anything else in the Sentry universe) please head over to the [Sentry Community on Discord](https://discord.com/invite/Ww9hbqr). There is a ton of great people in our Discord community ready to help you! +The SDK is open-source and available under the MIT license. Check out the [LICENSE](LICENSE) file for more information. -## Resources -- [![Documentation](https://img.shields.io/badge/documentation-sentry.io-green.svg)](https://docs.sentry.io/quickstart/) -- [![Forum](https://img.shields.io/badge/forum-sentry-green.svg)](https://forum.sentry.io/c/sdks) -- [![Discord](https://img.shields.io/discord/621778831602221064)](https://discord.gg/Ww9hbqr) -- [![Stack Overflow](https://img.shields.io/badge/stack%20overflow-sentry-green.svg)](http://stackoverflow.com/questions/tagged/sentry) -- [![Twitter Follow](https://img.shields.io/twitter/follow/getsentry?label=getsentry&style=social)](https://twitter.com/intent/follow?screen_name=getsentry) +## 😘 Contributors -## License +Thanks to everyone who has helped improve the SDK! -Licensed under the MIT license, see [`LICENSE`](LICENSE) + + + diff --git a/checkouts/data-schemas b/checkouts/data-schemas index 68def1ee9d..6d2c435b8c 160000 --- a/checkouts/data-schemas +++ b/checkouts/data-schemas @@ -1 +1 @@ -Subproject commit 68def1ee9d2437fb6fff6109b61238b6891dda62 +Subproject commit 6d2c435b8ce3a67e2065f38374bb437f274d0a6c diff --git a/codecov.yml b/codecov.yml index 93a5b687e4..b7abcf8c86 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,4 +1,3 @@ -comment: false coverage: status: project: @@ -6,6 +5,23 @@ coverage: target: auto # auto compares coverage to the previous base commit threshold: 10% # this allows a 10% drop from the previous base commit coverage informational: true + ignore: - "tests" - "sentry_sdk/_types.py" + +# Read more here: https://docs.codecov.com/docs/pull-request-comments +comment: + after_n_builds: 99 + layout: 'diff, files' + # Update, if comment exists. Otherwise post new. + behavior: default + # Comments will only post when coverage changes. Furthermore, if a comment + # already exists, and a newer commit results in no coverage change for the + # entire pull, the comment will be deleted. + require_changes: true + require_base: true # must have a base report to post + require_head: true # must have a head report to post + +github_checks: + annotations: false diff --git a/docs/api.rst b/docs/api.rst index f504bbb642..802abee75d 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -5,6 +5,14 @@ Top Level API This is the user facing API of the SDK. It's exposed as ``sentry_sdk``. With this API you can implement a custom performance monitoring or error reporting solution. +Initializing the SDK +==================== + +.. autoclass:: sentry_sdk.client.ClientConstructor + :members: + :undoc-members: + :special-members: __init__ + :noindex: Capturing Data ============== @@ -17,6 +25,7 @@ Capturing Data Enriching Events ================ +.. autofunction:: sentry_sdk.api.add_attachment .. autofunction:: sentry_sdk.api.add_breadcrumb .. autofunction:: sentry_sdk.api.set_context .. autofunction:: sentry_sdk.api.set_extra @@ -28,10 +37,12 @@ Enriching Events Performance Monitoring ====================== +.. autofunction:: sentry_sdk.api.trace .. autofunction:: sentry_sdk.api.continue_trace .. autofunction:: sentry_sdk.api.get_current_span .. autofunction:: sentry_sdk.api.start_span .. autofunction:: sentry_sdk.api.start_transaction +.. autofunction:: sentry_sdk.api.update_current_span Distributed Tracing @@ -41,13 +52,17 @@ Distributed Tracing .. autofunction:: sentry_sdk.api.get_traceparent +Client Management +================= + +.. autofunction:: sentry_sdk.api.is_initialized +.. autofunction:: sentry_sdk.api.get_client + + Managing Scope (advanced) ========================= .. autofunction:: sentry_sdk.api.configure_scope .. autofunction:: sentry_sdk.api.push_scope - -.. Not documented (On purpose. Not sure if anyone should use those) -.. last_event_id() -.. flush() +.. autofunction:: sentry_sdk.api.new_scope diff --git a/docs/apidocs.rst b/docs/apidocs.rst index dc4117e559..a3c8a6e150 100644 --- a/docs/apidocs.rst +++ b/docs/apidocs.rst @@ -11,6 +11,15 @@ API Docs .. autoclass:: sentry_sdk.Client :members: +.. autoclass:: sentry_sdk.client.BaseClient + :members: + +.. autoclass:: sentry_sdk.client.NonRecordingClient + :members: + +.. autoclass:: sentry_sdk.client._Client + :members: + .. autoclass:: sentry_sdk.Transport :members: @@ -23,7 +32,7 @@ API Docs .. autoclass:: sentry_sdk.tracing.Span :members: -.. autoclass:: sentry_sdk.profiler.Profile +.. autoclass:: sentry_sdk.profiler.transaction_profiler.Profile :members: .. autoclass:: sentry_sdk.session.Session diff --git a/docs/conf.py b/docs/conf.py index 56c4ea1ab3..4ce462103f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - import os import sys import typing @@ -10,7 +8,10 @@ import sphinx.builders.latex import sphinx.builders.texinfo import sphinx.builders.text +import sphinx.domains.c # noqa: F401 +import sphinx.domains.cpp # noqa: F401 import sphinx.ext.autodoc # noqa: F401 +import sphinx.ext.intersphinx # noqa: F401 import urllib3.exceptions # noqa: F401 typing.TYPE_CHECKING = True @@ -30,7 +31,7 @@ copyright = "2019-{}, Sentry Team and Contributors".format(datetime.now().year) author = "Sentry Team and Contributors" -release = "1.32.0" +release = "2.48.0" version = ".".join(release.split(".")[:2]) # The short X.Y version. diff --git a/linter-requirements.txt b/linter-requirements.txt deleted file mode 100644 index d1108f8eae..0000000000 --- a/linter-requirements.txt +++ /dev/null @@ -1,11 +0,0 @@ -mypy -black -flake8==5.0.4 # flake8 depends on pyflakes>=3.0.0 and this dropped support for Python 2 "# type:" comments -types-certifi -types-redis -types-setuptools -pymongo # There is no separate types module. -loguru # There is no separate types module. -flake8-bugbear -pep8-naming -pre-commit # local linting diff --git a/mypy.ini b/mypy.ini deleted file mode 100644 index fef90c867e..0000000000 --- a/mypy.ini +++ /dev/null @@ -1,73 +0,0 @@ -[mypy] -python_version = 3.11 -allow_redefinition = True -check_untyped_defs = True -; disallow_any_decorated = True -; disallow_any_explicit = True -; disallow_any_expr = True -disallow_any_generics = True -; disallow_any_unimported = True -disallow_incomplete_defs = True -disallow_subclassing_any = True -; disallow_untyped_calls = True -disallow_untyped_decorators = True -disallow_untyped_defs = True -no_implicit_optional = True -strict_equality = True -strict_optional = True -warn_redundant_casts = True -; warn_return_any = True -warn_unused_configs = True -warn_unused_ignores = True - - -; Relaxations for code written before mypy was introduced -; -; Do not use wildcards in module paths, otherwise added modules will -; automatically have the same set of relaxed rules as the rest - -[mypy-django.*] -ignore_missing_imports = True -[mypy-pyramid.*] -ignore_missing_imports = True -[mypy-psycopg2.*] -ignore_missing_imports = True -[mypy-pytest.*] -ignore_missing_imports = True -[mypy-aiohttp.*] -ignore_missing_imports = True -[mypy-sanic.*] -ignore_missing_imports = True -[mypy-tornado.*] -ignore_missing_imports = True -[mypy-fakeredis.*] -ignore_missing_imports = True -[mypy-rq.*] -ignore_missing_imports = True -[mypy-pyspark.*] -ignore_missing_imports = True -[mypy-asgiref.*] -ignore_missing_imports = True -[mypy-executing.*] -ignore_missing_imports = True -[mypy-asttokens.*] -ignore_missing_imports = True -[mypy-pure_eval.*] -ignore_missing_imports = True -[mypy-blinker.*] -ignore_missing_imports = True -[mypy-sentry_sdk._queue] -ignore_missing_imports = True -disallow_untyped_defs = False -[mypy-sentry_sdk._lru_cache] -disallow_untyped_defs = False -[mypy-celery.app.trace] -ignore_missing_imports = True -[mypy-flask.signals] -ignore_missing_imports = True -[mypy-huey.*] -ignore_missing_imports = True -[mypy-arq.*] -ignore_missing_imports = True -[mypy-grpc.*] -ignore_missing_imports = True diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000000..2038ccd81f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,236 @@ +# +# Tool: Coverage +# + +[tool.coverage.run] +branch = true +core = "ctrace" +omit = [ + "/tmp/*", + "*/tests/*", + "*/.venv/*", +] + +[tool.coverage.report] +exclude_also = [ + "if TYPE_CHECKING:", +] + +# +# Tool: Pytest +# + +[tool.pytest.ini_options] +addopts = "-vvv -rfEs -s --durations=5 --cov=./sentry_sdk --cov-branch --cov-report= --tb=short --junitxml=.junitxml" +asyncio_mode = "strict" +asyncio_default_fixture_loop_scope = "function" +markers = [ + "tests_internal_exceptions: Handle internal exceptions just as the SDK does, to test it. (Otherwise internal exceptions are recorded and reraised.)", +] + +[tool.pytest-watch] +verbose = true +nobeep = true + +# +# Tool: Mypy +# + +[tool.mypy] +allow_redefinition = true +check_untyped_defs = true +disallow_any_generics = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +python_version = "3.11" +strict_equality = true +strict_optional = true +warn_redundant_casts = true +warn_unused_configs = true +warn_unused_ignores = true + +# Relaxations for code written before mypy was introduced +# Do not use wildcards in module paths, otherwise added modules will +# automatically have the same set of relaxed rules as the rest +[[tool.mypy.overrides]] +module = "cohere.*" +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "django.*" +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "pyramid.*" +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "psycopg2.*" +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "pytest.*" +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "aiohttp.*" +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "anthropic.*" +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "sanic.*" +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "tornado.*" +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "fakeredis.*" +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "rq.*" +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "pyspark.*" +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "asgiref.*" +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "langchain_core.*" +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "langchain.*" +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "langgraph.*" +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "google.genai.*" +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "executing.*" +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "asttokens.*" +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "pure_eval.*" +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "blinker.*" +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "sentry_sdk._queue" +ignore_missing_imports = true +disallow_untyped_defs = false + +[[tool.mypy.overrides]] +module = "sentry_sdk._lru_cache" +disallow_untyped_defs = false + +[[tool.mypy.overrides]] +module = "celery.app.trace" +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "flask.signals" +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "huey.*" +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "openai.*" +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "openfeature.*" +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "huggingface_hub.*" +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "arq.*" +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "grpc.*" +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "agents.*" +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "dramatiq.*" +ignore_missing_imports = true + +# +# Tool: Ruff (linting and formatting) +# + +[tool.ruff] +# Target Python 3.7+ (minimum version supported by ruff) +target-version = "py37" + +# Exclude files and directories +extend-exclude = [ + "*_pb2.py", # Protocol Buffer files (covers all pb2 files including grpc_test_service_pb2.py) + "*_pb2_grpc.py", # Protocol Buffer files (covers all pb2_grpc files including grpc_test_service_pb2_grpc.py) + "checkouts", # From flake8 + "lol*", # From flake8 +] + +[tool.ruff.lint] +# Match flake8's default rule selection exactly +# Flake8 by default only enables E and W (pycodestyle) + F (pyflakes) +select = [ + "E", # pycodestyle errors (same as flake8 default) + "W", # pycodestyle warnings (same as flake8 default) + "F", # Pyflakes (same as flake8 default) + # Note: B and N rules are NOT enabled by default in flake8 + # They were only active through the plugins, which may not have been fully enabled +] + +# Use ONLY the same ignores as the original flake8 config + compatibility for this codebase +ignore = [ + "E203", # Whitespace before ':' + "E501", # Line too long + "E402", # Module level import not at top of file + "E731", # Do not assign a lambda expression, use a def + "B014", # Redundant exception types + "N812", # Lowercase imported as non-lowercase + "N804", # First argument of classmethod should be named cls + + # Additional ignores for codebase compatibility + "F401", # Unused imports - many in TYPE_CHECKING blocks used for type comments + "E721", # Use isinstance instead of type() == - existing pattern in this codebase +] + +[tool.ruff.format] +# ruff format already excludes the same files as specified in extend-exclude +# Ensure Python 3.7 compatibility - avoid using Python 3.9+ syntax features +skip-magic-trailing-comma = false diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index f736c30496..0000000000 --- a/pytest.ini +++ /dev/null @@ -1,14 +0,0 @@ -[pytest] -DJANGO_SETTINGS_MODULE = tests.integrations.django.myapp.settings -addopts = --tb=short -markers = - tests_internal_exceptions: Handle internal exceptions just as the SDK does, to test it. (Otherwise internal exceptions are recorded and reraised.) - only: A temporary marker, to make pytest only run the tests with the mark, similar to jests `it.only`. To use, run `pytest -v -m only`. -asyncio_mode = strict - -[pytest-watch] -; Enable this to drop into pdb on errors -; pdb = True - -verbose = True -nobeep = True diff --git a/requirements-aws-lambda-layer.txt b/requirements-aws-lambda-layer.txt new file mode 100644 index 0000000000..8a6ff63aa7 --- /dev/null +++ b/requirements-aws-lambda-layer.txt @@ -0,0 +1,8 @@ +certifi +urllib3 +# In Lambda functions botocore is used, and botocore has +# restrictions on urllib3 +# https://github.com/boto/botocore/blob/develop/setup.cfg +# So we pin this here to make our Lambda layer work with +# Lambda Function using Python 3.7+ +urllib3<1.27; python_version < "3.10" diff --git a/requirements-devenv.txt b/requirements-devenv.txt new file mode 100644 index 0000000000..e5be6c7d77 --- /dev/null +++ b/requirements-devenv.txt @@ -0,0 +1,6 @@ +-r requirements-linting.txt +-r requirements-testing.txt +mockupdb # required by `pymongo` tests that are enabled by `pymongo` from linter requirements +pytest>=6.0.0 +tomli;python_version<"3.11" # Only needed for pytest on Python < 3.11 +pytest-asyncio diff --git a/docs-requirements.txt b/requirements-docs.txt similarity index 80% rename from docs-requirements.txt rename to requirements-docs.txt index a4bb031506..81e04ba3ef 100644 --- a/docs-requirements.txt +++ b/requirements-docs.txt @@ -1,4 +1,5 @@ +gevent shibuya -sphinx==7.2.6 +sphinx<8.2 sphinx-autodoc-typehints[type_comments]>=1.8.0 typing-extensions diff --git a/requirements-linting.txt b/requirements-linting.txt new file mode 100644 index 0000000000..56c26df8de --- /dev/null +++ b/requirements-linting.txt @@ -0,0 +1,20 @@ +mypy +ruff +types-certifi +types-protobuf +types-gevent +types-greenlet +types-redis +types-setuptools +types-webob +opentelemetry-distro[otlp] +pymongo # There is no separate types module. +loguru # There is no separate types module. +pre-commit # local linting +httpcore +launchdarkly-server-sdk +openfeature-sdk +statsig +UnleashClient +typer +strawberry-graphql diff --git a/requirements-testing.txt b/requirements-testing.txt new file mode 100644 index 0000000000..5cd669af9a --- /dev/null +++ b/requirements-testing.txt @@ -0,0 +1,17 @@ +pip +pytest>=6.0.0 +tomli;python_version<"3.11" # Only needed for pytest on Python < 3.11 +pytest-cov +pytest-forked +pytest-localserver +pytest-watch +jsonschema +executing +asttokens +responses +pysocks +socksio +httpcore[http2] +setuptools +Brotli +docker diff --git a/scripts/aws-cleanup.sh b/scripts/aws-cleanup.sh deleted file mode 100644 index 1219668855..0000000000 --- a/scripts/aws-cleanup.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/sh -# Delete all AWS Lambda functions - -export AWS_ACCESS_KEY_ID="$SENTRY_PYTHON_TEST_AWS_ACCESS_KEY_ID" -export AWS_SECRET_ACCESS_KEY="$SENTRY_PYTHON_TEST_AWS_SECRET_ACCESS_KEY" -export AWS_IAM_ROLE="$SENTRY_PYTHON_TEST_AWS_IAM_ROLE" - -for func in $(aws lambda list-functions | jq -r .Functions[].FunctionName); do - echo "Deleting $func" - aws lambda delete-function --function-name $func -done diff --git a/scripts/aws-attach-layer-to-lambda-function.sh b/scripts/aws/aws-attach-layer-to-lambda-function.sh similarity index 100% rename from scripts/aws-attach-layer-to-lambda-function.sh rename to scripts/aws/aws-attach-layer-to-lambda-function.sh diff --git a/scripts/aws-delete-lamba-layer-versions.sh b/scripts/aws/aws-delete-lambda-layer-versions.sh similarity index 95% rename from scripts/aws-delete-lamba-layer-versions.sh rename to scripts/aws/aws-delete-lambda-layer-versions.sh index f467f9398b..dcbd2f9c65 100755 --- a/scripts/aws-delete-lamba-layer-versions.sh +++ b/scripts/aws/aws-delete-lambda-layer-versions.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash # # Deletes all versions of the layer specified in LAYER_NAME in one region. +# Use with caution! # set -euo pipefail diff --git a/scripts/aws-deploy-local-layer.sh b/scripts/aws/aws-deploy-local-layer.sh similarity index 74% rename from scripts/aws-deploy-local-layer.sh rename to scripts/aws/aws-deploy-local-layer.sh index 3f213849f3..ee7b3e45c0 100755 --- a/scripts/aws-deploy-local-layer.sh +++ b/scripts/aws/aws-deploy-local-layer.sh @@ -1,9 +1,8 @@ #!/usr/bin/env bash # -# Builds and deploys the Sentry AWS Lambda layer (including the Sentry SDK and the Sentry Lambda Extension) +# Builds and deploys the `SentryPythonServerlessSDK-local-dev` AWS Lambda layer (containing the Sentry SDK) # # The currently checked out version of the SDK in your local directory is used. -# The latest version of the Lambda Extension is fetched from the Sentry Release Registry. # set -euo pipefail @@ -22,7 +21,7 @@ aws lambda publish-layer-version \ --region "eu-central-1" \ --zip-file "fileb://dist/$ZIP" \ --description "Local test build of SentryPythonServerlessSDK (can be deleted)" \ - --compatible-runtimes python3.6 python3.7 python3.8 python3.9 + --compatible-runtimes python3.7 python3.8 python3.9 python3.10 python3.11 \ --no-cli-pager echo "Done deploying zipped Lambda layer to AWS as 'SentryPythonServerlessSDK-local-dev'." diff --git a/scripts/build_aws_lambda_layer.py b/scripts/build_aws_lambda_layer.py index d551097649..fce67080de 100644 --- a/scripts/build_aws_lambda_layer.py +++ b/scripts/build_aws_lambda_layer.py @@ -1,10 +1,15 @@ import os import shutil import subprocess +import sys import tempfile +from typing import TYPE_CHECKING from sentry_sdk.consts import VERSION as SDK_VERSION +if TYPE_CHECKING: + from typing import Optional + DIST_PATH = "dist" # created by "make dist" that is called by "make aws-lambda-layer" PYTHON_SITE_PACKAGES = "python" # see https://docs.aws.amazon.com/lambda/latest/dg/configuration-layers.html#configuration-layers-path @@ -12,28 +17,46 @@ class LayerBuilder: def __init__( self, - base_dir, # type: str - ): - # type: (...) -> None + base_dir: str, + out_zip_filename: "Optional[str]"=None, + ) -> None: self.base_dir = base_dir self.python_site_packages = os.path.join(self.base_dir, PYTHON_SITE_PACKAGES) - self.out_zip_filename = f"sentry-python-serverless-{SDK_VERSION}.zip" + self.out_zip_filename = ( + f"sentry-python-serverless-{SDK_VERSION}.zip" + if out_zip_filename is None + else out_zip_filename + ) - def make_directories(self): - # type: (...) -> None + def make_directories(self) -> None: os.makedirs(self.python_site_packages) - def install_python_packages(self): - # type: (...) -> None + def install_python_packages(self) -> None: + # Install requirements for Lambda Layer (these are more limited than the SDK requirements, + # because Lambda does not support the newest versions of some packages) + subprocess.check_call( + [ + sys.executable, + "-m", + "pip", + "install", + "-r", + "requirements-aws-lambda-layer.txt", + "--target", + self.python_site_packages, + ], + ) + sentry_python_sdk = os.path.join( DIST_PATH, - f"sentry_sdk-{SDK_VERSION}-py2.py3-none-any.whl", # this is generated by "make dist" that is called by "make aws-lamber-layer" + f"sentry_sdk-{SDK_VERSION}-py2.py3-none-any.whl", # this is generated by "make dist" that is called by "make aws-lambda-layer" ) subprocess.run( [ "pip", "install", "--no-cache-dir", # always access PyPI + "--no-deps", # the right depencencies have been installed in the call above "--quiet", sentry_python_sdk, "--target", @@ -42,15 +65,13 @@ def install_python_packages(self): check=True, ) - def create_init_serverless_sdk_package(self): - # type: (...) -> None + def create_init_serverless_sdk_package(self) -> None: """ Method that creates the init_serverless_sdk pkg in the sentry-python-serverless zip """ serverless_sdk_path = ( - f"{self.python_site_packages}/sentry_sdk/" - f"integrations/init_serverless_sdk" + f"{self.python_site_packages}/sentry_sdk/integrations/init_serverless_sdk" ) if not os.path.exists(serverless_sdk_path): os.makedirs(serverless_sdk_path) @@ -58,8 +79,7 @@ def create_init_serverless_sdk_package(self): "scripts/init_serverless_sdk.py", f"{serverless_sdk_path}/__init__.py" ) - def zip(self): - # type: (...) -> None + def zip(self) -> None: subprocess.run( [ "zip", @@ -80,13 +100,34 @@ def zip(self): ) -def build_packaged_zip(): - with tempfile.TemporaryDirectory() as base_dir: - layer_builder = LayerBuilder(base_dir) - layer_builder.make_directories() - layer_builder.install_python_packages() - layer_builder.create_init_serverless_sdk_package() - layer_builder.zip() +def build_packaged_zip(base_dir=None, make_dist=False, out_zip_filename=None): + if base_dir is None: + base_dir = tempfile.mkdtemp() + + if make_dist: + # Same thing that is done by "make dist" + # (which is a dependency of "make aws-lambda-layer") + subprocess.check_call( + [sys.executable, "setup.py", "sdist", "bdist_wheel", "-d", DIST_PATH], + ) + + layer_builder = LayerBuilder(base_dir, out_zip_filename=out_zip_filename) + layer_builder.make_directories() + layer_builder.install_python_packages() + layer_builder.create_init_serverless_sdk_package() + layer_builder.zip() + + # Just for debugging + dist_path = os.path.abspath(DIST_PATH) + print("Created Lambda Layer package with this information:") + print(" - Base directory for generating package: {}".format(layer_builder.base_dir)) + print( + " - Created Python SDK distribution (in `{}`): {}".format(dist_path, make_dist) + ) + if not make_dist: + print(" If 'False' we assume it was already created (by 'make dist')") + print(" - Package zip filename: {}".format(layer_builder.out_zip_filename)) + print(" - Copied package zip to: {}".format(dist_path)) if __name__ == "__main__": diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh index 74546f5d9f..7d4a817cf6 100755 --- a/scripts/bump-version.sh +++ b/scripts/bump-version.sh @@ -21,6 +21,6 @@ function replace() { grep "$2" $3 # verify that replacement was successful } -replace "version=\"[0-9.]+\"" "version=\"$NEW_VERSION\"" ./setup.py -replace "VERSION = \"[0-9.]+\"" "VERSION = \"$NEW_VERSION\"" ./sentry_sdk/consts.py -replace "release = \"[0-9.]+\"" "release = \"$NEW_VERSION\"" ./docs/conf.py +replace "version=\"$OLD_VERSION\"" "version=\"$NEW_VERSION\"" ./setup.py +replace "VERSION = \"$OLD_VERSION\"" "VERSION = \"$NEW_VERSION\"" ./sentry_sdk/consts.py +replace "release = \"$OLD_VERSION\"" "release = \"$NEW_VERSION\"" ./docs/conf.py diff --git a/scripts/generate-test-files.sh b/scripts/generate-test-files.sh new file mode 100755 index 0000000000..d1e0a7602c --- /dev/null +++ b/scripts/generate-test-files.sh @@ -0,0 +1,18 @@ +#!/bin/sh + +# This script generates tox.ini and CI YAML files in one go. + +set -xe + +cd "$(dirname "$0")" + +rm -rf toxgen.venv +python -m venv toxgen.venv +. toxgen.venv/bin/activate + +toxgen.venv/bin/pip install -e .. +toxgen.venv/bin/pip install -r populate_tox/requirements.txt +toxgen.venv/bin/pip install -r split_tox_gh_actions/requirements.txt + +toxgen.venv/bin/python populate_tox/populate_tox.py +toxgen.venv/bin/python split_tox_gh_actions/split_tox_gh_actions.py diff --git a/scripts/init_serverless_sdk.py b/scripts/init_serverless_sdk.py index e620c1067b..49f8834e1b 100644 --- a/scripts/init_serverless_sdk.py +++ b/scripts/init_serverless_sdk.py @@ -5,14 +5,16 @@ Then the Handler function sstring should be replaced with 'sentry_sdk.integrations.init_serverless_sdk.sentry_lambda_handler' """ + import os import sys import re import sentry_sdk -from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.integrations.aws_lambda import AwsLambdaIntegration +from typing import TYPE_CHECKING + if TYPE_CHECKING: from typing import Any @@ -48,8 +50,8 @@ def extract_and_load_lambda_function_module(self, module_path): module_name = module_path.split(os.path.sep)[-1] module_file_path = module_path + ".py" - # Supported python versions are 2.7, 3.6, 3.7, 3.8 - if py_version >= (3, 5): + # Supported python versions are 3.6, 3.7, 3.8 + if py_version >= (3, 6): import importlib.util spec = importlib.util.spec_from_file_location( @@ -57,12 +59,6 @@ def extract_and_load_lambda_function_module(self, module_path): ) self.lambda_function_module = importlib.util.module_from_spec(spec) spec.loader.exec_module(self.lambda_function_module) - elif py_version[0] < 3: - import imp - - self.lambda_function_module = imp.load_source( - module_name, module_file_path - ) else: raise ValueError("Python version %s is not supported." % py_version) else: @@ -74,8 +70,7 @@ def get_lambda_handler(self): return getattr(self.lambda_function_module, self.handler_name) -def sentry_lambda_handler(event, context): - # type: (Any, Any) -> None +def sentry_lambda_handler(event: "Any", context: "Any") -> None: """ Handler function that invokes a lambda handler which path is defined in environment variables as "SENTRY_INITIAL_HANDLER" diff --git a/scripts/populate_tox/README.md b/scripts/populate_tox/README.md new file mode 100644 index 0000000000..e483ed78cb --- /dev/null +++ b/scripts/populate_tox/README.md @@ -0,0 +1,229 @@ +# Populate Tox + +We integrate with a number of frameworks and libraries and have a test suite for +each. The tests run against different versions of the framework/library to make +sure we support everything we claim to. + +This `populate_tox.py` script is responsible for picking reasonable versions to +test automatically and generating parts of `tox.ini` to capture this. + +## Running the script + +You require a free-threaded interpreter with pip installed to run the script. With +a recent version of `uv` you can directly run the script with the following +command: + +``` +uv run --python 3.14t \ + --with pip \ + --with-requirements scripts/populate_tox/requirements.txt \ + --with-editable . \ + python scripts/populate_tox/populate_tox.py +``` + +## How it works + +There is a template in this directory called `tox.jinja` which contains a +combination of hardcoded and generated entries. + +The `populate_tox.py` script fills out the auto-generated part of that template. +It does this by querying PyPI for each framework's package and its metadata and +then determining which versions it makes sense to test to get good coverage. + +By default, the lowest supported and latest version of a framework are always +tested, with a number of releases in between: +- If the package has majors, we pick the highest version of each major. +- If the package doesn't have multiple majors, we pick two versions in between + lowest and highest. + +Each test suite requires at least some configuration to be added to +`TEST_SUITE_CONFIG` in `scripts/populate_tox/config.py`. If you're adding a new +integration, check out the [Add a new test suite](#add-a-new-test-suite) section. + +## Test suite config + +The `TEST_SUITE_CONFIG` dictionary in `scripts/populate_tox/config.py` defines, +for each integration test suite, the main package (framework, library) to test +with; any additional test dependencies, optionally gated behind specific +conditions; and optionally the Python versions to test on. + +Constraints are defined using the format specified below. The following sections +describe each key. + +``` +integration_name: { + "package": name_of_main_package_on_pypi, + "deps": { + rule1: [package1, package2, ...], + rule2: [package3, package4, ...], + }, + "python": python_version_specifier | dict[package_version_specifier, python_version_specifier], + "include": package_version_specifier, + "integration_name": integration_name, + "num_versions": int, +} +``` + +When talking about version specifiers, we mean +[version specifiers as defined](https://packaging.python.org/en/latest/specifications/version-specifiers/#id5) +by the Python Packaging Authority. See also the actual implementation +in [packaging.specifiers](https://packaging.pypa.io/en/stable/specifiers.html). + +### `package` + +The name of the third-party package as it's listed on PyPI. The script will +be picking different versions of this package to test. + +This key is mandatory. + +### `deps` + +The test dependencies of the test suite. They're defined as a dictionary of +`rule: [package1, package2, ...]` key-value pairs. All packages +in the package list of a rule will be installed as long as the rule applies. + +Each `rule` must be one of the following: + - `*`: packages will be always installed + - a version specifier on the main package (e.g. `<=0.32`): packages will only + be installed if the main package falls into the version bounds specified + - specific Python version(s) in the form `py3.8,py3.9`: packages will only be + installed if the Python version matches one from the list + +Rules can be used to specify version bounds on older versions of the main +package's dependencies, for example. If Flask tests generally need +Werkzeug and don't care about its version, but Flask older than 3.0 needs +a specific Werkzeug version to work, you can say: + +```python +"flask": { + "deps": { + "*": ["Werkzeug"], + "<3.0": ["Werkzeug<2.1.0"], + }, + ... +} +``` + +If you need to install a specific version of a secondary dependency on specific +Python versions, you can say: + +```python +"celery": { + "deps": { + "*": ["newrelic", "redis"], + "py3.7": ["importlib-metadata<5.0"], + }, + ... +} +``` + +This key is optional. + +### `python` + +Sometimes, the whole test suite should only run on specific Python versions. +This can be achieved via the `python` key. There are two variants how to define +the Python versions to run the test suite on. + +If you want the test suite to only be run on specific Python versions, you can +set `python` to a version specifier. For example, if you want AIOHTTP tests to +only run on Python 3.7+, you can say: + +```python +"aiohttp": { + "python": ">=3.7", + ... +} +``` + +If the Python version to use is dependent on the version of the package under +test, you can use the more expressive dictionary variant. For instance, while +HTTPX v0.28 supports Python 3.8, a test dependency of ours, `pytest-httpx`, +doesn't. If you want to specify that HTTPX test suite should not be run on +a Python version older than 3.9 if the HTTPX version is 0.28 or higher, you can +say: + +```python +"httpx": { + "python": { + # run the test suite for httpx v0.28+ on Python 3.9+ only + ">=0.28": ">=3.9", + }, +} +``` + +The `python` key is optional, and when possible, it should be omitted. The script +should automatically detect which Python versions the package supports. However, +if a package has broken metadata or the SDK is explicitly not supporting some +packages on specific Python versions, the `python` key can be used. + +### `include` + +Sometimes we only want to test specific versions of packages. For example, the +Starlite package has two alpha prereleases of version 2.0.0, but we do not want +to test these, since Starlite 2.0 was renamed to Litestar. + +The value of the `include` key expects a version specifier defining which +versions should be considered for testing. For example, since we only want to test +versions below 2.x in Starlite, we can use: + +```python +"starlite": { + "include": "<2", + ... +} +``` + +The `include` key can also be used to exclude a set of specific versions by using +`!=` version specifiers. For example, the Starlite restriction above could equivalently +be expressed like so: + + +```python +"starlite": { + "include": "!=2.0.0a1,!=2.0.0a2", + ... +} +``` + +### `integration_name` + +Sometimes, the name of the test suite doesn't match the name of the integration. +For example, we have the `openai-base` and `openai-notiktoken` test suites, both +of which are actually testing the `openai` integration. If this is the case, you +can use the `integration_name` key to define the name of the integration. If not +provided, it will default to the name of the test suite. + +Linking an integration to a test suite allows the script to access integration +configuration like, for example, the minimum supported version defined in +`sentry_sdk/integrations/__init__.py`. + +### `num_versions` + +With this option you can tweak the default version picking behavior by specifying +how many package versions should be tested. It accepts an integer equal to or +greater than 2, as the oldest and latest supported versions will always be +picked. Additionally, if there is a recent prerelease, it'll also always be +picked (this doesn't count towards `num_versions`). + +For instance, `num_versions` set to `2` will only test the first supported and +the last release of the package. `num_versions` equal to `3` will test the first +supported, the last release, and one release in between; `num_versions` set to `4` +will test an additional release in between. In all these cases, if there is +a recent prerelease, it'll be picked as well in addition to the picked versions. + +## How-Tos + +### Add a new test suite + +1. Add the minimum supported version of the framework/library to `_MIN_VERSIONS` + in `integrations/__init__.py`. This should be the lowest version of the + framework that we can guarantee works with the SDK. If you've just added the + integration, you should generally set this to the latest version of the framework + at the time, unless you've verified the integration works for earlier versions + as well. +2. Add the integration and any constraints to `TEST_SUITE_CONFIG`. See the + [Test suite config](#test-suite-config) section for the format. +3. Add the integration to one of the groups in the `GROUPS` dictionary in + `scripts/split_tox_gh_actions/split_tox_gh_actions.py`. +4. Run `scripts/generate-test-files.sh` and commit the changes. diff --git a/scripts/populate_tox/config.py b/scripts/populate_tox/config.py new file mode 100644 index 0000000000..9d5e97846b --- /dev/null +++ b/scripts/populate_tox/config.py @@ -0,0 +1,463 @@ +# The TEST_SUITE_CONFIG dictionary defines, for each integration test suite, +# at least the main package (framework, library) to test with. Additional +# test dependencies, Python versions to test on, etc. can also be defined here. +# +# See scripts/populate_tox/README.md for more info on the format and examples. + +TEST_SUITE_CONFIG = { + "aiohttp": { + "package": "aiohttp", + "deps": { + "*": ["pytest-aiohttp"], + ">=3.8": ["pytest-asyncio"], + }, + "python": ">=3.7", + }, + "anthropic": { + "package": "anthropic", + "deps": { + "*": ["pytest-asyncio"], + "<0.50": ["httpx<0.28.0"], + }, + "python": ">=3.8", + }, + "ariadne": { + "package": "ariadne", + "deps": { + "*": ["fastapi", "flask", "httpx"], + }, + "python": ">=3.8", + "num_versions": 2, + }, + "arq": { + "package": "arq", + "deps": { + "*": ["async-timeout", "pytest-asyncio", "fakeredis>=2.2.0,<2.8"], + "<=0.23": ["pydantic<2"], + }, + "num_versions": 2, + }, + "asyncpg": { + "package": "asyncpg", + "deps": { + "*": ["pytest-asyncio"], + }, + "python": ">=3.7", + }, + "beam": { + "package": "apache-beam", + "python": ">=3.7", + "num_versions": 2, + "deps": { + "*": ["dill"], + }, + }, + "boto3": { + "package": "boto3", + "deps": { + "py3.7,py3.8": ["urllib3<2.0.0"], + }, + }, + "bottle": { + "package": "bottle", + "deps": { + "*": ["werkzeug<2.1.0"], + }, + }, + "celery": { + "package": "celery", + "deps": { + "*": ["newrelic<10.17.0", "redis"], + "py3.7": ["importlib-metadata<5.0"], + }, + }, + "chalice": { + "package": "chalice", + "deps": { + "*": ["pytest-chalice"], + }, + "num_versions": 2, + }, + "clickhouse_driver": { + "package": "clickhouse-driver", + "num_versions": 2, + }, + "cohere": { + "package": "cohere", + "python": ">=3.9", + }, + "django": { + "package": "django", + "deps": { + "*": [ + "psycopg2-binary", + "djangorestframework", + "pytest-django", + "Werkzeug", + ], + ">=2.0": ["channels[daphne]"], + ">=2.2,<3.1": ["six"], + ">=3.0": ["pytest-asyncio"], + "<3.3": [ + "djangorestframework>=3.0,<4.0", + "Werkzeug<2.1.0", + ], + "<3.1": ["pytest-django<4.0"], + "py3.14,py3.14t": ["coverage==7.11.0"], + }, + }, + "dramatiq": { + "package": "dramatiq", + "num_versions": 2, + }, + "falcon": { + "package": "falcon", + "python": "<3.13", + }, + "fastapi": { + "package": "fastapi", + "deps": { + "*": [ + "httpx", + "pytest-asyncio", + "python-multipart", + "requests", + "anyio<4", + ], + # There's an incompatibility between FastAPI's TestClient, which is + # actually Starlette's TestClient, which is actually httpx's Client. + # httpx dropped a deprecated Client argument in 0.28.0, Starlette + # dropped it from its TestClient in 0.37.2, and FastAPI only pinned + # Starlette>=0.37.2 from version 0.110.1 onwards -- so for older + # FastAPI versions we use older httpx which still supports the + # deprecated argument. + "<0.110.1": ["httpx<0.28.0"], + "py3.6": ["aiocontextvars"], + }, + }, + "flask": { + "package": "flask", + "deps": { + "*": ["flask-login", "werkzeug"], + "<2.0": ["werkzeug<2.1.0", "markupsafe<2.1.0"], + }, + }, + "gql": { + "package": "gql[all]", + "num_versions": 2, + }, + "google_genai": { + "package": "google-genai", + "deps": { + "*": ["pytest-asyncio"], + }, + "python": ">=3.9", + }, + "graphene": { + "package": "graphene", + "deps": { + "*": ["blinker", "fastapi", "flask", "httpx"], + "py3.6": ["aiocontextvars"], + }, + }, + "grpc": { + "package": "grpcio", + "deps": { + "*": ["protobuf", "mypy-protobuf", "types-protobuf", "pytest-asyncio"], + }, + "python": ">=3.7", + }, + "httpx": { + "package": "httpx", + "deps": { + "*": ["anyio<4.0.0"], + ">=0.16,<0.17": ["pytest-httpx==0.10.0"], + ">=0.17,<0.19": ["pytest-httpx==0.12.0"], + ">=0.19,<0.21": ["pytest-httpx==0.14.0"], + ">=0.21,<0.23": ["pytest-httpx==0.19.0"], + ">=0.23,<0.24": ["pytest-httpx==0.21.0"], + ">=0.24,<0.25": ["pytest-httpx==0.22.0"], + ">=0.25,<0.26": ["pytest-httpx==0.25.0"], + ">=0.26,<0.27": ["pytest-httpx==0.28.0"], + ">=0.27,<0.28": ["pytest-httpx==0.30.0"], + ">=0.28,<0.29": ["pytest-httpx==0.35.0"], + }, + "python": { + ">=0.28": ">=3.9", + }, + }, + "huey": { + "package": "huey", + "num_versions": 2, + }, + "huggingface_hub": { + "package": "huggingface_hub", + "deps": { + "*": ["responses", "pytest-httpx"], + }, + }, + "langchain-base": { + "package": "langchain", + "integration_name": "langchain", + "deps": { + "*": ["pytest-asyncio", "openai", "tiktoken", "langchain-openai"], + "<=0.1": ["httpx<0.28.0"], + ">=0.3": ["langchain-community"], + ">=1.0": ["langchain-classic"], + }, + "python": { + "<1.0": "<3.14", # https://github.com/langchain-ai/langchain/issues/33449#issuecomment-3408876631 + }, + }, + "langchain-notiktoken": { + "package": "langchain", + "integration_name": "langchain", + "deps": { + "*": ["pytest-asyncio", "openai", "langchain-openai"], + "<=0.1": ["httpx<0.28.0"], + ">=0.3": ["langchain-community"], + ">=1.0": ["langchain-classic"], + }, + "python": { + "<1.0": "<3.14", # https://github.com/langchain-ai/langchain/issues/33449#issuecomment-3408876631 + }, + }, + "langgraph": { + "package": "langgraph", + }, + "launchdarkly": { + "package": "launchdarkly-server-sdk", + "num_versions": 2, + }, + "litellm": { + "package": "litellm", + }, + "litestar": { + "package": "litestar", + "deps": { + "*": [ + "pytest-asyncio", + "python-multipart", + "requests", + "cryptography", + "sniffio", + ], + "<2.7": ["httpx<0.28"], + }, + }, + "loguru": { + "package": "loguru", + "num_versions": 2, + }, + "mcp": { + "package": "mcp", + "deps": { + "*": ["pytest-asyncio"], + }, + }, + "fastmcp": { + "package": "fastmcp", + "deps": { + "*": ["pytest-asyncio"], + }, + }, + "openai-base": { + "package": "openai", + "integration_name": "openai", + "deps": { + "*": ["pytest-asyncio", "tiktoken"], + "<1.55": ["httpx<0.28"], + }, + "python": { + ">0.0,<2.3": ">=3.8", + ">=2.3": ">=3.9", + }, + }, + "openai-notiktoken": { + "package": "openai", + "integration_name": "openai", + "deps": { + "*": ["pytest-asyncio"], + "<1.55": ["httpx<0.28"], + }, + "python": { + ">0.0,<2.3": ">=3.8", + ">=2.3": ">=3.9", + }, + }, + "openai_agents": { + "package": "openai-agents", + "deps": { + "*": ["pytest-asyncio"], + }, + "python": ">=3.10", + }, + "openfeature": { + "package": "openfeature-sdk", + "num_versions": 2, + }, + "pure_eval": { + "package": "pure_eval", + "num_versions": 2, + }, + "pydantic_ai": { + "package": "pydantic-ai", + "deps": { + "*": ["pytest-asyncio"], + }, + }, + "pymongo": { + "package": "pymongo", + "deps": { + "*": ["mockupdb"], + }, + }, + "pyramid": { + "package": "pyramid", + "deps": { + "*": ["werkzeug<2.1.0"], + }, + }, + "quart": { + "package": "quart", + "deps": { + "*": ["quart-auth", "pytest-asyncio", "Werkzeug"], + ">=0.19": ["quart-flask-patch"], + "<0.19": [ + "blinker<1.6", + "jinja2<3.1.0", + "Werkzeug<2.3.0", + "hypercorn<0.15.0", + ], + "py3.8": ["taskgroup==0.0.0a4"], + }, + "num_versions": 2, + }, + "ray": { + "package": "ray", + "python": { + ">0.0,<2.52.0": ">=3.9", + ">=2.52.0": ">=3.10", + }, + "num_versions": 2, + }, + "redis": { + "package": "redis", + "deps": { + "*": ["fakeredis!=1.7.4", "pytest<8.0.0"], + ">=4.0,<5.0": ["fakeredis<2.31.0"], + "py3.6,py3.7,py3.8": ["fakeredis<2.26.0"], + "py3.7,py3.8,py3.9,py3.10,py3.11,py3.12,py3.13": ["pytest-asyncio"], + }, + }, + "redis_py_cluster_legacy": { + "package": "redis-py-cluster", + "num_versions": 2, + }, + "requests": { + "package": "requests", + "num_versions": 2, + }, + "rq": { + "package": "rq", + "deps": { + # https://github.com/jamesls/fakeredis/issues/245 + # https://github.com/cunla/fakeredis-py/issues/341 + "*": ["fakeredis<2.28.0"], + "<0.9": ["fakeredis<1.0", "redis<3.2.2"], + ">=0.9,<0.14": ["fakeredis>=1.0,<1.7.4"], + "py3.6,py3.7": ["fakeredis!=2.26.0"], + }, + }, + "sanic": { + "package": "sanic", + "deps": { + "*": ["websockets<11.0", "aiohttp"], + ">=22": ["sanic-testing"], + "py3.6": ["aiocontextvars==0.2.1"], + "py3.8": ["tracerite<1.1.2"], + }, + "num_versions": 4, + }, + "spark": { + "package": "pyspark", + "python": ">=3.8", + }, + "sqlalchemy": { + "package": "sqlalchemy", + }, + "starlette": { + "package": "starlette", + "deps": { + "*": [ + "pytest-asyncio", + "python-multipart", + "requests", + "anyio<4.0.0", + "jinja2", + "httpx", + ], + # See the comment on FastAPI's httpx bound for more info + "<0.37.2": ["httpx<0.28.0"], + "<0.15": ["jinja2<3.1"], + "py3.6": ["aiocontextvars"], + }, + }, + "starlite": { + "package": "starlite", + "deps": { + "*": [ + "pytest-asyncio", + "python-multipart", + "requests", + "cryptography", + "pydantic<2.0.0", + "httpx<0.28", + ], + }, + "python": "<=3.11", + "include": "!=2.0.0a1,!=2.0.0a2", # these are not relevant as there will never be a stable 2.0 release (starlite continues as litestar) + "num_versions": 2, + }, + "statsig": { + "package": "statsig", + "deps": { + "*": ["typing_extensions"], + }, + "num_versions": 2, + }, + "strawberry": { + "package": "strawberry-graphql[fastapi,flask]", + "deps": { + "*": ["httpx"], + "<=0.262.5": ["pydantic<2.11"], + }, + "num_versions": 2, + }, + "tornado": { + "package": "tornado", + "deps": { + "*": ["pytest"], + "<=6.4.1": [ + "pytest<8.2" + ], # https://github.com/tornadoweb/tornado/pull/3382 + "py3.6": ["aiocontextvars"], + }, + "num_versions": 2, + }, + "trytond": { + "package": "trytond", + "deps": { + "*": ["werkzeug"], + "<=5.0": ["werkzeug<1.0"], + }, + }, + "typer": { + "package": "typer", + "num_versions": 2, + }, + "unleash": { + "package": "UnleashClient", + "num_versions": 2, + }, +} diff --git a/scripts/populate_tox/package_dependencies.jsonl b/scripts/populate_tox/package_dependencies.jsonl new file mode 100644 index 0000000000..3c32b3cb93 --- /dev/null +++ b/scripts/populate_tox/package_dependencies.jsonl @@ -0,0 +1,29 @@ +{"name": "boto3", "version": "1.42.13", "dependencies": [{"download_info": {"url": "https://files.pythonhosted.org/packages/9e/a8/51fb7b8078864f673169456ce16eecd2abd9d40010a65c6fa910b41c0088/boto3-1.42.13-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/b1/52/b4235bd6cd9b86fa73be92bad1039fd533b666921c32d0d94ffdb220a871/botocore-1.42.13-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/31/b4/b9b800c45527aadd64d5b442f9b932b00648617eb5d63d2c7a6587b7cafc/jmespath-1.0.1-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/6d/b9/4095b668ea3678bf6a0af005527f39de12fb026516fb3df17495a733b7f8/urllib3-2.6.2-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl"}}]} +{"name": "django", "version": "5.2.9", "dependencies": [{"download_info": {"url": "https://files.pythonhosted.org/packages/17/b0/7f42bfc38b8f19b78546d47147e083ed06e12fc29c42da95655e0962c6c2/django-5.2.9-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/91/be/317c2c55b8bbec407257d45f5c8d1b6867abc76d12043f2d3d58c538a4ea/asgiref-3.11.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/25/70/001ee337f7aa888fb2e3f5fd7592a6afc5283adb1ed44ce8df5764070f22/sqlparse-0.5.4-py3-none-any.whl"}}]} +{"name": "django", "version": "6.0", "dependencies": [{"download_info": {"url": "https://files.pythonhosted.org/packages/d7/ae/f19e24789a5ad852670d6885f5480f5e5895576945fcc01817dfd9bc002a/django-6.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/91/be/317c2c55b8bbec407257d45f5c8d1b6867abc76d12043f2d3d58c538a4ea/asgiref-3.11.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/25/70/001ee337f7aa888fb2e3f5fd7592a6afc5283adb1ed44ce8df5764070f22/sqlparse-0.5.4-py3-none-any.whl"}}]} +{"name": "dramatiq", "version": "2.0.0", "dependencies": [{"download_info": {"url": "https://files.pythonhosted.org/packages/38/4b/4a538e5c324d5d2f788f437531419c7331c7f958591c7b6075b5ce931520/dramatiq-2.0.0-py3-none-any.whl"}}]} +{"name": "fastapi", "version": "0.125.0", "dependencies": [{"download_info": {"url": "https://files.pythonhosted.org/packages/34/2f/ff2fcc98f500713368d8b650e1bbc4a0b3ebcdd3e050dcdaad5f5a13fd7e/fastapi-0.125.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl"}}]} +{"name": "fastmcp", "version": "0.1.0", "dependencies": [{"download_info": {"url": "https://files.pythonhosted.org/packages/f5/07/bc69e65b45d638822190bce0defb497a50d240291b8467cb79078d0064b7/fastmcp-0.1.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/9f/9e/26e1d2d2c6afe15dfba5ca6799eeeea7656dce625c22766e4c57305e9cc2/mcp-1.23.1-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/23/a0/984525d19ca5c8a6c33911a0c164b11490dd0f90ff7fd689f704f84e9a11/sse_starlette-3.0.3-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/78/64/7713ffe4b5983314e9d436a90d5bd4f63b6054e2aca783a3cfc44cb95bbf/typer-0.20.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl"}}]} +{"name": "fastmcp", "version": "0.4.1", "dependencies": [{"download_info": {"url": "https://files.pythonhosted.org/packages/79/0b/008a340435fe8f0879e9d608f48af2737ad48440e09bd33b83b3fd03798b/fastmcp-0.4.1-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/9f/9e/26e1d2d2c6afe15dfba5ca6799eeeea7656dce625c22766e4c57305e9cc2/mcp-1.23.1-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/23/a0/984525d19ca5c8a6c33911a0c164b11490dd0f90ff7fd689f704f84e9a11/sse_starlette-3.0.3-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/78/64/7713ffe4b5983314e9d436a90d5bd4f63b6054e2aca783a3cfc44cb95bbf/typer-0.20.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl"}}]} +{"name": "fastmcp", "version": "1.0", "dependencies": [{"download_info": {"url": "https://files.pythonhosted.org/packages/b9/bf/0a77688242f30f81e3633d3765289966d9c7e408f9dcb4928a85852b9fde/fastmcp-1.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/9f/9e/26e1d2d2c6afe15dfba5ca6799eeeea7656dce625c22766e4c57305e9cc2/mcp-1.23.1-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/23/a0/984525d19ca5c8a6c33911a0c164b11490dd0f90ff7fd689f704f84e9a11/sse_starlette-3.0.3-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/78/64/7713ffe4b5983314e9d436a90d5bd4f63b6054e2aca783a3cfc44cb95bbf/typer-0.20.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl"}}]} +{"name": "flask", "version": "2.3.3", "dependencies": [{"download_info": {"url": "https://files.pythonhosted.org/packages/fd/56/26f0be8adc2b4257df20c1c4260ddd0aa396cf8e75d90ab2f7ff99bc34f9/flask-2.3.3-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/2f/f9/9e082990c2585c744734f85bec79b5dae5df9c974ffee58fe421652c8e91/werkzeug-3.1.4-py3-none-any.whl"}}]} +{"name": "flask", "version": "3.1.2", "dependencies": [{"download_info": {"url": "https://files.pythonhosted.org/packages/ec/f9/7f9263c5695f4bd0023734af91bedb2ff8209e8de6ead162f35d8dc762fd/flask-3.1.2-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/2f/f9/9e082990c2585c744734f85bec79b5dae5df9c974ffee58fe421652c8e91/werkzeug-3.1.4-py3-none-any.whl"}}]} +{"name": "google-genai", "version": "1.47.0", "dependencies": [{"download_info": {"url": "https://files.pythonhosted.org/packages/89/ef/e080e8d67c270ea320956bb911a9359664fc46d3b87d1f029decd33e5c4c/google_genai-1.47.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/c6/97/451d55e05487a5cd6279a01a7e34921858b16f7dc8aa38a2c684743cd2b3/google_auth-2.45.0-py2.py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/2c/fc/1d7b80d0eb7b714984ce40efc78859c022cd930e402f599d8ca9e39c78a4/cachetools-6.2.4-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/6d/b9/4095b668ea3678bf6a0af005527f39de12fb026516fb3df17495a733b7f8/urllib3-2.6.2-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl"}}]} +{"name": "google-genai", "version": "1.56.0", "dependencies": [{"download_info": {"url": "https://files.pythonhosted.org/packages/84/93/94bc7a89ef4e7ed3666add55cd859d1483a22737251df659bf1aa46e9405/google_genai-1.56.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/c6/97/451d55e05487a5cd6279a01a7e34921858b16f7dc8aa38a2c684743cd2b3/google_auth-2.45.0-py2.py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/2c/fc/1d7b80d0eb7b714984ce40efc78859c022cd930e402f599d8ca9e39c78a4/cachetools-6.2.4-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/6d/b9/4095b668ea3678bf6a0af005527f39de12fb026516fb3df17495a733b7f8/urllib3-2.6.2-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl"}}]} +{"name": "huey", "version": "2.5.5", "dependencies": [{"download_info": {"url": "https://files.pythonhosted.org/packages/de/c2/0543039071259cfdab525757022de8dad6d22c15a0e7352f1a50a1444a13/huey-2.5.5-py3-none-any.whl"}}]} +{"name": "huggingface_hub", "version": "1.2.3", "dependencies": [{"download_info": {"url": "https://files.pythonhosted.org/packages/df/8d/7ca723a884d55751b70479b8710f06a317296b1fa1c1dec01d0420d13e43/huggingface_hub-1.2.3-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/6e/1d/a641a88b69994f9371bd347f1dd35e5d1e2e2460a2e350c8d5165fc62005/hf_xet-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/51/c7/b64cae5dba3a1b138d7123ec36bb5ccd39d39939f18454407e5468f4763f/fsspec-2025.12.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/e3/7f/a1a97644e39e7316d850784c642093c99df1290a460df4ede27659056834/filelock-3.20.1-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/5e/dd/5cbf31f402f1cc0ab087c94d4669cfa55bd1e818688b910631e131d74e75/typer_slim-0.20.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl"}}]} +{"name": "langchain", "version": "1.2.0", "dependencies": [{"download_info": {"url": "https://files.pythonhosted.org/packages/23/00/4e3fa0d90f5a5c376ccb8ca983d0f0f7287783dfac48702e18f01d24673b/langchain-1.2.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/cc/95/98c47dbb4b6098934ff70e0f52efef3a85505dbcccc9eb63587e21fde4c9/langchain_core-1.2.1-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/73/07/02e16ed01e04a374e644b575638ec7987ae846d25ad97bcc9945a3ee4b0e/jsonpatch-1.33-py2.py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/23/1b/e318ee76e42d28f515d87356ac5bd7a7acc8bad3b8f54ee377bef62e1cbf/langgraph-1.0.5-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/48/e3/616e3a7ff737d98c1bbb5700dd62278914e2a9ded09a79a1fa93cf24ce12/langgraph_checkpoint-3.0.1-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/87/5e/aeba4a5b39fe6e874e0dd003a82da71c7153e671312671a8dacc5cb7c1af/langgraph_prebuilt-1.0.5-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/69/48/ee4d7afb3c3d38bd2ebe51a4d37f1ed7f1058dd242f35994b562203067aa/langgraph_sdk-0.3.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/63/54/4577ef9424debea2fa08af338489d593276520d2e2f8950575d292be612c/langsmith-0.4.59-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/0b/0e/512fb221e4970c2f75ca9dae412d320b7d9ddc9f2b15e04ea8e44710396c/uuid_utils-0.12.0.tar.gz"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/71/92/5e77f98553e9e75130c78900d000368476aed74276eb8ae8796f65f00918/jsonpointer-3.0.0-py2.py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/04/b8/333fdb27840f3bf04022d21b654a35f58e15407183aeb16f3b41aa053446/orjson-3.11.5.tar.gz"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/38/b3/ef4494438c90359e1547eaed3c5ec46e2c431d59a3de2af4e70ebd594c49/ormsgpack-1.12.1-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/6d/b9/4095b668ea3678bf6a0af005527f39de12fb026516fb3df17495a733b7f8/urllib3-2.6.2-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/55/f4/2a7c3c68e564a099becfa44bb3d398810cc0ff6749b0d3cb8ccb93f23c14/xxhash-3.6.0-cp314-cp314t-macosx_11_0_arm64.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl"}}]} +{"name": "langgraph", "version": "0.6.11", "dependencies": [{"download_info": {"url": "https://files.pythonhosted.org/packages/df/94/430f0341c5c2fe3e3b9f5ab2622f35e2bda12c4a7d655c519468e853d1b0/langgraph-0.6.11-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/48/e3/616e3a7ff737d98c1bbb5700dd62278914e2a9ded09a79a1fa93cf24ce12/langgraph_checkpoint-3.0.1-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/8e/d1/e4727f4822943befc3b7046f79049b1086c9493a34b4d44a1adf78577693/langgraph_prebuilt-0.6.5-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/d8/2f/5c97b3fc799730179f2061cca633c0dc03d9e74f0372a783d4d2be924110/langgraph_sdk-0.2.12-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/71/1e/e129fc471a2d2a7b3804480a937b5ab9319cab9f4142624fcb115f925501/langchain_core-1.1.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/73/07/02e16ed01e04a374e644b575638ec7987ae846d25ad97bcc9945a3ee4b0e/jsonpatch-1.33-py2.py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/fc/48/37cc533e2d16e4ec1d01f30b41933c9319af18389ea0f6866835ace7d331/langsmith-0.4.53-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/0b/0e/512fb221e4970c2f75ca9dae412d320b7d9ddc9f2b15e04ea8e44710396c/uuid_utils-0.12.0.tar.gz"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/71/92/5e77f98553e9e75130c78900d000368476aed74276eb8ae8796f65f00918/jsonpointer-3.0.0-py2.py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/c6/fe/ed708782d6709cc60eb4c2d8a361a440661f74134675c72990f2c48c785f/orjson-3.11.4.tar.gz"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/b3/cf/5d58d9b132128d2fe5d586355dde76af386554abef00d608f66b913bff1f/ormsgpack-1.12.0-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/55/f4/2a7c3c68e564a099becfa44bb3d398810cc0ff6749b0d3cb8ccb93f23c14/xxhash-3.6.0-cp314-cp314t-macosx_11_0_arm64.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl"}}]} +{"name": "launchdarkly-server-sdk", "version": "9.14.1", "dependencies": [{"download_info": {"url": "https://files.pythonhosted.org/packages/3e/64/3b0ca36edef5f795e9367ce727a8b761697e7306030f4105b29796ec9fd5/launchdarkly_server_sdk-9.14.1-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/e6/94/a46d76ff5738fb9a842c27a1f95fbdae8397621596bdfc5c582079958567/launchdarkly_eventsource-1.5.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/6d/b9/4095b668ea3678bf6a0af005527f39de12fb026516fb3df17495a733b7f8/urllib3-2.6.2-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/cb/84/a04c59324445f4bcc98dc05b39a1cd07c242dde643c1a3c21e4f7beaf2f2/expiringdict-1.2.2-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/34/90/0200184d2124484f918054751ef997ed6409cb05b7e8dcbf5a22da4c4748/pyrfc3339-2.1.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/a6/24/4d91e05817e92e3a61c8a21e08fd0f390f5301f1c448b137c57c4bc6e543/semver-3.0.4-py3-none-any.whl"}}]} +{"name": "openai", "version": "2.14.0", "dependencies": [{"download_info": {"url": "https://files.pythonhosted.org/packages/27/4b/7c1a00c2c3fbd004253937f7520f692a9650767aa73894d7a34f0d65d3f4/openai-2.14.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/30/61/12ed8ee7a643cce29ac97c2281f9ce3956eb76b037e88d290f4ed0d41480/jiter-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl"}}]} +{"name": "openai-agents", "version": "0.6.4", "dependencies": [{"download_info": {"url": "https://files.pythonhosted.org/packages/2b/ed/f2282debf62c52241959b111d2e35105b43b2ea6ff060833734405bb4d0f/openai_agents-0.6.4-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/9c/83/3b1d03d36f224edded98e9affd0467630fc09d766c0e56fb1498cbb04a9b/griffe-1.15.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/61/0d/5cf14e177c8ae655a2fd9324a6ef657ca4cafd3fc2201c87716055e29641/mcp-1.24.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/27/4b/7c1a00c2c3fbd004253937f7520f692a9650767aa73894d7a34f0d65d3f4/openai-2.14.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/30/61/12ed8ee7a643cce29ac97c2281f9ce3956eb76b037e88d290f4ed0d41480/jiter-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/2a/20/9a227ea57c1285986c4cf78400d0a91615d25b24e257fd9e2969606bdfae/types_requests-2.32.4.20250913-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/6d/b9/4095b668ea3678bf6a0af005527f39de12fb026516fb3df17495a733b7f8/urllib3-2.6.2-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/aa/76/03af049af4dcee5d27442f71b6924f01f3efb5d2bd34f23fcd563f2cc5f5/python_multipart-0.0.21-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/71/22/8ab1066358601163e1ac732837adba3672f703818f693e179b24e0d3b65c/sse_starlette-3.0.4-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl"}}]} +{"name": "openfeature-sdk", "version": "0.7.5", "dependencies": [{"download_info": {"url": "https://files.pythonhosted.org/packages/9b/20/cb043f54b11505d993e4dd84652cfc44c1260dc94b7f41aa35489af58277/openfeature_sdk-0.7.5-py3-none-any.whl"}}]} +{"name": "openfeature-sdk", "version": "0.8.4", "dependencies": [{"download_info": {"url": "https://files.pythonhosted.org/packages/9c/80/f6532778188c573cc83790b11abccde717d4c1442514e722d6bb6140e55c/openfeature_sdk-0.8.4-py3-none-any.whl"}}]} +{"name": "quart", "version": "0.20.0", "dependencies": [{"download_info": {"url": "https://files.pythonhosted.org/packages/7e/e9/cc28f21f52913adf333f653b9e0a3bf9cb223f5083a26422968ba73edd8d/quart-0.20.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/ec/f9/7f9263c5695f4bd0023734af91bedb2ff8209e8de6ead162f35d8dc762fd/flask-3.1.2-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/93/35/850277d1b17b206bd10874c8a9a3f52e059452fb49bb0d22cbb908f6038b/hypercorn-0.18.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/2f/f9/9e082990c2585c744734f85bec79b5dae5df9c974ffee58fe421652c8e91/werkzeug-3.1.4-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/5e/5f/82c8074f7e84978129347c2c6ec8b6c59f3584ff1a20bc3c940a3e061790/priority-2.0.0-py3-none-any.whl"}}]} +{"name": "redis", "version": "7.1.0", "dependencies": [{"download_info": {"url": "https://files.pythonhosted.org/packages/89/f0/8956f8a86b20d7bb9d6ac0187cf4cd54d8065bc9a1a09eb8011d4d326596/redis-7.1.0-py3-none-any.whl"}}]} +{"name": "requests", "version": "2.32.5", "dependencies": [{"download_info": {"url": "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl"}}]} +{"name": "starlette", "version": "0.50.0", "dependencies": [{"download_info": {"url": "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl"}}]} +{"name": "statsig", "version": "0.55.3", "dependencies": [{"download_info": {"url": "https://files.pythonhosted.org/packages/9d/17/de62fdea8aab8aa7c4a833378e0e39054b728dfd45ef279e975ed5ef4e86/statsig-0.55.3-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/b6/e0/318c1ce3ae5a17894d5791e87aea147587c9e702f24122cc7a5c8bbaeeb1/grpcio-1.76.0.tar.gz"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/2a/97/e88295f9456ba939d90d4603af28fcabda3b443ef55e709e9381df3daa58/ijson-3.4.0.post0-cp314-cp314t-macosx_11_0_arm64.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/d6/b7/48a7f1ab9eee62f1113114207df7e7e6bc29227389d554f42cc11bc98108/ip3country-0.4.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/08/b4/46310463b4f6ceef310f8348786f3cff181cea671578e3d9743ba61a459e/protobuf-6.33.1-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/94/37/be6dfbfa45719aa82c008fb4772cfe5c46db765a2ca4b6f524a1fdfee4d7/ua_parser-1.0.1-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/6f/d3/13adff37f15489c784cc7669c35a6c3bf94b87540229eedf52ef2a1d0175/ua_parser_builtins-0.18.0.post1-py3-none-any.whl"}}]} +{"name": "statsig", "version": "0.66.2", "dependencies": [{"download_info": {"url": "https://files.pythonhosted.org/packages/0d/e6/788773678cef0f84758c648a3e085c57f953dc1a6f1d55f37e6844fcb655/statsig-0.66.2-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/b6/e0/318c1ce3ae5a17894d5791e87aea147587c9e702f24122cc7a5c8bbaeeb1/grpcio-1.76.0.tar.gz"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/2a/97/e88295f9456ba939d90d4603af28fcabda3b443ef55e709e9381df3daa58/ijson-3.4.0.post0-cp314-cp314t-macosx_11_0_arm64.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/d6/b7/48a7f1ab9eee62f1113114207df7e7e6bc29227389d554f42cc11bc98108/ip3country-0.4.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/0e/15/4f02896cc3df04fc465010a4c6a0cd89810f54617a32a70ef531ed75d61c/protobuf-6.33.2-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/6d/b9/4095b668ea3678bf6a0af005527f39de12fb026516fb3df17495a733b7f8/urllib3-2.6.2-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/94/37/be6dfbfa45719aa82c008fb4772cfe5c46db765a2ca4b6f524a1fdfee4d7/ua_parser-1.0.1-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/6f/d3/13adff37f15489c784cc7669c35a6c3bf94b87540229eedf52ef2a1d0175/ua_parser_builtins-0.18.0.post1-py3-none-any.whl"}}]} +{"name": "strawberry-graphql", "version": "0.287.3", "dependencies": [{"download_info": {"url": "https://files.pythonhosted.org/packages/7c/8e/ffd20e179cc8218465599922660323f453c7b955aca2b909e5b86ba61eb0/strawberry_graphql-0.287.3-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/0a/14/933037032608787fb92e365883ad6a741c235e0ff992865ec5d904a38f1e/graphql_core-3.2.7-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/00/f2/c68a97c727c795119f1056ad2b7e716c23f26f004292517c435accf90b5c/lia_web-0.2.3-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl"}}]} +{"name": "typer", "version": "0.20.0", "dependencies": [{"download_info": {"url": "https://files.pythonhosted.org/packages/78/64/7713ffe4b5983314e9d436a90d5bd4f63b6054e2aca783a3cfc44cb95bbf/typer-0.20.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl"}}, {"download_info": {"url": "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl"}}]} diff --git a/scripts/populate_tox/populate_tox.py b/scripts/populate_tox/populate_tox.py new file mode 100644 index 0000000000..ce0983ad50 --- /dev/null +++ b/scripts/populate_tox/populate_tox.py @@ -0,0 +1,1080 @@ +""" +This script populates tox.ini automatically using release data from PyPI. + +See scripts/populate_tox/README.md for more info. +""" + +import functools +import hashlib +import json +import os +import subprocess +import sys +import time +from dataclasses import dataclass +from bisect import bisect_left +from collections import defaultdict +from datetime import datetime, timedelta, timezone # noqa: F401 +from importlib.metadata import PackageMetadata, distributions +from packaging.specifiers import SpecifierSet +from packaging.version import Version +from pathlib import Path +from textwrap import dedent +from typing import Optional, Union + +# Adding the scripts directory to PATH. This is necessary in order to be able +# to import stuff from the split_tox_gh_actions script +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +import requests +from jinja2 import Environment, FileSystemLoader +from sentry_sdk.integrations import _MIN_VERSIONS + +from config import TEST_SUITE_CONFIG +from split_tox_gh_actions.split_tox_gh_actions import GROUPS + + +# Set CUTOFF this to a datetime to ignore packages older than CUTOFF +CUTOFF = None +# CUTOFF = datetime.now(tz=timezone.utc) - timedelta(days=365 * 5) + +TOX_FILE = Path(__file__).resolve().parent.parent.parent / "tox.ini" +RELEASES_CACHE_FILE = Path(__file__).resolve().parent / "releases.jsonl" +DEPENDENCIES_CACHE_FILE = Path(__file__).resolve().parent / "package_dependencies.jsonl" +ENV = Environment( + loader=FileSystemLoader(Path(__file__).resolve().parent), + trim_blocks=True, + lstrip_blocks=True, +) + +PYPI_COOLDOWN = 0.05 # seconds to wait between requests to PyPI + +PYPI_PROJECT_URL = "https://pypi.python.org/pypi/{project}/json" +PYPI_VERSION_URL = "https://pypi.python.org/pypi/{project}/{version}/json" +CLASSIFIER_PREFIX = "Programming Language :: Python :: " + +CACHE = defaultdict(dict) +DEPENDENCIES_CACHE = defaultdict(dict) + +IGNORE = { + # Do not try auto-generating the tox entries for these. They will be + # hardcoded in tox.ini since they don't fit the toxgen usecase (there is no + # one package that should be tested in different versions). + "asgi", + "aws_lambda", + "cloud_resource_context", + "common", + "integration_deactivation", + "shadowed_module", + "gcp", + "gevent", + "opentelemetry", + "otlp", + "potel", +} + +# Free-threading is experimentally supported in 3.13, and officially supported in 3.14. +MIN_FREE_THREADING_SUPPORT = Version("3.14") + + +@dataclass(order=True) +class ThreadedVersion: + version: Version + no_gil: bool + + def __init__(self, version: str | Version, no_gil=False): + self.version = Version(version) if isinstance(version, str) else version + self.no_gil = no_gil + + def __str__(self): + version = f"py{self.version.major}.{self.version.minor}" + if self.no_gil: + version += "t" + + return version + + +def _fetch_sdk_metadata() -> PackageMetadata: + (dist,) = distributions( + name="sentry-sdk", path=[Path(__file__).parent.parent.parent] + ) + return dist.metadata + + +def fetch_url(url: str) -> Optional[dict]: + for attempt in range(3): + pypi_data = requests.get(url) + + if pypi_data.status_code == 200: + return pypi_data.json() + + backoff = PYPI_COOLDOWN * 2**attempt + print( + f"{url} returned an error: {pypi_data.status_code}. Attempt {attempt + 1}/3. Waiting {backoff}s" + ) + time.sleep(backoff) + + return None + + +@functools.cache +def fetch_package(package: str) -> Optional[dict]: + """Fetch package metadata from PyPI.""" + url = PYPI_PROJECT_URL.format(project=package) + return fetch_url(url) + + +@functools.cache +def fetch_release(package: str, version: Version) -> Optional[dict]: + """Fetch release metadata from cache or, failing that, PyPI.""" + release = _fetch_from_cache(package, version) + if release is not None: + return release + + url = PYPI_VERSION_URL.format(project=package, version=version) + release = fetch_url(url) + if release is not None: + _save_to_cache(package, version, release) + return release + + +@functools.cache +def fetch_package_dependencies(package: str, version: Version) -> dict: + """Fetch package dependencies metadata from cache or, failing that, PyPI.""" + package_dependencies = _fetch_package_dependencies_from_cache(package, version) + if package_dependencies is not None: + return package_dependencies + + # Removing non-report output with -qqq may be brittle, but avoids file I/O. + # Currently -qqq supresses all non-report output that would break json.loads(). + pip_report = subprocess.run( + [ + sys.executable, + "-m", + "pip", + "install", + f"{package}=={str(version)}", + "--dry-run", + "--ignore-installed", + "--report", + "-", + "-qqq", + ], + capture_output=True, + text=True, + ).stdout.strip() + + dependencies_info = json.loads(pip_report)["install"] + _save_to_package_dependencies_cache(package, version, dependencies_info) + + return dependencies_info + + +def _fetch_from_cache(package: str, version: Version) -> Optional[dict]: + package = _normalize_name(package) + if package in CACHE and str(version) in CACHE[package]: + CACHE[package][str(version)]["_accessed"] = True + return CACHE[package][str(version)] + + return None + + +def _fetch_package_dependencies_from_cache( + package: str, version: Version +) -> Optional[dict]: + package = _normalize_name(package) + if package in DEPENDENCIES_CACHE and str(version) in DEPENDENCIES_CACHE[package]: + DEPENDENCIES_CACHE[package][str(version)]["_accessed"] = True + return DEPENDENCIES_CACHE[package][str(version)]["dependencies"] + + return None + + +def _save_to_cache(package: str, version: Version, release: Optional[dict]) -> None: + with open(RELEASES_CACHE_FILE, "a") as releases_cache: + releases_cache.write(json.dumps(_normalize_release(release)) + "\n") + + CACHE[_normalize_name(package)][str(version)] = release + CACHE[_normalize_name(package)][str(version)]["_accessed"] = True + + +def _save_to_package_dependencies_cache( + package: str, version: Version, release: Optional[dict] +) -> None: + with open(DEPENDENCIES_CACHE_FILE, "a") as releases_cache: + line = { + "name": package, + "version": str(version), + "dependencies": _normalize_package_dependencies(release), + } + releases_cache.write(json.dumps(line) + "\n") + + DEPENDENCIES_CACHE[_normalize_name(package)][str(version)] = { + "info": release, + "_accessed": True, + } + + +def _prefilter_releases( + integration: str, + releases: dict[str, dict], +) -> tuple[list[Version], Optional[Version]]: + """ + Filter `releases`, removing releases that are for sure unsupported. + + This function doesn't guarantee that all releases it returns are supported -- + there are further criteria that will be checked later in the pipeline because + they require additional API calls to be made. The purpose of this function is + to slim down the list so that we don't have to make more API calls than + necessary for releases that are for sure not supported. + + The function returns a tuple with: + - the list of prefiltered releases + - an optional prerelease if there is one that should be tested + """ + integration_name = ( + TEST_SUITE_CONFIG[integration].get("integration_name") or integration + ) + + min_supported = _MIN_VERSIONS.get(integration_name) + if min_supported is not None: + min_supported = Version(".".join(map(str, min_supported))) + else: + print( + f" {integration} doesn't have a minimum version defined in " + f"sentry_sdk/integrations/__init__.py. Consider defining one" + ) + + include_versions = None + if TEST_SUITE_CONFIG[integration].get("include") is not None: + include_versions = SpecifierSet( + TEST_SUITE_CONFIG[integration]["include"], prereleases=True + ) + + filtered_releases = [] + last_prerelease = None + + for release, data in releases.items(): + if not data: + continue + + meta = data[0] + + if meta["yanked"]: + continue + + uploaded = datetime.fromisoformat(meta["upload_time_iso_8601"]) + + if CUTOFF is not None and uploaded < CUTOFF: + continue + + version = Version(release) + + if min_supported and version < min_supported: + continue + + if version.is_postrelease or version.is_devrelease: + continue + + if include_versions is not None and version not in include_versions: + continue + + if version.is_prerelease: + if last_prerelease is None or version > last_prerelease: + last_prerelease = version + continue + + for i, saved_version in enumerate(filtered_releases): + if ( + version.major == saved_version.major + and version.minor == saved_version.minor + ): + # Don't save all patch versions of a release, just the newest one + if version.micro > saved_version.micro: + filtered_releases[i] = version + break + else: + filtered_releases.append(version) + + filtered_releases.sort() + + # Check if the latest prerelease is relevant (i.e., it's for a version higher + # than the last released version); if not, don't consider it + if last_prerelease is not None: + if not filtered_releases or last_prerelease > filtered_releases[-1]: + return filtered_releases, last_prerelease + + return filtered_releases, None + + +def get_supported_releases( + integration: str, pypi_data: dict +) -> tuple[list[Version], Optional[Version]]: + """ + Get a list of releases that are currently supported by the SDK. + + This takes into account a handful of parameters (Python support, the lowest + supported version we've defined for the framework, optionally the date + of the release). + + We return the list of supported releases and optionally also the newest + prerelease, if it should be tested (meaning it's for a version higher than + the current stable version). + """ + package = pypi_data["info"]["name"] + + # Get a consolidated list without taking into account Python support yet + # (because that might require an additional API call for some + # of the releases) + releases, latest_prerelease = _prefilter_releases( + integration, + pypi_data["releases"], + ) + + def _supports_lowest(release: Version) -> bool: + time.sleep(PYPI_COOLDOWN) # don't DoS PYPI + + pypi_data = fetch_release(package, release) + if pypi_data is None: + print("Failed to fetch necessary data from PyPI. Aborting.") + sys.exit(1) + + py_versions = determine_python_versions(pypi_data) + target_python_versions = _transform_target_python_versions( + TEST_SUITE_CONFIG[integration].get("python") + ) + return bool(supported_python_versions(py_versions, target_python_versions)) + + if not _supports_lowest(releases[0]): + i = bisect_left(releases, True, key=_supports_lowest) + if i != len(releases) and _supports_lowest(releases[i]): + # we found the lowest version that supports at least some Python + # version(s) that we do, cut off the rest + releases = releases[i:] + + return releases, latest_prerelease + + +def pick_releases_to_test( + integration: str, releases: list[Version], last_prerelease: Optional[Version] +) -> list[Version]: + """Pick a handful of releases to test from a sorted list of supported releases.""" + # If the package has majors (or major-like releases, even if they don't do + # semver), we want to make sure we're testing them all. If it doesn't have + # multiple majors, we just pick the oldest, the newest, and a couple of + # releases in between. + # + # If there is a relevant prerelease, also test that in addition to the above. + num_versions = TEST_SUITE_CONFIG[integration].get("num_versions") + if num_versions is not None and ( + not isinstance(num_versions, int) or num_versions < 2 + ): + print(" Integration has invalid `num_versions`: must be an int >= 2") + num_versions = None + + has_majors = len({v.major for v in releases}) > 1 + filtered_releases = set() + + if has_majors: + # Always check the very first supported release + filtered_releases.add(releases[0]) + + # Find out the max release by each major + releases_by_major = {} + for release in releases: + if ( + release.major not in releases_by_major + or release > releases_by_major[release.major] + ): + releases_by_major[release.major] = release + + # Add the highest release in each major + for max_version in releases_by_major.values(): + filtered_releases.add(max_version) + + # If num_versions was provided, slim down the selection + if num_versions is not None: + filtered_releases = _pick_releases(sorted(filtered_releases), num_versions) + + else: + filtered_releases = _pick_releases(releases, num_versions) + + filtered_releases = sorted(filtered_releases) + if last_prerelease is not None: + filtered_releases.append(last_prerelease) + + return filtered_releases + + +def _pick_releases( + releases: list[Version], num_versions: Optional[int] +) -> set[Version]: + num_versions = num_versions or 4 + + versions = { + releases[0], # oldest version supported + releases[-1], # latest + } + + for i in range(1, num_versions - 1): + try: + versions.add(releases[len(releases) // (num_versions - 1) * i]) + except IndexError: + pass + + return versions + + +def supported_python_versions( + package_python_versions: Union[SpecifierSet, list[Version]], + custom_supported_versions: Optional[ + Union[SpecifierSet, dict[SpecifierSet, SpecifierSet]] + ] = None, + release_version: Optional[Version] = None, +) -> list[Version]: + """ + Get the intersection of Python versions supported by the package and the SDK. + + Optionally, if `custom_supported_versions` is provided, the function will + return the intersection of Python versions supported by the package, the SDK, + and `custom_supported_versions`. This is used when a test suite definition + in `TEST_SUITE_CONFIG` contains a range of Python versions to run the tests + on. + + Examples: + - The Python SDK supports Python 3.6-3.13. The package supports 3.5-3.8. This + function will return [3.6, 3.7, 3.8] as the Python versions supported + by both. + - The Python SDK supports Python 3.6-3.13. The package supports 3.5-3.8. We + have an additional test limitation in place to only test this framework + on Python 3.7, so we can provide this as `custom_supported_versions`. The + result of this function will then by the intersection of all three, i.e., + [3.7]. + - The Python SDK supports Python 3.6-3.13. The package supports 3.5-3.8. + Additionally, we have a limitation in place to only test this framework on + Python 3.5 if the framework version is <2.0. `custom_supported_versions` + will contain this restriction, and `release_version` will contain the + version of the package we're currently looking at, to determine whether the + <2.0 restriction applies in this case. + """ + supported = [] + + # Iterate through Python versions from MIN_PYTHON_VERSION to MAX_PYTHON_VERSION + curr = MIN_PYTHON_VERSION + while curr <= MAX_PYTHON_VERSION: + if curr in package_python_versions: + if not custom_supported_versions: + supported.append(curr) + + else: + if isinstance(custom_supported_versions, SpecifierSet): + if curr in custom_supported_versions: + supported.append(curr) + + elif release_version is not None and isinstance( + custom_supported_versions, dict + ): + for v, py in custom_supported_versions.items(): + if release_version in v: + if curr in py: + supported.append(curr) + break + else: + supported.append(curr) + + # Construct the next Python version (i.e., bump the minor) + next = [int(v) for v in str(curr).split(".")] + next[1] += 1 + curr = Version(".".join(map(str, next))) + + return supported + + +def pick_python_versions_to_test( + python_versions: list[Version], + python_versions_with_supported_free_threaded_wheel: set[Version], +) -> list[ThreadedVersion]: + """ + Given a list of Python versions, pick those that make sense to test on. + + Currently, this is the oldest, the newest, and the second newest Python + version. + + A free-threaded variant is also chosen for the newest Python version for which + - a free-threaded wheel is distributed; and + - the SDK supports free-threading. + """ + filtered_python_versions = { + python_versions[0], + } + + filtered_python_versions.add(python_versions[-1]) + try: + filtered_python_versions.add(python_versions[-2]) + except IndexError: + pass + + versions_to_test = sorted( + ThreadedVersion(version) for version in filtered_python_versions + ) + + for python_version in reversed(python_versions): + if python_version < MIN_FREE_THREADING_SUPPORT: + break + + if python_version in python_versions_with_supported_free_threaded_wheel: + versions_to_test.append( + ThreadedVersion(versions_to_test[-1].version, no_gil=True) + ) + break + + return versions_to_test + + +def _parse_python_versions_from_classifiers(classifiers: list[str]) -> list[Version]: + python_versions = [] + for classifier in classifiers: + if classifier.startswith(CLASSIFIER_PREFIX): + python_version = classifier[len(CLASSIFIER_PREFIX) :] + if "." in python_version: + # We don't care about stuff like + # Programming Language :: Python :: 3 :: Only, + # Programming Language :: Python :: 3, + # etc., we're only interested in specific versions, like 3.13 + python_versions.append(Version(python_version)) + + if python_versions: + python_versions.sort() + return python_versions + + +def determine_python_versions(pypi_data: dict) -> Union[SpecifierSet, list[Version]]: + """ + Determine the Python versions supported by the package from PyPI data. + + We're looking at Python version classifiers, if present, and + `requires_python` if there are no classifiers. + """ + try: + classifiers = pypi_data["info"]["classifiers"] + except (AttributeError, KeyError): + # This function assumes `pypi_data` contains classifiers. This is the case + # for the most recent release in the /{project} endpoint or for any release + # fetched via the /{project}/{version} endpoint. + return [] + + # Try parsing classifiers + python_versions = _parse_python_versions_from_classifiers(classifiers) + if python_versions: + return python_versions + + # We only use `requires_python` if there are no classifiers. This is because + # `requires_python` doesn't tell us anything about the upper bound, which + # implicitly depends on when the release first came out. + try: + requires_python = pypi_data["info"]["requires_python"] + except (AttributeError, KeyError): + pass + + if requires_python: + return SpecifierSet(requires_python) + + return [] + + +def _get_abi_tag(wheel_filename: str) -> str: + return wheel_filename.removesuffix(".whl").split("-")[-2] + + +def _get_abi_tag_version(python_version: Version): + return f"{python_version.major}{python_version.minor}" + + +@functools.cache +def _has_free_threading_dependencies( + package_name: str, release: Version, python_version: Version +) -> bool: + """ + Checks if all dependencies of a version of a package support free-threading. + + A dependency supports free-threading if + - the dependency is pure Python, indicated by a "none" abi tag in its wheel name; or + - the abi tag of one of its wheels has a "t" suffix to indicate a free-threaded build; or + - no wheel targets the platform on which the script is run, but PyPI distributes a wheel + satisfying one of the above conditions. + """ + dependencies_info = fetch_package_dependencies(package_name, release) + + for dependency_info in dependencies_info: + wheel_filename = dependency_info["download_info"]["url"].split("/")[-1] + + if wheel_filename.endswith(".tar.gz"): + package_release = wheel_filename.rstrip(".tar.gz") + dependency_name, dependency_version = package_release.split("-") + + pypi_data = fetch_release(dependency_name, Version(dependency_version)) + supports_free_threading = False + + for download in pypi_data["urls"]: + abi_tag = _get_abi_tag(download["filename"]) + abi_tag_version = _get_abi_tag_version(python_version) + + if download["packagetype"] == "bdist_wheel" and ( + ( + abi_tag.endswith("t") + and abi_tag.startswith(f"cp{abi_tag_version}") + ) + or abi_tag == "none" + ): + supports_free_threading = True + + if not supports_free_threading: + return False + + elif wheel_filename.endswith(".whl"): + abi_tag = _get_abi_tag(wheel_filename) + if abi_tag != "none" and not abi_tag.endswith("t"): + return False + + else: + raise Exception( + f"Wheel filename with unhandled extension: {wheel_filename}" + ) + + return True + + +def _supports_free_threading( + package_name: str, release: Version, python_version: Version, pypi_data: dict +) -> bool: + """ + Check if the package version supports free-threading on the given Python minor + version. + + There are two cases in which we assume a package has free-threading + support: + - The package is pure Python, indicated by a "none" abi tag in its wheel name, + and has dependencies supporting free-threading; or + - the abi tag of one of its wheels has a "t" suffix to indicate a free-threaded build. + + See https://peps.python.org/pep-0427/#file-name-convention + """ + for download in pypi_data["urls"]: + if download["packagetype"] == "bdist_wheel": + abi_tag = _get_abi_tag(download["filename"]) + + abi_tag_version = _get_abi_tag_version(python_version) + if ( + abi_tag.endswith("t") and abi_tag.startswith(f"cp{abi_tag_version}") + ) or ( + abi_tag == "none" + and _has_free_threading_dependencies( + package_name, release, python_version + ) + ): + return True + + return False + + +def _render_python_versions(python_versions: list[ThreadedVersion]) -> str: + return "{" + ",".join(str(version) for version in python_versions) + "}" + + +def _render_dependencies(integration: str, releases: list[Version]) -> list[str]: + rendered = [] + + if TEST_SUITE_CONFIG[integration].get("deps") is None: + return rendered + + for constraint, deps in TEST_SUITE_CONFIG[integration]["deps"].items(): + if constraint == "*": + for dep in deps: + rendered.append(f"{integration}: {dep}") + elif constraint.startswith("py3"): + for dep in deps: + rendered.append(f"{{{constraint}}}-{integration}: {dep}") + else: + restriction = SpecifierSet(constraint, prereleases=True) + for release in releases: + if release in restriction: + for dep in deps: + rendered.append(f"{integration}-v{release}: {dep}") + + return rendered + + +def write_tox_file(packages: dict) -> None: + template = ENV.get_template("tox.jinja") + + context = { + "groups": {}, + "testpaths": [], + } + + for group, integrations in packages.items(): + context["groups"][group] = [] + for integration in integrations: + context["groups"][group].append( + { + "name": integration["name"], + "package": integration["package"], + "extra": integration["extra"], + "releases": integration["releases"], + "dependencies": _render_dependencies( + integration["name"], integration["releases"] + ), + } + ) + context["testpaths"].append( + ( + integration["name"], + f"tests/integrations/{integration['integration_name']}", + ) + ) + + context["testpaths"].sort() + + rendered = template.render(context) + + with open(TOX_FILE, "w") as file: + file.write(rendered) + file.write("\n") + + +def _get_package_name(integration: str) -> tuple[str, Optional[str]]: + package = TEST_SUITE_CONFIG[integration]["package"] + extra = None + if "[" in package: + extra = package[package.find("[") + 1 : package.find("]")] + package = package[: package.find("[")] + + return package, extra + + +def _compare_min_version_with_defined( + integration: str, releases: list[Version] +) -> None: + defined_min_version = _MIN_VERSIONS.get(integration) + if defined_min_version: + defined_min_version = Version(".".join([str(v) for v in defined_min_version])) + if ( + defined_min_version.major != releases[0].major + or defined_min_version.minor != releases[0].minor + ): + print( + f" Integration defines {defined_min_version} as minimum " + f"version, but the effective minimum version based on metadata " + f"is {releases[0]}." + ) + + +def _add_python_versions_to_release( + integration: str, package: str, release: Version +) -> None: + release_pypi_data = fetch_release(package, release) + if release_pypi_data is None: + print("Failed to fetch necessary data from PyPI. Aborting.") + sys.exit(1) + + time.sleep(PYPI_COOLDOWN) # give PYPI some breathing room + + target_python_versions = _transform_target_python_versions( + TEST_SUITE_CONFIG[integration].get("python") + ) + + supported_py_versions = supported_python_versions( + determine_python_versions(release_pypi_data), + target_python_versions, + release, + ) + + py_versions_with_supported_free_threaded_wheel = set( + version + for version in supported_py_versions + if version >= MIN_FREE_THREADING_SUPPORT + and _supports_free_threading(package, release, version, release_pypi_data) + ) + + release.python_versions = pick_python_versions_to_test( + supported_py_versions, + py_versions_with_supported_free_threaded_wheel, + ) + + release.rendered_python_versions = _render_python_versions(release.python_versions) + + +def _transform_target_python_versions( + python_versions: Union[str, dict[str, str], None], +) -> Union[SpecifierSet, dict[SpecifierSet, SpecifierSet], None]: + """Wrap the contents of the `python` key in SpecifierSets.""" + if not python_versions: + return None + + if isinstance(python_versions, str): + return SpecifierSet(python_versions) + + if isinstance(python_versions, dict): + updated = {} + for key, value in python_versions.items(): + updated[SpecifierSet(key)] = SpecifierSet(value) + return updated + + +def get_file_hash() -> str: + """Calculate a hash of the tox.ini file.""" + hasher = hashlib.md5() + + with open(TOX_FILE, "rb") as f: + buf = f.read() + hasher.update(buf) + + return hasher.hexdigest() + + +def get_last_updated() -> Optional[datetime]: + repo_root = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + ).stdout.strip() + tox_ini_path = Path(repo_root) / "tox.ini" + + timestamp = subprocess.run( + ["git", "log", "-1", "--pretty=%ct", str(tox_ini_path)], + capture_output=True, + text=True, + ).stdout.strip() + + timestamp = datetime.fromtimestamp(int(timestamp), timezone.utc) + print(f"Last committed tox.ini update: {timestamp}") + return timestamp + + +def _normalize_name(package: str) -> str: + return package.lower().replace("-", "_") + + +def _extract_wheel_info_to_cache(wheel: dict): + return { + "packagetype": wheel["packagetype"], + "filename": wheel["filename"], + } + + +def _normalize_release(release: dict) -> dict: + """Filter out unneeded parts of the release JSON.""" + urls = [_extract_wheel_info_to_cache(wheel) for wheel in release["urls"]] + normalized = { + "info": { + "classifiers": release["info"]["classifiers"], + "name": release["info"]["name"], + "requires_python": release["info"]["requires_python"], + "version": release["info"]["version"], + "yanked": release["info"]["yanked"], + }, + "urls": urls, + } + return normalized + + +def _normalize_package_dependencies(package_dependencies: list[dict]) -> list[dict]: + """Filter out unneeded parts of the package dependencies JSON.""" + normalized = [ + { + "download_info": {"url": depedency["download_info"]["url"]}, + } + for depedency in package_dependencies + ] + + return normalized + + +def _exit_if_not_free_threaded_interpreter(): + if "free-threading build" not in sys.version: + exc = Exception("Running with a free-threaded interpreter is required.") + exc.add_note( + "A dry run of pip is used to determine free-threading support of packages." + ) + raise exc + + +def _exit_if_pip_unavailable(): + pip_help_return_code = subprocess.run( + [ + sys.executable, + "-m", + "pip", + "--help", + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ).returncode + + if pip_help_return_code != 0: + exc = Exception("pip must be available.") + exc.add_note( + "A dry run of pip is used to determine free-threading support of packages." + ) + raise exc + + +def main() -> dict[str, list]: + """ + Generate tox.ini from the tox.jinja template. + """ + global MIN_PYTHON_VERSION, MAX_PYTHON_VERSION + + _exit_if_not_free_threaded_interpreter() + _exit_if_pip_unavailable() + + meta = _fetch_sdk_metadata() + sdk_python_versions = _parse_python_versions_from_classifiers( + meta.get_all("Classifier") + ) + MIN_PYTHON_VERSION = sdk_python_versions[0] + MAX_PYTHON_VERSION = sdk_python_versions[-1] + print( + f"The SDK supports Python versions {MIN_PYTHON_VERSION} - {MAX_PYTHON_VERSION}." + ) + + if not RELEASES_CACHE_FILE.exists(): + print( + f"Creating {RELEASES_CACHE_FILE.name}." + "You should only see this message if you cleared the cache by removing the file." + ) + RELEASES_CACHE_FILE.write_text("") + + if not DEPENDENCIES_CACHE_FILE.exists(): + print( + f"Creating {DEPENDENCIES_CACHE_FILE.name}." + "You should only see this message if you cleared the cache by removing the file." + ) + DEPENDENCIES_CACHE_FILE.write_text("") + + # Load file cache + global CACHE + + with open(RELEASES_CACHE_FILE) as releases_cache: + for line in releases_cache: + release = json.loads(line) + name = _normalize_name(release["info"]["name"]) + version = release["info"]["version"] + CACHE[name][version] = release + CACHE[name][version]["_accessed"] = ( + False # for cleaning up unused cache entries + ) + + # Load package dependencies cache + global DEPENDENCIES_CACHE + + with open(DEPENDENCIES_CACHE_FILE) as dependencies_cache: + for line in dependencies_cache: + release = json.loads(line) + name = _normalize_name(release["name"]) + version = release["version"] + DEPENDENCIES_CACHE[name][version] = { + "dependencies": release["dependencies"], + "_accessed": False, + } + + # Process packages + packages = defaultdict(list) + + for group, integrations in GROUPS.items(): + for integration in integrations: + if integration in IGNORE: + continue + + print(f"Processing {integration}...") + + # Figure out the actual main package + package, extra = _get_package_name(integration) + + # Fetch data for the main package + pypi_data = fetch_package(package) + if pypi_data is None: + print("Failed to fetch necessary data from PyPI. Aborting.") + sys.exit(1) + + # Get the list of all supported releases + + releases, latest_prerelease = get_supported_releases(integration, pypi_data) + + if not releases: + print(" Found no supported releases.") + continue + + _compare_min_version_with_defined(integration, releases) + + # Pick a handful of the supported releases to actually test against + # and fetch the PyPI data for each to determine which Python versions + # to test it on + test_releases = pick_releases_to_test( + integration, releases, latest_prerelease + ) + + for release in test_releases: + _add_python_versions_to_release(integration, package, release) + if not release.python_versions: + print(f" Release {release} has no Python versions, skipping.") + + test_releases = [ + release for release in test_releases if release.python_versions + ] + if test_releases: + packages[group].append( + { + "name": integration, + "package": package, + "extra": extra, + "releases": test_releases, + "integration_name": TEST_SUITE_CONFIG[integration].get( + "integration_name" + ) + or integration, + } + ) + + write_tox_file(packages) + + # Sort the release cache file + releases = [] + with open(RELEASES_CACHE_FILE) as releases_cache: + releases = [json.loads(line) for line in releases_cache] + releases.sort(key=lambda r: (r["info"]["name"], r["info"]["version"])) + with open(RELEASES_CACHE_FILE, "w") as releases_cache: + for release in releases: + if ( + CACHE[_normalize_name(release["info"]["name"])][ + release["info"]["version"] + ]["_accessed"] + is True + ): + releases_cache.write(json.dumps(release) + "\n") + + # Sort the dependencies file + releases = [] + with open(DEPENDENCIES_CACHE_FILE) as releases_cache: + releases = [json.loads(line) for line in releases_cache] + releases.sort(key=lambda r: (r["name"], r["version"])) + with open(DEPENDENCIES_CACHE_FILE, "w") as releases_cache: + for release in releases: + if ( + DEPENDENCIES_CACHE[_normalize_name(release["name"])][ + release["version"] + ]["_accessed"] + is True + ): + releases_cache.write(json.dumps(release) + "\n") + + print( + "Done generating tox.ini. Make sure to also update the CI YAML " + "files by executing split_tox_gh_actions.py." + ) + + return packages + + +if __name__ == "__main__": + main() diff --git a/scripts/populate_tox/releases.jsonl b/scripts/populate_tox/releases.jsonl new file mode 100644 index 0000000000..3c6b99e8ff --- /dev/null +++ b/scripts/populate_tox/releases.jsonl @@ -0,0 +1,235 @@ +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Framework :: Django", "Intended Audience :: Developers", "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 2", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: Dynamic Content", "Topic :: Internet :: WWW/HTTP :: WSGI", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "Django", "requires_python": "", "version": "1.10.8", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "Django-1.10.8-py2.py3-none-any.whl"}, {"packagetype": "sdist", "filename": "Django-1.10.8.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Framework :: Django", "Intended Audience :: Developers", "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 2", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: Dynamic Content", "Topic :: Internet :: WWW/HTTP :: WSGI", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "Django", "requires_python": "", "version": "1.11.29", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "Django-1.11.29-py2.py3-none-any.whl"}, {"packagetype": "sdist", "filename": "Django-1.11.29.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Framework :: Django", "Intended Audience :: Developers", "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 2", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.2", "Programming Language :: Python :: 3.3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: Dynamic Content", "Topic :: Internet :: WWW/HTTP :: WSGI", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "Django", "requires_python": "", "version": "1.8.19", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "Django-1.8.19-py2.py3-none-any.whl"}, {"packagetype": "sdist", "filename": "Django-1.8.19.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Framework :: Django", "Intended Audience :: Developers", "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: Dynamic Content", "Topic :: Internet :: WWW/HTTP :: WSGI", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "Django", "requires_python": ">=3.4", "version": "2.0.13", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "Django-2.0.13-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "Django-2.0.13.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Framework :: Django", "Intended Audience :: Developers", "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: Dynamic Content", "Topic :: Internet :: WWW/HTTP :: WSGI", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "Django", "requires_python": ">=3.5", "version": "2.2.28", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "Django-2.2.28-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "Django-2.2.28.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Framework :: Django", "Intended Audience :: Developers", "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: Dynamic Content", "Topic :: Internet :: WWW/HTTP :: WSGI", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "Django", "requires_python": ">=3.6", "version": "3.1.14", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "Django-3.1.14-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "Django-3.1.14.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Framework :: Django", "Intended Audience :: Developers", "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: Dynamic Content", "Topic :: Internet :: WWW/HTTP :: WSGI", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "Django", "requires_python": ">=3.6", "version": "3.2.25", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "Django-3.2.25-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "Django-3.2.25.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Framework :: Django", "Intended Audience :: Developers", "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: Dynamic Content", "Topic :: Internet :: WWW/HTTP :: WSGI", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "Django", "requires_python": ">=3.8", "version": "4.2.27", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "django-4.2.27-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "django-4.2.27.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Framework :: Django", "Intended Audience :: Developers", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: Dynamic Content", "Topic :: Internet :: WWW/HTTP :: WSGI", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "Django", "requires_python": ">=3.10", "version": "5.2.9", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "django-5.2.9-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "django-5.2.9.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Framework :: Django", "Intended Audience :: Developers", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: Dynamic Content", "Topic :: Internet :: WWW/HTTP :: WSGI", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "Django", "requires_python": ">=3.12", "version": "6.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "django-6.0-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "django-6.0.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Framework :: Flask", "Intended Audience :: Developers", "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 2", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Internet :: WWW/HTTP :: Dynamic Content", "Topic :: Internet :: WWW/HTTP :: WSGI :: Application", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "Flask", "requires_python": ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*", "version": "1.1.4", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "Flask-1.1.4-py2.py3-none-any.whl"}, {"packagetype": "sdist", "filename": "Flask-1.1.4.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Framework :: Flask", "Intended Audience :: Developers", "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Programming Language :: Python", "Topic :: Internet :: WWW/HTTP :: Dynamic Content", "Topic :: Internet :: WWW/HTTP :: WSGI", "Topic :: Internet :: WWW/HTTP :: WSGI :: Application", "Topic :: Software Development :: Libraries :: Application Frameworks"], "name": "Flask", "requires_python": ">=3.8", "version": "2.3.3", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "flask-2.3.3-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "flask-2.3.3.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Framework :: Flask", "Intended Audience :: Developers", "Operating System :: OS Independent", "Programming Language :: Python", "Topic :: Internet :: WWW/HTTP :: Dynamic Content", "Topic :: Internet :: WWW/HTTP :: WSGI", "Topic :: Internet :: WWW/HTTP :: WSGI :: Application", "Topic :: Software Development :: Libraries :: Application Frameworks", "Typing :: Typed"], "name": "Flask", "requires_python": ">=3.9", "version": "3.1.2", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "flask-3.1.2-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "flask-3.1.2.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 4 - Beta", "Environment :: Web Environment", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Internet :: WWW/HTTP :: Dynamic Content", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "Quart", "requires_python": ">=3.7", "version": "0.16.3", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "Quart-0.16.3-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "Quart-0.16.3.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 4 - Beta", "Environment :: Web Environment", "Framework :: Flask", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Topic :: Internet :: WWW/HTTP :: Dynamic Content", "Topic :: Software Development :: Libraries :: Application Frameworks", "Typing :: Typed"], "name": "Quart", "requires_python": ">=3.9", "version": "0.20.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "quart-0.20.0-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "quart-0.20.0.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Database :: Front-Ends"], "name": "SQLAlchemy", "requires_python": "", "version": "1.2.19", "yanked": false}, "urls": [{"packagetype": "sdist", "filename": "SQLAlchemy-1.2.19.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 2", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Database :: Front-Ends"], "name": "SQLAlchemy", "requires_python": ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*", "version": "1.3.24", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.3.24-cp27-cp27m-macosx_10_14_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.3.24-cp27-cp27m-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.3.24-cp27-cp27m-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.3.24-cp35-cp35m-macosx_10_14_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.3.24-cp35-cp35m-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.3.24-cp35-cp35m-manylinux2010_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.3.24-cp35-cp35m-manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.3.24-cp35-cp35m-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.3.24-cp35-cp35m-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.3.24-cp36-cp36m-macosx_10_14_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.3.24-cp36-cp36m-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.3.24-cp36-cp36m-manylinux2010_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.3.24-cp36-cp36m-manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.3.24-cp36-cp36m-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.3.24-cp36-cp36m-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.3.24-cp37-cp37m-macosx_10_14_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.3.24-cp37-cp37m-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.3.24-cp37-cp37m-manylinux2010_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.3.24-cp37-cp37m-manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.3.24-cp37-cp37m-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.3.24-cp37-cp37m-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.3.24-cp38-cp38-macosx_10_14_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.3.24-cp38-cp38-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.3.24-cp38-cp38-manylinux2010_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.3.24-cp38-cp38-manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.3.24-cp38-cp38-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.3.24-cp38-cp38-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.3.24-cp39-cp39-macosx_10_14_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.3.24-cp39-cp39-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.3.24-cp39-cp39-manylinux2010_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.3.24-cp39-cp39-manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.3.24-cp39-cp39-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.3.24-cp39-cp39-win_amd64.whl"}, {"packagetype": "sdist", "filename": "SQLAlchemy-1.3.24.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 2", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Database :: Front-Ends"], "name": "SQLAlchemy", "requires_python": "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7", "version": "1.4.54", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.4.54-cp310-cp310-macosx_12_0_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.4.54-cp310-cp310-manylinux1_x86_64.manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_5_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.4.54-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.4.54-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.4.54-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.4.54-cp310-cp310-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.4.54-cp310-cp310-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.4.54-cp311-cp311-macosx_10_9_universal2.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.4.54-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.4.54-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.4.54-cp311-cp311-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.4.54-cp311-cp311-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.4.54-cp312-cp312-macosx_10_9_universal2.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.4.54-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.4.54-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.4.54-cp312-cp312-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.4.54-cp312-cp312-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.4.54-cp36-cp36m-macosx_10_14_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.4.54-cp36-cp36m-manylinux1_x86_64.manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_5_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.4.54-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.4.54-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.4.54-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.4.54-cp37-cp37m-macosx_11_0_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.4.54-cp37-cp37m-manylinux1_x86_64.manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_5_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.4.54-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.4.54-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.4.54-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.4.54-cp37-cp37m-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.4.54-cp37-cp37m-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.4.54-cp38-cp38-macosx_12_0_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.4.54-cp38-cp38-manylinux1_x86_64.manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_5_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.4.54-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.4.54-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.4.54-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.4.54-cp38-cp38-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.4.54-cp38-cp38-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.4.54-cp39-cp39-macosx_12_0_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.4.54-cp39-cp39-manylinux1_x86_64.manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_5_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.4.54-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.4.54-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.4.54-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.4.54-cp39-cp39-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "SQLAlchemy-1.4.54-cp39-cp39-win_amd64.whl"}, {"packagetype": "sdist", "filename": "sqlalchemy-1.4.54.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Database :: Front-Ends"], "name": "SQLAlchemy", "requires_python": ">=3.7", "version": "2.0.45", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "sqlalchemy-2.0.45-cp310-cp310-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "sqlalchemy-2.0.45-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "sqlalchemy-2.0.45-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "sqlalchemy-2.0.45-cp310-cp310-musllinux_1_2_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "sqlalchemy-2.0.45-cp310-cp310-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "sqlalchemy-2.0.45-cp310-cp310-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "sqlalchemy-2.0.45-cp310-cp310-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "sqlalchemy-2.0.45-cp311-cp311-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "sqlalchemy-2.0.45-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "sqlalchemy-2.0.45-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "sqlalchemy-2.0.45-cp311-cp311-musllinux_1_2_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "sqlalchemy-2.0.45-cp311-cp311-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "sqlalchemy-2.0.45-cp311-cp311-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "sqlalchemy-2.0.45-cp311-cp311-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "sqlalchemy-2.0.45-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "sqlalchemy-2.0.45-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "sqlalchemy-2.0.45-cp312-cp312-musllinux_1_2_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "sqlalchemy-2.0.45-cp312-cp312-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "sqlalchemy-2.0.45-cp312-cp312-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "sqlalchemy-2.0.45-cp312-cp312-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "sqlalchemy-2.0.45-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "sqlalchemy-2.0.45-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "sqlalchemy-2.0.45-cp313-cp313-musllinux_1_2_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "sqlalchemy-2.0.45-cp313-cp313-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "sqlalchemy-2.0.45-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "sqlalchemy-2.0.45-cp313-cp313t-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "sqlalchemy-2.0.45-cp313-cp313-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "sqlalchemy-2.0.45-cp313-cp313-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "sqlalchemy-2.0.45-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "sqlalchemy-2.0.45-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "sqlalchemy-2.0.45-cp314-cp314-musllinux_1_2_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "sqlalchemy-2.0.45-cp314-cp314-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "sqlalchemy-2.0.45-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "sqlalchemy-2.0.45-cp314-cp314t-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "sqlalchemy-2.0.45-cp314-cp314-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "sqlalchemy-2.0.45-cp314-cp314-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "sqlalchemy-2.0.45-cp38-cp38-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "sqlalchemy-2.0.45-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "sqlalchemy-2.0.45-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "sqlalchemy-2.0.45-cp38-cp38-musllinux_1_2_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "sqlalchemy-2.0.45-cp38-cp38-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "sqlalchemy-2.0.45-cp38-cp38-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "sqlalchemy-2.0.45-cp38-cp38-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "sqlalchemy-2.0.45-cp39-cp39-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "sqlalchemy-2.0.45-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "sqlalchemy-2.0.45-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "sqlalchemy-2.0.45-cp39-cp39-musllinux_1_2_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "sqlalchemy-2.0.45-cp39-cp39-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "sqlalchemy-2.0.45-cp39-cp39-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "sqlalchemy-2.0.45-cp39-cp39-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "sqlalchemy-2.0.45-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "sqlalchemy-2.0.45.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Typing :: Typed"], "name": "UnleashClient", "requires_python": ">=3.8", "version": "6.0.1", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "UnleashClient-6.0.1-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "unleashclient-6.0.1.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Typing :: Typed"], "name": "UnleashClient", "requires_python": ">=3.8", "version": "6.4.1", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "unleashclient-6.4.1-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "unleashclient-6.4.1.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Framework :: AsyncIO", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: MacOS :: MacOS X", "Operating System :: Microsoft :: Windows", "Operating System :: POSIX", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Internet :: WWW/HTTP"], "name": "aiohttp", "requires_python": ">=3.8", "version": "3.10.11", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp310-cp310-macosx_10_9_universal2.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp310-cp310-macosx_10_9_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp310-cp310-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp310-cp310-musllinux_1_2_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp310-cp310-musllinux_1_2_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp310-cp310-musllinux_1_2_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp310-cp310-musllinux_1_2_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp310-cp310-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp310-cp310-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp310-cp310-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp311-cp311-macosx_10_9_universal2.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp311-cp311-macosx_10_9_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp311-cp311-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp311-cp311-musllinux_1_2_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp311-cp311-musllinux_1_2_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp311-cp311-musllinux_1_2_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp311-cp311-musllinux_1_2_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp311-cp311-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp311-cp311-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp311-cp311-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp312-cp312-macosx_10_9_universal2.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp312-cp312-macosx_10_9_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp312-cp312-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp312-cp312-musllinux_1_2_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp312-cp312-musllinux_1_2_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp312-cp312-musllinux_1_2_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp312-cp312-musllinux_1_2_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp312-cp312-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp312-cp312-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp312-cp312-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp313-cp313-macosx_10_13_universal2.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp313-cp313-macosx_10_13_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp313-cp313-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp313-cp313-musllinux_1_2_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp313-cp313-musllinux_1_2_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp313-cp313-musllinux_1_2_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp313-cp313-musllinux_1_2_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp313-cp313-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp313-cp313-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp313-cp313-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp38-cp38-macosx_10_9_universal2.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp38-cp38-macosx_10_9_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp38-cp38-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp38-cp38-musllinux_1_2_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp38-cp38-musllinux_1_2_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp38-cp38-musllinux_1_2_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp38-cp38-musllinux_1_2_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp38-cp38-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp38-cp38-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp38-cp38-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp39-cp39-macosx_10_9_universal2.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp39-cp39-macosx_10_9_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp39-cp39-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp39-cp39-musllinux_1_2_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp39-cp39-musllinux_1_2_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp39-cp39-musllinux_1_2_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp39-cp39-musllinux_1_2_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp39-cp39-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp39-cp39-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.10.11-cp39-cp39-win_amd64.whl"}, {"packagetype": "sdist", "filename": "aiohttp-3.10.11.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Framework :: AsyncIO", "Intended Audience :: Developers", "Operating System :: MacOS :: MacOS X", "Operating System :: Microsoft :: Windows", "Operating System :: POSIX", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3.9", "Topic :: Internet :: WWW/HTTP"], "name": "aiohttp", "requires_python": ">=3.9", "version": "3.13.2", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp310-cp310-macosx_10_9_universal2.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp310-cp310-macosx_10_9_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp310-cp310-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp310-cp310-musllinux_1_2_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp310-cp310-musllinux_1_2_armv7l.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp310-cp310-musllinux_1_2_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp310-cp310-musllinux_1_2_riscv64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp310-cp310-musllinux_1_2_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp310-cp310-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp310-cp310-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp310-cp310-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp311-cp311-macosx_10_9_universal2.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp311-cp311-macosx_10_9_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp311-cp311-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp311-cp311-musllinux_1_2_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp311-cp311-musllinux_1_2_armv7l.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp311-cp311-musllinux_1_2_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp311-cp311-musllinux_1_2_riscv64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp311-cp311-musllinux_1_2_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp311-cp311-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp311-cp311-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp311-cp311-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp312-cp312-macosx_10_13_universal2.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp312-cp312-macosx_10_13_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp312-cp312-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp312-cp312-musllinux_1_2_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp312-cp312-musllinux_1_2_armv7l.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp312-cp312-musllinux_1_2_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp312-cp312-musllinux_1_2_riscv64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp312-cp312-musllinux_1_2_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp312-cp312-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp312-cp312-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp312-cp312-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp313-cp313-macosx_10_13_universal2.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp313-cp313-macosx_10_13_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp313-cp313-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp313-cp313-musllinux_1_2_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp313-cp313-musllinux_1_2_armv7l.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp313-cp313-musllinux_1_2_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp313-cp313-musllinux_1_2_riscv64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp313-cp313-musllinux_1_2_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp313-cp313-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp313-cp313-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp313-cp313-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp314-cp314-macosx_10_13_universal2.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp314-cp314-macosx_10_13_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp314-cp314-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp314-cp314-musllinux_1_2_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp314-cp314-musllinux_1_2_armv7l.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp314-cp314-musllinux_1_2_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp314-cp314-musllinux_1_2_riscv64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp314-cp314-musllinux_1_2_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp314-cp314-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp314-cp314t-macosx_10_13_universal2.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp314-cp314t-macosx_10_13_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp314-cp314t-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_armv7l.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_riscv64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp314-cp314t-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp314-cp314t-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp314-cp314-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp314-cp314-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp39-cp39-macosx_10_9_universal2.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp39-cp39-macosx_10_9_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp39-cp39-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp39-cp39-musllinux_1_2_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp39-cp39-musllinux_1_2_armv7l.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp39-cp39-musllinux_1_2_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp39-cp39-musllinux_1_2_riscv64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp39-cp39-musllinux_1_2_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp39-cp39-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp39-cp39-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.13.2-cp39-cp39-win_amd64.whl"}, {"packagetype": "sdist", "filename": "aiohttp-3.13.2.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Framework :: AsyncIO", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: MacOS :: MacOS X", "Operating System :: Microsoft :: Windows", "Operating System :: POSIX", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Topic :: Internet :: WWW/HTTP"], "name": "aiohttp", "requires_python": ">=3.5.3", "version": "3.4.4", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "aiohttp-3.4.4-cp35-cp35m-macosx_10_10_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.4.4-cp35-cp35m-macosx_10_11_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.4.4-cp35-cp35m-macosx_10_13_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.4.4-cp35-cp35m-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.4.4-cp35-cp35m-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.4.4-cp35-cp35m-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.4.4-cp35-cp35m-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.4.4-cp36-cp36m-macosx_10_10_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.4.4-cp36-cp36m-macosx_10_11_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.4.4-cp36-cp36m-macosx_10_13_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.4.4-cp36-cp36m-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.4.4-cp36-cp36m-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.4.4-cp36-cp36m-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.4.4-cp36-cp36m-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.4.4-cp37-cp37m-macosx_10_10_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.4.4-cp37-cp37m-macosx_10_11_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.4.4-cp37-cp37m-macosx_10_13_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.4.4-cp37-cp37m-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.4.4-cp37-cp37m-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.4.4-cp37-cp37m-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.4.4-cp37-cp37m-win_amd64.whl"}, {"packagetype": "sdist", "filename": "aiohttp-3.4.4.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Framework :: AsyncIO", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: MacOS :: MacOS X", "Operating System :: Microsoft :: Windows", "Operating System :: POSIX", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Internet :: WWW/HTTP"], "name": "aiohttp", "requires_python": ">=3.6", "version": "3.7.4", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "aiohttp-3.7.4-cp36-cp36m-macosx_10_14_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.7.4-cp36-cp36m-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.7.4-cp36-cp36m-manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.7.4-cp36-cp36m-manylinux2014_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.7.4-cp36-cp36m-manylinux2014_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.7.4-cp36-cp36m-manylinux2014_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.7.4-cp36-cp36m-manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.7.4-cp36-cp36m-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.7.4-cp36-cp36m-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.7.4-cp37-cp37m-macosx_10_14_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.7.4-cp37-cp37m-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.7.4-cp37-cp37m-manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.7.4-cp37-cp37m-manylinux2014_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.7.4-cp37-cp37m-manylinux2014_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.7.4-cp37-cp37m-manylinux2014_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.7.4-cp37-cp37m-manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.7.4-cp37-cp37m-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.7.4-cp37-cp37m-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.7.4-cp38-cp38-macosx_10_14_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.7.4-cp38-cp38-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.7.4-cp38-cp38-manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.7.4-cp38-cp38-manylinux2014_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.7.4-cp38-cp38-manylinux2014_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.7.4-cp38-cp38-manylinux2014_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.7.4-cp38-cp38-manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.7.4-cp38-cp38-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.7.4-cp38-cp38-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.7.4-cp39-cp39-macosx_10_14_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.7.4-cp39-cp39-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.7.4-cp39-cp39-manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.7.4-cp39-cp39-manylinux2014_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.7.4-cp39-cp39-manylinux2014_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.7.4-cp39-cp39-manylinux2014_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.7.4-cp39-cp39-manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.7.4-cp39-cp39-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "aiohttp-3.7.4-cp39-cp39-win_amd64.whl"}, {"packagetype": "sdist", "filename": "aiohttp-3.7.4.tar.gz"}]} +{"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: MacOS", "Operating System :: Microsoft :: Windows", "Operating System :: OS Independent", "Operating System :: POSIX", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "anthropic", "requires_python": ">=3.7", "version": "0.16.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "anthropic-0.16.0-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "anthropic-0.16.0.tar.gz"}]} +{"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: MacOS", "Operating System :: Microsoft :: Windows", "Operating System :: OS Independent", "Operating System :: POSIX", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "anthropic", "requires_python": ">=3.7", "version": "0.36.2", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "anthropic-0.36.2-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "anthropic-0.36.2.tar.gz"}]} +{"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: MacOS", "Operating System :: Microsoft :: Windows", "Operating System :: OS Independent", "Operating System :: POSIX", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "anthropic", "requires_python": ">=3.8", "version": "0.56.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "anthropic-0.56.0-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "anthropic-0.56.0.tar.gz"}]} +{"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: MacOS", "Operating System :: Microsoft :: Windows", "Operating System :: OS Independent", "Operating System :: POSIX", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "anthropic", "requires_python": ">=3.9", "version": "0.75.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "anthropic-0.75.0-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "anthropic-0.75.0.tar.gz"}]} +{"info": {"classifiers": ["Intended Audience :: End Users/Desktop", "License :: OSI Approved :: Apache Software License", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3.5", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "apache-beam", "requires_python": ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*", "version": "2.12.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "apache_beam-2.12.0-cp27-cp27m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.12.0-cp27-cp27m-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.12.0-cp27-cp27m-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.12.0-cp27-cp27mu-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.12.0-cp27-cp27mu-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.12.0-cp35-cp35m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.12.0-cp35-cp35m-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.12.0-cp35-cp35m-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.12.0-cp36-cp36m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.12.0-cp36-cp36m-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.12.0-cp36-cp36m-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.12.0-cp37-cp37m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.12.0-cp37-cp37m-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.12.0-cp37-cp37m-manylinux1_x86_64.whl"}, {"packagetype": "sdist", "filename": "apache-beam-2.12.0.zip"}]} +{"info": {"classifiers": ["Intended Audience :: End Users/Desktop", "License :: OSI Approved :: Apache Software License", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3.5", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "apache-beam", "requires_python": ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*", "version": "2.13.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "apache_beam-2.13.0-cp27-cp27m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.13.0-cp27-cp27m-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.13.0-cp27-cp27m-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.13.0-cp27-cp27mu-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.13.0-cp27-cp27mu-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.13.0-cp35-cp35m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.13.0-cp35-cp35m-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.13.0-cp35-cp35m-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.13.0-cp36-cp36m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.13.0-cp36-cp36m-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.13.0-cp36-cp36m-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.13.0-cp37-cp37m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.13.0-cp37-cp37m-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.13.0-cp37-cp37m-manylinux1_x86_64.whl"}, {"packagetype": "sdist", "filename": "apache-beam-2.13.0.zip"}]} +{"info": {"classifiers": ["Intended Audience :: End Users/Desktop", "License :: OSI Approved :: Apache Software License", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "apache-beam", "requires_python": ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*", "version": "2.14.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "apache_beam-2.14.0-cp27-cp27m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.14.0-cp27-cp27m-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.14.0-cp27-cp27m-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.14.0-cp27-cp27mu-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.14.0-cp27-cp27mu-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.14.0-cp35-cp35m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.14.0-cp35-cp35m-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.14.0-cp35-cp35m-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.14.0-cp36-cp36m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.14.0-cp36-cp36m-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.14.0-cp36-cp36m-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.14.0-cp37-cp37m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.14.0-cp37-cp37m-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.14.0-cp37-cp37m-manylinux1_x86_64.whl"}, {"packagetype": "sdist", "filename": "apache-beam-2.14.0.zip"}]} +{"info": {"classifiers": ["Intended Audience :: End Users/Desktop", "License :: OSI Approved :: Apache Software License", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "apache-beam", "requires_python": ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*", "version": "2.15.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "apache_beam-2.15.0-cp27-cp27m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.15.0-cp27-cp27m-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.15.0-cp27-cp27m-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.15.0-cp27-cp27mu-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.15.0-cp27-cp27mu-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.15.0-cp35-cp35m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.15.0-cp35-cp35m-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.15.0-cp35-cp35m-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.15.0-cp36-cp36m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.15.0-cp36-cp36m-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.15.0-cp36-cp36m-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.15.0-cp37-cp37m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.15.0-cp37-cp37m-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.15.0-cp37-cp37m-manylinux1_x86_64.whl"}, {"packagetype": "sdist", "filename": "apache-beam-2.15.0.zip"}]} +{"info": {"classifiers": ["Intended Audience :: End Users/Desktop", "License :: OSI Approved :: Apache Software License", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "apache-beam", "requires_python": ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*", "version": "2.19.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "apache_beam-2.19.0-cp27-cp27m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.19.0-cp27-cp27m-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.19.0-cp27-cp27m-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.19.0-cp27-cp27mu-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.19.0-cp27-cp27mu-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.19.0-cp35-cp35m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.19.0-cp35-cp35m-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.19.0-cp35-cp35m-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.19.0-cp36-cp36m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.19.0-cp36-cp36m-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.19.0-cp36-cp36m-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.19.0-cp37-cp37m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.19.0-cp37-cp37m-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.19.0-cp37-cp37m-manylinux1_x86_64.whl"}, {"packagetype": "sdist", "filename": "apache-beam-2.19.0.zip"}]} +{"info": {"classifiers": ["Intended Audience :: End Users/Desktop", "License :: OSI Approved :: Apache Software License", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "apache-beam", "requires_python": ">=3.6", "version": "2.26.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "apache_beam-2.26.0-cp36-cp36m-macosx_10_9_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.26.0-cp36-cp36m-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.26.0-cp36-cp36m-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.26.0-cp36-cp36m-manylinux2010_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.26.0-cp36-cp36m-manylinux2010_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.26.0-cp36-cp36m-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.26.0-cp36-cp36m-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.26.0-cp37-cp37m-macosx_10_9_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.26.0-cp37-cp37m-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.26.0-cp37-cp37m-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.26.0-cp37-cp37m-manylinux2010_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.26.0-cp37-cp37m-manylinux2010_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.26.0-cp37-cp37m-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.26.0-cp37-cp37m-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.26.0-cp38-cp38-macosx_10_9_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.26.0-cp38-cp38-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.26.0-cp38-cp38-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.26.0-cp38-cp38-manylinux2010_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.26.0-cp38-cp38-manylinux2010_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.26.0-cp38-cp38-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.26.0-cp38-cp38-win_amd64.whl"}, {"packagetype": "sdist", "filename": "apache-beam-2.26.0.zip"}]} +{"info": {"classifiers": ["Intended Audience :: End Users/Desktop", "License :: OSI Approved :: Apache Software License", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "apache-beam", "requires_python": ">=3.7", "version": "2.41.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "apache_beam-2.41.0-cp37-cp37m-macosx_10_9_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.41.0-cp37-cp37m-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.41.0-cp37-cp37m-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.41.0-cp37-cp37m-manylinux2010_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.41.0-cp37-cp37m-manylinux2010_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.41.0-cp37-cp37m-manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.41.0-cp37-cp37m-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.41.0-cp37-cp37m-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.41.0-cp38-cp38-macosx_10_9_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.41.0-cp38-cp38-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.41.0-cp38-cp38-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.41.0-cp38-cp38-manylinux2010_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.41.0-cp38-cp38-manylinux2010_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.41.0-cp38-cp38-manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.41.0-cp38-cp38-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.41.0-cp38-cp38-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.41.0-cp39-cp39-macosx_10_9_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.41.0-cp39-cp39-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.41.0-cp39-cp39-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.41.0-cp39-cp39-manylinux2010_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.41.0-cp39-cp39-manylinux2010_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.41.0-cp39-cp39-manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.41.0-cp39-cp39-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.41.0-cp39-cp39-win_amd64.whl"}, {"packagetype": "sdist", "filename": "apache-beam-2.41.0.zip"}]} +{"info": {"classifiers": ["Intended Audience :: End Users/Desktop", "License :: OSI Approved :: Apache Software License", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "apache-beam", "requires_python": ">=3.10", "version": "2.70.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "apache_beam-2.70.0-cp310-cp310-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.70.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.70.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.70.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.70.0-cp310-cp310-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.70.0-cp310-cp310-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.70.0-cp311-cp311-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.70.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.70.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.70.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.70.0-cp311-cp311-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.70.0-cp311-cp311-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.70.0-cp312-cp312-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.70.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.70.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.70.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.70.0-cp312-cp312-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.70.0-cp312-cp312-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.70.0-cp313-cp313-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.70.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.70.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.70.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.70.0-cp313-cp313-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "apache_beam-2.70.0-cp313-cp313-win_amd64.whl"}, {"packagetype": "sdist", "filename": "apache_beam-2.70.0.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 4 - Beta", "Intended Audience :: Developers", "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "ariadne", "requires_python": "", "version": "0.20.1", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "ariadne-0.20.1-py2.py3-none-any.whl"}, {"packagetype": "sdist", "filename": "ariadne-0.20.1.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 4 - Beta", "Intended Audience :: Developers", "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "ariadne", "requires_python": ">=3.9", "version": "0.26.2", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "ariadne-0.26.2-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "ariadne-0.26.2.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 4 - Beta", "Environment :: Console", "Framework :: AsyncIO", "Intended Audience :: Developers", "Intended Audience :: Information Technology", "Intended Audience :: System Administrators", "License :: OSI Approved :: MIT License", "Operating System :: POSIX :: Linux", "Operating System :: Unix", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: System :: Clustering", "Topic :: System :: Distributed Computing", "Topic :: System :: Monitoring", "Topic :: System :: Systems Administration"], "name": "arq", "requires_python": ">=3.6", "version": "0.23", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "arq-0.23-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "arq-0.23.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Console", "Framework :: AsyncIO", "Intended Audience :: Developers", "Intended Audience :: Information Technology", "Intended Audience :: System Administrators", "License :: OSI Approved :: MIT License", "Operating System :: POSIX :: Linux", "Operating System :: Unix", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: System :: Clustering", "Topic :: System :: Distributed Computing", "Topic :: System :: Monitoring", "Topic :: System :: Systems Administration"], "name": "arq", "requires_python": ">=3.8", "version": "0.26.3", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "arq-0.26.3-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "arq-0.26.3.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Framework :: AsyncIO", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: MacOS :: MacOS X", "Operating System :: Microsoft :: Windows", "Operating System :: POSIX", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: CPython", "Topic :: Database :: Front-Ends"], "name": "asyncpg", "requires_python": ">=3.5.0", "version": "0.23.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "asyncpg-0.23.0-cp35-cp35m-macosx_10_14_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.23.0-cp35-cp35m-manylinux_2_5_x86_64.manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.23.0-cp36-cp36m-macosx_10_14_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.23.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.23.0-cp36-cp36m-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.23.0-cp37-cp37m-macosx_10_14_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.23.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.23.0-cp37-cp37m-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.23.0-cp38-cp38-macosx_10_14_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.23.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.23.0-cp38-cp38-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.23.0-cp39-cp39-macosx_10_14_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.23.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.23.0-cp39-cp39-win_amd64.whl"}, {"packagetype": "sdist", "filename": "asyncpg-0.23.0.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Framework :: AsyncIO", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: MacOS :: MacOS X", "Operating System :: Microsoft :: Windows", "Operating System :: POSIX", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: CPython", "Topic :: Database :: Front-Ends"], "name": "asyncpg", "requires_python": ">=3.6.0", "version": "0.26.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "asyncpg-0.26.0-cp310-cp310-macosx_10_9_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.26.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.26.0-cp310-cp310-musllinux_1_1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.26.0-cp310-cp310-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.26.0-cp310-cp310-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.26.0-cp36-cp36m-macosx_10_9_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.26.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.26.0-cp36-cp36m-musllinux_1_1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.26.0-cp36-cp36m-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.26.0-cp36-cp36m-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.26.0-cp37-cp37m-macosx_10_9_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.26.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.26.0-cp37-cp37m-musllinux_1_1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.26.0-cp37-cp37m-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.26.0-cp37-cp37m-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.26.0-cp38-cp38-macosx_10_9_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.26.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.26.0-cp38-cp38-musllinux_1_1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.26.0-cp38-cp38-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.26.0-cp38-cp38-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.26.0-cp39-cp39-macosx_10_9_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.26.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.26.0-cp39-cp39-musllinux_1_1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.26.0-cp39-cp39-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.26.0-cp39-cp39-win_amd64.whl"}, {"packagetype": "sdist", "filename": "asyncpg-0.26.0.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Framework :: AsyncIO", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: MacOS :: MacOS X", "Operating System :: Microsoft :: Windows", "Operating System :: POSIX", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: CPython", "Topic :: Database :: Front-Ends"], "name": "asyncpg", "requires_python": ">=3.8.0", "version": "0.29.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "asyncpg-0.29.0-cp310-cp310-macosx_10_9_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.29.0-cp310-cp310-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.29.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.29.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.29.0-cp310-cp310-musllinux_1_1_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.29.0-cp310-cp310-musllinux_1_1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.29.0-cp310-cp310-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.29.0-cp310-cp310-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.29.0-cp311-cp311-macosx_10_9_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.29.0-cp311-cp311-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.29.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.29.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.29.0-cp311-cp311-musllinux_1_1_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.29.0-cp311-cp311-musllinux_1_1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.29.0-cp311-cp311-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.29.0-cp311-cp311-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.29.0-cp312-cp312-macosx_10_9_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.29.0-cp312-cp312-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.29.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.29.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.29.0-cp312-cp312-musllinux_1_1_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.29.0-cp312-cp312-musllinux_1_1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.29.0-cp312-cp312-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.29.0-cp312-cp312-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.29.0-cp38-cp38-macosx_10_9_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.29.0-cp38-cp38-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.29.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.29.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.29.0-cp38-cp38-musllinux_1_1_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.29.0-cp38-cp38-musllinux_1_1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.29.0-cp38-cp38-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.29.0-cp38-cp38-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.29.0-cp39-cp39-macosx_10_9_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.29.0-cp39-cp39-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.29.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.29.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.29.0-cp39-cp39-musllinux_1_1_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.29.0-cp39-cp39-musllinux_1_1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.29.0-cp39-cp39-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.29.0-cp39-cp39-win_amd64.whl"}, {"packagetype": "sdist", "filename": "asyncpg-0.29.0.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Framework :: AsyncIO", "Intended Audience :: Developers", "Operating System :: MacOS :: MacOS X", "Operating System :: Microsoft :: Windows", "Operating System :: POSIX", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Free Threading :: 2 - Beta", "Programming Language :: Python :: Implementation :: CPython", "Topic :: Database :: Front-Ends"], "name": "asyncpg", "requires_python": ">=3.9.0", "version": "0.31.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "asyncpg-0.31.0-cp310-cp310-macosx_10_9_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.31.0-cp310-cp310-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.31.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.31.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.31.0-cp310-cp310-musllinux_1_2_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.31.0-cp310-cp310-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.31.0-cp310-cp310-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.31.0-cp310-cp310-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.31.0-cp311-cp311-macosx_10_9_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.31.0-cp311-cp311-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.31.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.31.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.31.0-cp311-cp311-musllinux_1_2_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.31.0-cp311-cp311-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.31.0-cp311-cp311-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.31.0-cp311-cp311-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.31.0-cp312-cp312-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.31.0-cp312-cp312-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.31.0-cp313-cp313-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.31.0-cp313-cp313-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.31.0-cp314-cp314t-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.31.0-cp314-cp314t-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.31.0-cp314-cp314-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.31.0-cp314-cp314-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.31.0-cp39-cp39-macosx_10_9_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.31.0-cp39-cp39-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.31.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.31.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.31.0-cp39-cp39-musllinux_1_2_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.31.0-cp39-cp39-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.31.0-cp39-cp39-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "asyncpg-0.31.0-cp39-cp39-win_amd64.whl"}, {"packagetype": "sdist", "filename": "asyncpg-0.31.0.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Natural Language :: English", "Programming Language :: Python", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7"], "name": "boto3", "requires_python": "", "version": "1.12.49", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "boto3-1.12.49-py2.py3-none-any.whl"}, {"packagetype": "sdist", "filename": "boto3-1.12.49.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Natural Language :: English", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9"], "name": "boto3", "requires_python": ">= 3.6", "version": "1.21.46", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "boto3-1.21.46-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "boto3-1.21.46.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Natural Language :: English", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9"], "name": "boto3", "requires_python": ">= 3.7", "version": "1.33.13", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "boto3-1.33.13-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "boto3-1.33.13.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Natural Language :: English", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3.9"], "name": "boto3", "requires_python": ">=3.9", "version": "1.42.13", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "boto3-1.42.13-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "boto3-1.42.13.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 4 - Beta", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 2.5", "Programming Language :: Python :: 2.6", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.2", "Programming Language :: Python :: 3.3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Topic :: Internet :: WWW/HTTP :: Dynamic Content :: CGI Tools/Libraries", "Topic :: Internet :: WWW/HTTP :: HTTP Servers", "Topic :: Internet :: WWW/HTTP :: WSGI", "Topic :: Internet :: WWW/HTTP :: WSGI :: Application", "Topic :: Internet :: WWW/HTTP :: WSGI :: Middleware", "Topic :: Internet :: WWW/HTTP :: WSGI :: Server", "Topic :: Software Development :: Libraries :: Application Frameworks"], "name": "bottle", "requires_python": "", "version": "0.12.25", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "bottle-0.12.25-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "bottle-0.12.25.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 4 - Beta", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Internet :: WWW/HTTP :: Dynamic Content :: CGI Tools/Libraries", "Topic :: Internet :: WWW/HTTP :: HTTP Servers", "Topic :: Internet :: WWW/HTTP :: WSGI", "Topic :: Internet :: WWW/HTTP :: WSGI :: Application", "Topic :: Internet :: WWW/HTTP :: WSGI :: Middleware", "Topic :: Internet :: WWW/HTTP :: WSGI :: Server", "Topic :: Software Development :: Libraries :: Application Frameworks"], "name": "bottle", "requires_python": null, "version": "0.13.4", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "bottle-0.13.4-py2.py3-none-any.whl"}, {"packagetype": "sdist", "filename": "bottle-0.13.4.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 4 - Beta", "Environment :: Console", "Intended Audience :: Developers", "Operating System :: MacOS :: MacOS X", "Operating System :: Microsoft :: Windows", "Operating System :: POSIX :: Linux", "Programming Language :: C", "Programming Language :: C++", "Programming Language :: Python", "Programming Language :: Python :: 2", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Unix Shell", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: System :: Archiving", "Topic :: System :: Archiving :: Compression", "Topic :: Text Processing :: Fonts", "Topic :: Utilities"], "name": "brotli", "requires_python": null, "version": "1.2.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp27-cp27m-macosx_10_9_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp27-cp27m-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp27-cp27m-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp27-cp27mu-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp27-cp27mu-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp27-cp27m-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp27-cp27m-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp310-cp310-macosx_10_9_universal2.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp310-cp310-macosx_10_9_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp310-cp310-musllinux_1_2_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp310-cp310-musllinux_1_2_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp310-cp310-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp310-cp310-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp310-cp310-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp311-cp311-macosx_10_9_universal2.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp311-cp311-macosx_10_9_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp311-cp311-musllinux_1_2_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp311-cp311-musllinux_1_2_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp311-cp311-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp311-cp311-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp311-cp311-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp312-cp312-macosx_10_13_universal2.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp312-cp312-macosx_10_13_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp312-cp312-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp312-cp312-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp313-cp313-macosx_10_13_universal2.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp313-cp313-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp313-cp313-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp314-cp314-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp314-cp314-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp36-cp36m-macosx_10_9_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp36-cp36m-musllinux_1_2_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp36-cp36m-musllinux_1_2_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp36-cp36m-musllinux_1_2_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp36-cp36m-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp36-cp36m-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp36-cp36m-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp37-cp37m-macosx_10_9_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp37-cp37m-musllinux_1_2_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp37-cp37m-musllinux_1_2_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp37-cp37m-musllinux_1_2_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp37-cp37m-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp37-cp37m-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp37-cp37m-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp38-cp38-macosx_10_9_universal2.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp38-cp38-macosx_10_9_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp38-cp38-musllinux_1_2_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp38-cp38-musllinux_1_2_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp38-cp38-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp38-cp38-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp38-cp38-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp39-cp39-macosx_10_9_universal2.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp39-cp39-macosx_10_9_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp39-cp39-musllinux_1_2_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp39-cp39-musllinux_1_2_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp39-cp39-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp39-cp39-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "brotli-1.2.0-cp39-cp39-win_amd64.whl"}, {"packagetype": "sdist", "filename": "brotli-1.2.0.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 2", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Software Development :: Object Brokering", "Topic :: System :: Distributed Computing"], "name": "celery", "requires_python": ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*", "version": "4.4.7", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "celery-4.4.7-py2.py3-none-any.whl"}, {"packagetype": "sdist", "filename": "celery-4.4.7.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Framework :: Celery", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Software Development :: Object Brokering", "Topic :: System :: Distributed Computing"], "name": "celery", "requires_python": ">=3.9", "version": "5.6.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "celery-5.6.0-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "celery-5.6.0.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Natural Language :: English", "Programming Language :: Python :: 2", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8"], "name": "chalice", "requires_python": "", "version": "1.16.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "chalice-1.16.0-py2.py3-none-any.whl"}, {"packagetype": "sdist", "filename": "chalice-1.16.0.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Natural Language :: English", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9"], "name": "chalice", "requires_python": null, "version": "1.32.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "chalice-1.32.0-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "chalice-1.32.0.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 4 - Beta", "Environment :: Console", "Intended Audience :: Developers", "Intended Audience :: Information Technology", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: PyPy", "Programming Language :: SQL", "Topic :: Database", "Topic :: Scientific/Engineering :: Information Analysis", "Topic :: Software Development", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "clickhouse-driver", "requires_python": "<4,>=3.9", "version": "0.2.10", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp310-cp310-macosx_10_9_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp310-cp310-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp310-cp310-musllinux_1_2_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp310-cp310-musllinux_1_2_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp310-cp310-musllinux_1_2_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp310-cp310-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp310-cp310-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp310-cp310-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp311-cp311-macosx_10_9_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp311-cp311-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp311-cp311-musllinux_1_2_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp311-cp311-musllinux_1_2_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp311-cp311-musllinux_1_2_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp311-cp311-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp311-cp311-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp311-cp311-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp312-cp312-macosx_10_13_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp312-cp312-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp312-cp312-musllinux_1_2_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp312-cp312-musllinux_1_2_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp312-cp312-musllinux_1_2_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp312-cp312-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp312-cp312-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp312-cp312-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp313-cp313-macosx_10_13_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp313-cp313-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp313-cp313-musllinux_1_2_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp313-cp313-musllinux_1_2_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp313-cp313-musllinux_1_2_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp313-cp313-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp313-cp313-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp313-cp313-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp314-cp314-macosx_10_15_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp314-cp314-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp314-cp314-musllinux_1_2_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp314-cp314-musllinux_1_2_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp314-cp314-musllinux_1_2_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp314-cp314-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp314-cp314-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp314-cp314-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp38-cp38-macosx_10_9_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp38-cp38-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp38-cp38-musllinux_1_2_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp38-cp38-musllinux_1_2_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp38-cp38-musllinux_1_2_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp38-cp38-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp38-cp38-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp38-cp38-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp39-cp39-macosx_10_9_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp39-cp39-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp39-cp39-musllinux_1_2_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp39-cp39-musllinux_1_2_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp39-cp39-musllinux_1_2_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp39-cp39-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp39-cp39-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-cp39-cp39-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-pp310-pypy310_pp73-macosx_10_15_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-pp310-pypy310_pp73-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-pp311-pypy311_pp73-macosx_10_15_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-pp311-pypy311_pp73-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-pp39-pypy39_pp73-macosx_10_15_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-pp39-pypy39_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-pp39-pypy39_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "clickhouse_driver-0.2.10-pp39-pypy39_pp73-win_amd64.whl"}, {"packagetype": "sdist", "filename": "clickhouse_driver-0.2.10.tar.gz"}]} +{"info": {"classifiers": ["Intended Audience :: Developers", "Operating System :: MacOS", "Operating System :: Microsoft :: Windows", "Operating System :: OS Independent", "Operating System :: POSIX", "Operating System :: POSIX :: Linux", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "cohere", "requires_python": "<4.0,>=3.8", "version": "5.10.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "cohere-5.10.0-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "cohere-5.10.0.tar.gz"}]} +{"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: MacOS", "Operating System :: Microsoft :: Windows", "Operating System :: OS Independent", "Operating System :: POSIX", "Operating System :: POSIX :: Linux", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "cohere", "requires_python": "<4.0,>=3.9", "version": "5.15.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "cohere-5.15.0-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "cohere-5.15.0.tar.gz"}]} +{"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: MacOS", "Operating System :: Microsoft :: Windows", "Operating System :: OS Independent", "Operating System :: POSIX", "Operating System :: POSIX :: Linux", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "cohere", "requires_python": "<4.0,>=3.9", "version": "5.20.1", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "cohere-5.20.1-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "cohere-5.20.1.tar.gz"}]} +{"info": {"classifiers": ["Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9"], "name": "cohere", "requires_python": "<4.0,>=3.8", "version": "5.4.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "cohere-5.4.0-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "cohere-5.4.0.tar.gz"}]} +{"info": {"classifiers": ["License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Topic :: System :: Distributed Computing"], "name": "dramatiq", "requires_python": ">=3.5", "version": "1.9.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "dramatiq-1.9.0-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "dramatiq-1.9.0.tar.gz"}]} +{"info": {"classifiers": ["License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Topic :: System :: Distributed Computing"], "name": "dramatiq", "requires_python": ">=3.10", "version": "2.0.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "dramatiq-2.0.0-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "dramatiq-2.0.0.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Intended Audience :: Developers", "Intended Audience :: System Administrators", "License :: OSI Approved :: Apache Software License", "Natural Language :: English", "Operating System :: MacOS :: MacOS X", "Operating System :: Microsoft :: Windows", "Operating System :: POSIX", "Programming Language :: Python", "Programming Language :: Python :: 2.6", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3.3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: Jython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Internet :: WWW/HTTP :: WSGI", "Topic :: Software Development :: Libraries :: Application Frameworks"], "name": "falcon", "requires_python": "", "version": "1.4.1", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "falcon-1.4.1-py2.py3-none-any.whl"}, {"packagetype": "sdist", "filename": "falcon-1.4.1.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Intended Audience :: Developers", "Intended Audience :: System Administrators", "License :: OSI Approved :: Apache Software License", "Natural Language :: English", "Operating System :: MacOS :: MacOS X", "Operating System :: Microsoft :: Windows", "Operating System :: POSIX", "Programming Language :: Python", "Programming Language :: Python :: 2", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Internet :: WWW/HTTP :: WSGI", "Topic :: Software Development :: Libraries :: Application Frameworks"], "name": "falcon", "requires_python": ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*", "version": "2.0.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "falcon-2.0.0-cp27-cp27m-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-2.0.0-cp27-cp27m-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-2.0.0-cp27-cp27mu-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-2.0.0-cp27-cp27mu-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-2.0.0-cp34-cp34m-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-2.0.0-cp34-cp34m-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-2.0.0-cp35-cp35m-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-2.0.0-cp35-cp35m-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-2.0.0-cp36-cp36m-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-2.0.0-cp36-cp36m-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-2.0.0-cp37-cp37m-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-2.0.0-cp37-cp37m-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-2.0.0-py2.py3-none-any.whl"}, {"packagetype": "sdist", "filename": "falcon-2.0.0.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Intended Audience :: Developers", "Intended Audience :: System Administrators", "License :: OSI Approved :: Apache Software License", "Natural Language :: English", "Operating System :: MacOS :: MacOS X", "Operating System :: Microsoft :: Windows", "Operating System :: POSIX", "Programming Language :: Cython", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Internet :: WWW/HTTP :: WSGI", "Topic :: Software Development :: Libraries :: Application Frameworks"], "name": "falcon", "requires_python": ">=3.5", "version": "3.1.3", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "falcon-3.1.3-cp310-cp310-macosx_11_0_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-3.1.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-3.1.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-3.1.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-3.1.3-cp310-cp310-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-3.1.3-cp311-cp311-macosx_10_9_universal2.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-3.1.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-3.1.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-3.1.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-3.1.3-cp311-cp311-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-3.1.3-cp312-cp312-macosx_10_9_universal2.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-3.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-3.1.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-3.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-3.1.3-cp312-cp312-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-3.1.3-cp36-cp36m-macosx_10_14_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-3.1.3-cp36-cp36m-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-3.1.3-cp37-cp37m-macosx_11_0_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-3.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-3.1.3-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-3.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-3.1.3-cp37-cp37m-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-3.1.3-cp38-cp38-macosx_11_0_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-3.1.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-3.1.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-3.1.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-3.1.3-cp38-cp38-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-3.1.3-cp39-cp39-macosx_11_0_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-3.1.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-3.1.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-3.1.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-3.1.3-cp39-cp39-win_amd64.whl"}, {"packagetype": "sdist", "filename": "falcon-3.1.3.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Intended Audience :: Developers", "Intended Audience :: System Administrators", "License :: OSI Approved :: Apache Software License", "Natural Language :: English", "Operating System :: MacOS :: MacOS X", "Operating System :: Microsoft :: Windows", "Operating System :: POSIX", "Programming Language :: Cython", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Free Threading", "Programming Language :: Python :: Free Threading :: 2 - Beta", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Internet :: WWW/HTTP :: WSGI", "Topic :: Software Development :: Libraries :: Application Frameworks", "Typing :: Typed"], "name": "falcon", "requires_python": ">=3.9", "version": "4.2.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "falcon-4.2.0-cp310-cp310-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-4.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-4.2.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-4.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-4.2.0-cp310-cp310-musllinux_1_2_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-4.2.0-cp310-cp310-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-4.2.0-cp310-cp310-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-4.2.0-cp311-cp311-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-4.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-4.2.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-4.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-4.2.0-cp311-cp311-musllinux_1_2_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-4.2.0-cp311-cp311-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-4.2.0-cp311-cp311-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-4.2.0-cp312-cp312-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-4.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-4.2.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-4.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-4.2.0-cp312-cp312-musllinux_1_2_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-4.2.0-cp312-cp312-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-4.2.0-cp312-cp312-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-4.2.0-cp313-cp313-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-4.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-4.2.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-4.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-4.2.0-cp313-cp313-musllinux_1_2_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-4.2.0-cp313-cp313-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-4.2.0-cp313-cp313-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-4.2.0-cp314-cp314-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-4.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-4.2.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-4.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-4.2.0-cp314-cp314-musllinux_1_2_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-4.2.0-cp314-cp314-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-4.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-4.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-4.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-4.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-4.2.0-cp314-cp314-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "falcon-4.2.0-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "falcon-4.2.0.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 4 - Beta", "Environment :: Web Environment", "Framework :: AsyncIO", "Framework :: FastAPI", "Framework :: Pydantic", "Framework :: Pydantic :: 1", "Intended Audience :: Developers", "Intended Audience :: Information Technology", "Intended Audience :: System Administrators", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: HTTP Servers", "Topic :: Software Development", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "fastapi", "requires_python": ">=3.8", "version": "0.109.2", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "fastapi-0.109.2-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "fastapi-0.109.2.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 4 - Beta", "Environment :: Web Environment", "Framework :: AsyncIO", "Framework :: FastAPI", "Framework :: Pydantic", "Framework :: Pydantic :: 1", "Framework :: Pydantic :: 2", "Intended Audience :: Developers", "Intended Audience :: Information Technology", "Intended Audience :: System Administrators", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: HTTP Servers", "Topic :: Software Development", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "fastapi", "requires_python": ">=3.9", "version": "0.125.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "fastapi-0.125.0-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "fastapi-0.125.0.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 4 - Beta", "Environment :: Web Environment", "Framework :: AsyncIO", "Framework :: FastAPI", "Intended Audience :: Developers", "Intended Audience :: Information Technology", "Intended Audience :: System Administrators", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: HTTP Servers", "Topic :: Software Development", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "fastapi", "requires_python": ">=3.6.1", "version": "0.79.1", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "fastapi-0.79.1-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "fastapi-0.79.1.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 4 - Beta", "Environment :: Web Environment", "Framework :: AsyncIO", "Framework :: FastAPI", "Framework :: Pydantic", "Framework :: Pydantic :: 1", "Intended Audience :: Developers", "Intended Audience :: Information Technology", "Intended Audience :: System Administrators", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: HTTP Servers", "Topic :: Software Development", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "fastapi", "requires_python": ">=3.7", "version": "0.94.1", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "fastapi-0.94.1-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "fastapi-0.94.1.tar.gz"}]} +{"info": {"classifiers": [], "name": "fastmcp", "requires_python": ">=3.10", "version": "0.1.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "fastmcp-0.1.0-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "fastmcp-0.1.0.tar.gz"}]} +{"info": {"classifiers": [], "name": "fastmcp", "requires_python": ">=3.10", "version": "0.4.1", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "fastmcp-0.4.1-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "fastmcp-0.4.1.tar.gz"}]} +{"info": {"classifiers": [], "name": "fastmcp", "requires_python": ">=3.10", "version": "1.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "fastmcp-1.0-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "fastmcp-1.0.tar.gz"}]} +{"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Topic :: Scientific/Engineering :: Artificial Intelligence", "Typing :: Typed"], "name": "fastmcp", "requires_python": ">=3.10", "version": "2.14.1", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "fastmcp-2.14.1-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "fastmcp-2.14.1.tar.gz"}]} +{"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "google-genai", "requires_python": ">=3.9", "version": "1.29.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "google_genai-1.29.0-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "google_genai-1.29.0.tar.gz"}]} +{"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "google-genai", "requires_python": ">=3.9", "version": "1.38.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "google_genai-1.38.0-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "google_genai-1.38.0.tar.gz"}]} +{"info": {"classifiers": ["Intended Audience :: Developers", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "google-genai", "requires_python": ">=3.9", "version": "1.47.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "google_genai-1.47.0-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "google_genai-1.47.0.tar.gz"}]} +{"info": {"classifiers": ["Intended Audience :: Developers", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Topic :: Internet", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "google-genai", "requires_python": ">=3.10", "version": "1.56.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "google_genai-1.56.0-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "google_genai-1.56.0.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Software Development :: Libraries"], "name": "gql", "requires_python": "", "version": "3.4.1", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "gql-3.4.1-py2.py3-none-any.whl"}, {"packagetype": "sdist", "filename": "gql-3.4.1.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Software Development :: Libraries"], "name": "gql", "requires_python": ">=3.8.1", "version": "4.0.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "gql-4.0.0-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "gql-4.0.0.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Software Development :: Libraries"], "name": "gql", "requires_python": ">=3.8.1", "version": "4.2.0b0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "gql-4.2.0b0-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "gql-4.2.0b0.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 3 - Alpha", "Intended Audience :: Developers", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Software Development :: Libraries"], "name": "graphene", "requires_python": "", "version": "3.3", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "graphene-3.3-py2.py3-none-any.whl"}, {"packagetype": "sdist", "filename": "graphene-3.3.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Software Development :: Libraries"], "name": "graphene", "requires_python": null, "version": "3.4.3", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "graphene-3.4.3-py2.py3-none-any.whl"}, {"packagetype": "sdist", "filename": "graphene-3.4.3.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "License :: OSI Approved :: Apache Software License", "Programming Language :: Python", "Programming Language :: Python :: 2", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8"], "name": "grpcio", "requires_python": "", "version": "1.32.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "grpcio-1.32.0-cp27-cp27m-macosx_10_9_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.32.0-cp27-cp27m-manylinux2010_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.32.0-cp27-cp27m-manylinux2010_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.32.0-cp27-cp27mu-linux_armv7l.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.32.0-cp27-cp27mu-manylinux2010_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.32.0-cp27-cp27mu-manylinux2010_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.32.0-cp27-cp27m-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.32.0-cp27-cp27m-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.32.0-cp35-cp35m-linux_armv7l.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.32.0-cp35-cp35m-macosx_10_7_intel.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.32.0-cp35-cp35m-manylinux2010_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.32.0-cp35-cp35m-manylinux2010_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.32.0-cp35-cp35m-manylinux2014_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.32.0-cp35-cp35m-manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.32.0-cp35-cp35m-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.32.0-cp35-cp35m-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.32.0-cp36-cp36m-linux_armv7l.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.32.0-cp36-cp36m-macosx_10_9_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.32.0-cp36-cp36m-manylinux2010_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.32.0-cp36-cp36m-manylinux2010_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.32.0-cp36-cp36m-manylinux2014_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.32.0-cp36-cp36m-manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.32.0-cp36-cp36m-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.32.0-cp36-cp36m-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.32.0-cp37-cp37m-macosx_10_9_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.32.0-cp37-cp37m-manylinux2010_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.32.0-cp37-cp37m-manylinux2010_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.32.0-cp37-cp37m-manylinux2014_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.32.0-cp37-cp37m-manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.32.0-cp37-cp37m-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.32.0-cp37-cp37m-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.32.0-cp38-cp38-macosx_10_9_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.32.0-cp38-cp38-manylinux2010_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.32.0-cp38-cp38-manylinux2010_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.32.0-cp38-cp38-manylinux2014_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.32.0-cp38-cp38-manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.32.0-cp38-cp38-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.32.0-cp38-cp38-win_amd64.whl"}, {"packagetype": "sdist", "filename": "grpcio-1.32.0.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "License :: OSI Approved :: Apache Software License", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9"], "name": "grpcio", "requires_python": ">=3.6", "version": "1.47.5", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "grpcio-1.47.5-cp310-cp310-linux_armv7l.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.47.5-cp310-cp310-macosx_12_0_universal2.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.47.5-cp310-cp310-manylinux_2_17_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.47.5-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.47.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.47.5-cp310-cp310-musllinux_1_1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.47.5-cp310-cp310-musllinux_1_1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.47.5-cp310-cp310-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.47.5-cp310-cp310-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.47.5-cp36-cp36m-linux_armv7l.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.47.5-cp36-cp36m-macosx_10_10_universal2.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.47.5-cp36-cp36m-manylinux_2_17_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.47.5-cp36-cp36m-manylinux_2_17_i686.manylinux2014_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.47.5-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.47.5-cp36-cp36m-musllinux_1_1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.47.5-cp36-cp36m-musllinux_1_1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.47.5-cp36-cp36m-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.47.5-cp36-cp36m-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.47.5-cp37-cp37m-linux_armv7l.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.47.5-cp37-cp37m-macosx_10_10_universal2.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.47.5-cp37-cp37m-manylinux_2_17_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.47.5-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.47.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.47.5-cp37-cp37m-musllinux_1_1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.47.5-cp37-cp37m-musllinux_1_1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.47.5-cp37-cp37m-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.47.5-cp37-cp37m-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.47.5-cp38-cp38-linux_armv7l.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.47.5-cp38-cp38-macosx_10_10_universal2.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.47.5-cp38-cp38-manylinux_2_17_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.47.5-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.47.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.47.5-cp38-cp38-musllinux_1_1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.47.5-cp38-cp38-musllinux_1_1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.47.5-cp38-cp38-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.47.5-cp38-cp38-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.47.5-cp39-cp39-linux_armv7l.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.47.5-cp39-cp39-macosx_10_10_universal2.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.47.5-cp39-cp39-manylinux_2_17_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.47.5-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.47.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.47.5-cp39-cp39-musllinux_1_1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.47.5-cp39-cp39-musllinux_1_1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.47.5-cp39-cp39-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.47.5-cp39-cp39-win_amd64.whl"}, {"packagetype": "sdist", "filename": "grpcio-1.47.5.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "License :: OSI Approved :: Apache Software License", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9"], "name": "grpcio", "requires_python": ">=3.7", "version": "1.62.3", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "grpcio-1.62.3-cp310-cp310-linux_armv7l.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.62.3-cp310-cp310-macosx_12_0_universal2.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.62.3-cp310-cp310-manylinux_2_17_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.62.3-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.62.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.62.3-cp310-cp310-musllinux_1_1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.62.3-cp310-cp310-musllinux_1_1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.62.3-cp310-cp310-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.62.3-cp310-cp310-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.62.3-cp311-cp311-linux_armv7l.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.62.3-cp311-cp311-macosx_10_10_universal2.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.62.3-cp311-cp311-manylinux_2_17_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.62.3-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.62.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.62.3-cp311-cp311-musllinux_1_1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.62.3-cp311-cp311-musllinux_1_1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.62.3-cp311-cp311-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.62.3-cp311-cp311-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.62.3-cp312-cp312-linux_armv7l.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.62.3-cp312-cp312-macosx_10_10_universal2.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.62.3-cp312-cp312-manylinux_2_17_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.62.3-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.62.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.62.3-cp312-cp312-musllinux_1_1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.62.3-cp312-cp312-musllinux_1_1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.62.3-cp312-cp312-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.62.3-cp312-cp312-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.62.3-cp37-cp37m-linux_armv7l.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.62.3-cp37-cp37m-macosx_10_10_universal2.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.62.3-cp37-cp37m-manylinux_2_17_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.62.3-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.62.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.62.3-cp37-cp37m-musllinux_1_1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.62.3-cp37-cp37m-musllinux_1_1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.62.3-cp37-cp37m-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.62.3-cp38-cp38-linux_armv7l.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.62.3-cp38-cp38-macosx_10_10_universal2.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.62.3-cp38-cp38-manylinux_2_17_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.62.3-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.62.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.62.3-cp38-cp38-musllinux_1_1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.62.3-cp38-cp38-musllinux_1_1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.62.3-cp38-cp38-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.62.3-cp38-cp38-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.62.3-cp39-cp39-linux_armv7l.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.62.3-cp39-cp39-macosx_10_10_universal2.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.62.3-cp39-cp39-manylinux_2_17_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.62.3-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.62.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.62.3-cp39-cp39-musllinux_1_1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.62.3-cp39-cp39-musllinux_1_1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.62.3-cp39-cp39-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.62.3-cp39-cp39-win_amd64.whl"}, {"packagetype": "sdist", "filename": "grpcio-1.62.3.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "License :: OSI Approved :: Apache Software License", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3.9"], "name": "grpcio", "requires_python": ">=3.9", "version": "1.76.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp310-cp310-linux_armv7l.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp310-cp310-macosx_11_0_universal2.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp310-cp310-musllinux_1_2_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp310-cp310-musllinux_1_2_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp310-cp310-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp310-cp310-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp310-cp310-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp311-cp311-linux_armv7l.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp311-cp311-macosx_11_0_universal2.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp311-cp311-musllinux_1_2_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp311-cp311-musllinux_1_2_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp311-cp311-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp311-cp311-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp311-cp311-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp312-cp312-linux_armv7l.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp312-cp312-macosx_11_0_universal2.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp312-cp312-musllinux_1_2_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp312-cp312-musllinux_1_2_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp312-cp312-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp312-cp312-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp312-cp312-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp313-cp313-linux_armv7l.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp313-cp313-macosx_11_0_universal2.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp313-cp313-musllinux_1_2_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp313-cp313-musllinux_1_2_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp313-cp313-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp313-cp313-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp313-cp313-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp314-cp314-linux_armv7l.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp314-cp314-macosx_11_0_universal2.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp314-cp314-musllinux_1_2_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp314-cp314-musllinux_1_2_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp314-cp314-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp314-cp314-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp314-cp314-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp39-cp39-linux_armv7l.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp39-cp39-macosx_11_0_universal2.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp39-cp39-manylinux2014_i686.manylinux_2_17_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp39-cp39-musllinux_1_2_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp39-cp39-musllinux_1_2_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp39-cp39-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp39-cp39-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "grpcio-1.76.0-cp39-cp39-win_amd64.whl"}, {"packagetype": "sdist", "filename": "grpcio-1.76.0.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 4 - Beta", "Environment :: Web Environment", "Framework :: AsyncIO", "Framework :: Trio", "Intended Audience :: Developers", "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Internet :: WWW/HTTP"], "name": "httpx", "requires_python": ">=3.6", "version": "0.16.1", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "httpx-0.16.1-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "httpx-0.16.1.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 4 - Beta", "Environment :: Web Environment", "Framework :: AsyncIO", "Framework :: Trio", "Intended Audience :: Developers", "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Internet :: WWW/HTTP"], "name": "httpx", "requires_python": ">=3.6", "version": "0.20.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "httpx-0.20.0-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "httpx-0.20.0.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 4 - Beta", "Environment :: Web Environment", "Framework :: AsyncIO", "Framework :: Trio", "Intended Audience :: Developers", "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Internet :: WWW/HTTP"], "name": "httpx", "requires_python": ">=3.6", "version": "0.22.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "httpx-0.22.0-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "httpx-0.22.0.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 4 - Beta", "Environment :: Web Environment", "Framework :: AsyncIO", "Framework :: Trio", "Intended Audience :: Developers", "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Internet :: WWW/HTTP"], "name": "httpx", "requires_python": ">=3.7", "version": "0.24.1", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "httpx-0.24.1-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "httpx-0.24.1.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 4 - Beta", "Environment :: Web Environment", "Framework :: AsyncIO", "Framework :: Trio", "Intended Audience :: Developers", "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Internet :: WWW/HTTP"], "name": "httpx", "requires_python": ">=3.8", "version": "0.26.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "httpx-0.26.0-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "httpx-0.26.0.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 4 - Beta", "Environment :: Web Environment", "Framework :: AsyncIO", "Framework :: Trio", "Intended Audience :: Developers", "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Internet :: WWW/HTTP"], "name": "httpx", "requires_python": ">=3.8", "version": "0.28.1", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "httpx-0.28.1-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "httpx-0.28.1.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 4 - Beta", "Environment :: Web Environment", "Framework :: Django", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python"], "name": "huey", "requires_python": null, "version": "0.1.1", "yanked": false}, "urls": [{"packagetype": "sdist", "filename": "huey-0.1.1.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 4 - Beta", "Environment :: Web Environment", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python"], "name": "huey", "requires_python": "", "version": "1.10.5", "yanked": false}, "urls": [{"packagetype": "sdist", "filename": "huey-1.10.5.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 4 - Beta", "Environment :: Web Environment", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python"], "name": "huey", "requires_python": "", "version": "1.7.0", "yanked": false}, "urls": [{"packagetype": "sdist", "filename": "huey-1.7.0.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 4 - Beta", "Environment :: Web Environment", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python"], "name": "huey", "requires_python": "", "version": "2.0.1", "yanked": false}, "urls": [{"packagetype": "sdist", "filename": "huey-2.0.1.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 4 - Beta", "Environment :: Web Environment", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7"], "name": "huey", "requires_python": "", "version": "2.1.3", "yanked": false}, "urls": [{"packagetype": "sdist", "filename": "huey-2.1.3.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "huey", "requires_python": null, "version": "2.5.5", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "huey-2.5.5-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "huey-2.5.5.tar.gz"}]} +{"info": {"classifiers": ["Intended Audience :: Developers", "Intended Audience :: Education", "Intended Audience :: Science/Research", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Scientific/Engineering :: Artificial Intelligence"], "name": "huggingface-hub", "requires_python": ">=3.8.0", "version": "0.24.7", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "huggingface_hub-0.24.7-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "huggingface_hub-0.24.7.tar.gz"}]} +{"info": {"classifiers": ["Intended Audience :: Developers", "Intended Audience :: Education", "Intended Audience :: Science/Research", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Scientific/Engineering :: Artificial Intelligence"], "name": "huggingface-hub", "requires_python": ">=3.8.0", "version": "0.36.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "huggingface_hub-0.36.0-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "huggingface_hub-0.36.0.tar.gz"}]} +{"info": {"classifiers": ["Intended Audience :: Developers", "Intended Audience :: Education", "Intended Audience :: Science/Research", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3.9", "Topic :: Scientific/Engineering :: Artificial Intelligence"], "name": "huggingface-hub", "requires_python": ">=3.9.0", "version": "1.2.3", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "huggingface_hub-1.2.3-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "huggingface_hub-1.2.3.tar.gz"}]} +{"info": {"classifiers": ["License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.9"], "name": "langchain", "requires_python": "<4.0,>=3.8.1", "version": "0.1.20", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "langchain-0.1.20-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "langchain-0.1.20.tar.gz"}]} +{"info": {"classifiers": [], "name": "langchain", "requires_python": "<4.0,>=3.9", "version": "0.3.27", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "langchain-0.3.27-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "langchain-0.3.27.tar.gz"}]} +{"info": {"classifiers": [], "name": "langchain", "requires_python": "<4.0.0,>=3.10.0", "version": "1.0.8", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "langchain-1.0.8-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "langchain-1.0.8.tar.gz"}]} +{"info": {"classifiers": [], "name": "langchain", "requires_python": "<4.0.0,>=3.10.0", "version": "1.2.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "langchain-1.2.0-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "langchain-1.2.0.tar.gz"}]} +{"info": {"classifiers": [], "name": "langgraph", "requires_python": ">=3.9", "version": "0.6.11", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "langgraph-0.6.11-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "langgraph-0.6.11.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy"], "name": "langgraph", "requires_python": ">=3.10", "version": "1.0.5", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "langgraph-1.0.5-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "langgraph-1.0.5.tar.gz"}]} +{"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3.9", "Topic :: Software Development", "Topic :: Software Development :: Libraries"], "name": "launchdarkly-server-sdk", "requires_python": ">=3.9", "version": "9.14.1", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "launchdarkly_server_sdk-9.14.1-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "launchdarkly_server_sdk-9.14.1.tar.gz"}]} +{"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Software Development", "Topic :: Software Development :: Libraries"], "name": "launchdarkly-server-sdk", "requires_python": ">=3.8", "version": "9.8.1", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "launchdarkly_server_sdk-9.8.1-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "launchdarkly_server_sdk-9.8.1.tar.gz"}]} +{"info": {"classifiers": ["License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9"], "name": "litellm", "requires_python": "!=2.7.*,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,!=3.7.*,>=3.8", "version": "1.77.7", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "litellm-1.77.7-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "litellm-1.77.7.tar.gz"}]} +{"info": {"classifiers": ["License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9"], "name": "litellm", "requires_python": "!=2.7.*,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,!=3.7.*,>=3.8", "version": "1.78.7", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "litellm-1.78.7-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "litellm-1.78.7.tar.gz"}]} +{"info": {"classifiers": ["License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9"], "name": "litellm", "requires_python": "!=2.7.*,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,!=3.7.*,>=3.8", "version": "1.79.3", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "litellm-1.79.3-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "litellm-1.79.3.tar.gz"}]} +{"info": {"classifiers": ["License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9"], "name": "litellm", "requires_python": "<4.0,>=3.9", "version": "1.80.10", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "litellm-1.80.10-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "litellm-1.80.10.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Framework :: AsyncIO", "Framework :: Pydantic", "Framework :: Pydantic :: 1", "Intended Audience :: Developers", "Intended Audience :: Information Technology", "Intended Audience :: System Administrators", "License :: OSI Approved :: MIT License", "Natural Language :: English", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: HTTP Servers", "Topic :: Software Development", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "litestar", "requires_python": ">=3.8,<4.0", "version": "2.0.1", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "litestar-2.0.1-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "litestar-2.0.1.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Intended Audience :: Developers", "Intended Audience :: Information Technology", "Intended Audience :: System Administrators", "License :: OSI Approved :: MIT License", "Natural Language :: English", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: HTTP Servers", "Topic :: Software Development", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "litestar", "requires_python": "<4.0,>=3.8", "version": "2.12.1", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "litestar-2.12.1-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "litestar-2.12.1.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Intended Audience :: Developers", "Intended Audience :: Information Technology", "Intended Audience :: System Administrators", "License :: OSI Approved :: MIT License", "Natural Language :: English", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: HTTP Servers", "Topic :: Software Development", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "litestar", "requires_python": "<4.0,>=3.8", "version": "2.19.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "litestar-2.19.0-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "litestar-2.19.0.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Intended Audience :: Developers", "Intended Audience :: Information Technology", "Intended Audience :: System Administrators", "License :: OSI Approved :: MIT License", "Natural Language :: English", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: HTTP Servers", "Topic :: Software Development", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "litestar", "requires_python": "<4.0,>=3.8", "version": "2.6.4", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "litestar-2.6.4-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "litestar-2.6.4.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Natural Language :: English", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: System :: Logging"], "name": "loguru", "requires_python": "<4.0,>=3.5", "version": "0.7.3", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "loguru-0.7.3-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "loguru-0.7.3.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 4 - Beta", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13"], "name": "mcp", "requires_python": ">=3.10", "version": "1.15.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "mcp-1.15.0-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "mcp-1.15.0.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 4 - Beta", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13"], "name": "mcp", "requires_python": ">=3.10", "version": "1.18.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "mcp-1.18.0-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "mcp-1.18.0.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 4 - Beta", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13"], "name": "mcp", "requires_python": ">=3.10", "version": "1.21.2", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "mcp-1.21.2-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "mcp-1.21.2.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 4 - Beta", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13"], "name": "mcp", "requires_python": ">=3.10", "version": "1.24.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "mcp-1.24.0-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "mcp-1.24.0.tar.gz"}]} +{"info": {"classifiers": ["Intended Audience :: Developers", "Operating System :: MacOS", "Operating System :: Microsoft :: Windows", "Operating System :: OS Independent", "Operating System :: POSIX", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "openai", "requires_python": ">=3.7.1", "version": "1.0.1", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "openai-1.0.1-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "openai-1.0.1.tar.gz"}]} +{"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: MacOS", "Operating System :: Microsoft :: Windows", "Operating System :: OS Independent", "Operating System :: POSIX", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "openai", "requires_python": ">=3.8", "version": "1.109.1", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "openai-1.109.1-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "openai-1.109.1.tar.gz"}]} +{"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: MacOS", "Operating System :: Microsoft :: Windows", "Operating System :: OS Independent", "Operating System :: POSIX", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "openai", "requires_python": ">=3.8", "version": "1.62.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "openai-1.62.0-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "openai-1.62.0.tar.gz"}]} +{"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: MacOS", "Operating System :: Microsoft :: Windows", "Operating System :: OS Independent", "Operating System :: POSIX", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "openai", "requires_python": ">=3.8", "version": "1.93.3", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "openai-1.93.3-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "openai-1.93.3.tar.gz"}]} +{"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: MacOS", "Operating System :: Microsoft :: Windows", "Operating System :: OS Independent", "Operating System :: POSIX", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3.9", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "openai", "requires_python": ">=3.9", "version": "2.12.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "openai-2.12.0-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "openai-2.12.0.tar.gz"}]} +{"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: MacOS", "Operating System :: Microsoft :: Windows", "Operating System :: OS Independent", "Operating System :: POSIX", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3.9", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "openai", "requires_python": ">=3.9", "version": "2.14.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "openai-2.14.0-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "openai-2.14.0.tar.gz"}]} +{"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: MacOS", "Operating System :: Microsoft :: Windows", "Operating System :: OS Independent", "Operating System :: POSIX", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "openai", "requires_python": ">=3.9", "version": "2.7.2", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "openai-2.7.2-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "openai-2.7.2.tar.gz"}]} +{"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.9", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "openai-agents", "requires_python": ">=3.9", "version": "0.0.19", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "openai_agents-0.0.19-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "openai_agents-0.0.19.tar.gz"}]} +{"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "openai-agents", "requires_python": ">=3.9", "version": "0.2.11", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "openai_agents-0.2.11-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "openai_agents-0.2.11.tar.gz"}]} +{"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "openai-agents", "requires_python": ">=3.9", "version": "0.4.2", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "openai_agents-0.4.2-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "openai_agents-0.4.2.tar.gz"}]} +{"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3.9", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "openai-agents", "requires_python": ">=3.9", "version": "0.6.4", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "openai_agents-0.6.4-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "openai_agents-0.6.4.tar.gz"}]} +{"info": {"classifiers": ["License :: OSI Approved :: Apache Software License", "Programming Language :: Python", "Programming Language :: Python :: 3"], "name": "openfeature-sdk", "requires_python": ">=3.8", "version": "0.7.5", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "openfeature_sdk-0.7.5-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "openfeature_sdk-0.7.5.tar.gz"}]} +{"info": {"classifiers": ["Programming Language :: Python", "Programming Language :: Python :: 3"], "name": "openfeature-sdk", "requires_python": ">=3.9", "version": "0.8.4", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "openfeature_sdk-0.8.4-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "openfeature_sdk-0.8.4.tar.gz"}]} +{"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8"], "name": "pure-eval", "requires_python": "", "version": "0.0.3", "yanked": false}, "urls": [{"packagetype": "sdist", "filename": "pure_eval-0.0.3.tar.gz"}]} +{"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9"], "name": "pure-eval", "requires_python": null, "version": "0.2.3", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "pure_eval-0.2.3-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "pure_eval-0.2.3.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Framework :: Pydantic", "Framework :: Pydantic :: 2", "Intended Audience :: Developers", "Intended Audience :: Information Technology", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Topic :: Internet", "Topic :: Scientific/Engineering :: Artificial Intelligence", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "pydantic-ai", "requires_python": ">=3.10", "version": "1.0.18", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "pydantic_ai-1.0.18-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "pydantic_ai-1.0.18.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Framework :: Pydantic", "Framework :: Pydantic :: 2", "Intended Audience :: Developers", "Intended Audience :: Information Technology", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Topic :: Internet", "Topic :: Scientific/Engineering :: Artificial Intelligence", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "pydantic-ai", "requires_python": ">=3.10", "version": "1.12.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "pydantic_ai-1.12.0-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "pydantic_ai-1.12.0.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Framework :: Pydantic", "Framework :: Pydantic :: 2", "Intended Audience :: Developers", "Intended Audience :: Information Technology", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Topic :: Internet", "Topic :: Scientific/Engineering :: Artificial Intelligence", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "pydantic-ai", "requires_python": ">=3.10", "version": "1.24.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "pydantic_ai-1.24.0-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "pydantic_ai-1.24.0.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Framework :: Pydantic", "Framework :: Pydantic :: 2", "Intended Audience :: Developers", "Intended Audience :: Information Technology", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Topic :: Internet", "Topic :: Scientific/Engineering :: Artificial Intelligence", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "pydantic-ai", "requires_python": ">=3.10", "version": "1.36.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "pydantic_ai-1.36.0-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "pydantic_ai-1.36.0.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 3 - Alpha", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: MacOS :: MacOS X", "Operating System :: Microsoft :: Windows", "Operating System :: POSIX", "Programming Language :: Python", "Topic :: Database"], "name": "pymongo", "requires_python": null, "version": "0.6", "yanked": false}, "urls": [{"packagetype": "bdist_egg", "filename": "pymongo-0.6-py2.5-macosx-10.5-i386.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-0.6-py2.6-macosx-10.5-i386.egg"}, {"packagetype": "sdist", "filename": "pymongo-0.6.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: MacOS :: MacOS X", "Operating System :: Microsoft :: Windows", "Operating System :: POSIX", "Programming Language :: Python :: 2", "Programming Language :: Python :: 2.4", "Programming Language :: Python :: 2.5", "Programming Language :: Python :: 2.6", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.1", "Programming Language :: Python :: 3.2", "Programming Language :: Python :: 3.3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: Jython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Database"], "name": "pymongo", "requires_python": null, "version": "2.8.1", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "pymongo-2.8.1-cp26-none-macosx_10_10_intel.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-2.8.1-cp26-none-macosx_10_8_intel.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-2.8.1-cp26-none-macosx_10_9_intel.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-2.8.1-cp26-none-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-2.8.1-cp26-none-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-2.8.1-cp27-none-macosx_10_10_intel.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-2.8.1-cp27-none-macosx_10_8_intel.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-2.8.1-cp27-none-macosx_10_9_intel.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-2.8.1-cp27-none-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-2.8.1-cp27-none-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-2.8.1-cp32-cp32m-macosx_10_6_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-2.8.1-cp32-none-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-2.8.1-cp32-none-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-2.8.1-cp33-cp33m-macosx_10_6_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-2.8.1-cp33-none-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-2.8.1-cp33-none-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-2.8.1-cp34-cp34m-macosx_10_6_intel.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-2.8.1-cp34-none-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-2.8.1-cp34-none-win_amd64.whl"}, {"packagetype": "bdist_egg", "filename": "pymongo-2.8.1-py2.4-win32.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-2.8.1-py2.5-macosx-10.8-x86_64.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-2.8.1-py2.5-macosx-10.9-x86_64.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-2.8.1-py2.5-win32.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-2.8.1-py2.6-macosx-10.10-intel.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-2.8.1-py2.6-macosx-10.8-intel.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-2.8.1-py2.6-macosx-10.9-intel.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-2.8.1-py2.6-win32.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-2.8.1-py2.6-win-amd64.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-2.8.1-py2.7-macosx-10.10-intel.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-2.8.1-py2.7-macosx-10.8-intel.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-2.8.1-py2.7-macosx-10.9-intel.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-2.8.1-py2.7-win32.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-2.8.1-py2.7-win-amd64.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-2.8.1-py3.2-macosx-10.6-x86_64.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-2.8.1-py3.2-win32.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-2.8.1-py3.2-win-amd64.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-2.8.1-py3.3-macosx-10.6-x86_64.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-2.8.1-py3.3-win32.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-2.8.1-py3.3-win-amd64.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-2.8.1-py3.4-macosx-10.6-intel.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-2.8.1-py3.4-win32.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-2.8.1-py3.4-win-amd64.egg"}, {"packagetype": "sdist", "filename": "pymongo-2.8.1.tar.gz"}, {"packagetype": "bdist_wininst", "filename": "pymongo-2.8.1.win32-py2.4.exe"}, {"packagetype": "bdist_wininst", "filename": "pymongo-2.8.1.win32-py2.5.exe"}, {"packagetype": "bdist_wininst", "filename": "pymongo-2.8.1.win32-py2.6.exe"}, {"packagetype": "bdist_wininst", "filename": "pymongo-2.8.1.win32-py2.7.exe"}, {"packagetype": "bdist_wininst", "filename": "pymongo-2.8.1.win32-py3.2.exe"}, {"packagetype": "bdist_wininst", "filename": "pymongo-2.8.1.win32-py3.3.exe"}, {"packagetype": "bdist_wininst", "filename": "pymongo-2.8.1.win32-py3.4.exe"}, {"packagetype": "bdist_wininst", "filename": "pymongo-2.8.1.win-amd64-py2.6.exe"}, {"packagetype": "bdist_wininst", "filename": "pymongo-2.8.1.win-amd64-py2.7.exe"}, {"packagetype": "bdist_wininst", "filename": "pymongo-2.8.1.win-amd64-py3.2.exe"}, {"packagetype": "bdist_wininst", "filename": "pymongo-2.8.1.win-amd64-py3.3.exe"}, {"packagetype": "bdist_wininst", "filename": "pymongo-2.8.1.win-amd64-py3.4.exe"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: MacOS :: MacOS X", "Operating System :: Microsoft :: Windows", "Operating System :: POSIX", "Programming Language :: Python :: 2", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Database"], "name": "pymongo", "requires_python": "", "version": "3.13.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp27-cp27m-macosx_10_14_intel.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp27-cp27m-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp27-cp27m-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp27-cp27m-manylinux_2_5_i686.manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp27-cp27mu-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp27-cp27mu-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp27-cp27mu-manylinux_2_5_i686.manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp27-cp27m-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp27-cp27m-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp310-cp310-macosx_10_9_universal2.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp310-cp310-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp310-cp310-manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp310-cp310-manylinux2014_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp310-cp310-manylinux2014_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp310-cp310-manylinux2014_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp310-cp310-manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp310-cp310-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp310-cp310-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp311-cp311-macosx_10_9_universal2.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp311-cp311-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp311-cp311-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp35-cp35m-macosx_10_6_intel.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp35-cp35m-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp35-cp35m-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp35-cp35m-manylinux_2_5_i686.manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp35-cp35m-manylinux_2_5_x86_64.manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp35-cp35m-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp35-cp35m-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp36-cp36m-macosx_10_6_intel.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp36-cp36m-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp36-cp36m-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp36-cp36m-manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp36-cp36m-manylinux2014_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp36-cp36m-manylinux2014_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp36-cp36m-manylinux2014_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp36-cp36m-manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp36-cp36m-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp36-cp36m-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp37-cp37m-macosx_10_6_intel.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp37-cp37m-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp37-cp37m-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp37-cp37m-manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp37-cp37m-manylinux2014_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp37-cp37m-manylinux2014_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp37-cp37m-manylinux2014_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp37-cp37m-manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp37-cp37m-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp37-cp37m-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp38-cp38-macosx_10_9_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp38-cp38-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp38-cp38-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp38-cp38-manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp38-cp38-manylinux2014_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp38-cp38-manylinux2014_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp38-cp38-manylinux2014_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp38-cp38-manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp38-cp38-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp38-cp38-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp39-cp39-macosx_10_9_universal2.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp39-cp39-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp39-cp39-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp39-cp39-manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp39-cp39-manylinux2014_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp39-cp39-manylinux2014_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp39-cp39-manylinux2014_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp39-cp39-manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp39-cp39-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.13.0-cp39-cp39-win_amd64.whl"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.13.0-py2.7-macosx-10.14-intel.egg"}, {"packagetype": "sdist", "filename": "pymongo-3.13.0.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: MacOS :: MacOS X", "Operating System :: Microsoft :: Windows", "Operating System :: POSIX", "Programming Language :: Python :: 2", "Programming Language :: Python :: 2.6", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.2", "Programming Language :: Python :: 3.3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Database"], "name": "pymongo", "requires_python": null, "version": "3.2.2", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "pymongo-3.2.2-cp26-none-macosx_10_10_intel.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.2.2-cp26-none-macosx_10_11_intel.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.2.2-cp26-none-macosx_10_8_intel.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.2.2-cp26-none-macosx_10_9_intel.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.2.2-cp26-none-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.2.2-cp26-none-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.2.2-cp27-none-macosx_10_10_intel.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.2.2-cp27-none-macosx_10_11_intel.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.2.2-cp27-none-macosx_10_8_intel.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.2.2-cp27-none-macosx_10_9_intel.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.2.2-cp27-none-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.2.2-cp27-none-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.2.2-cp32-cp32m-macosx_10_6_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.2.2-cp32-none-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.2.2-cp32-none-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.2.2-cp33-cp33m-macosx_10_6_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.2.2-cp33-none-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.2.2-cp33-none-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.2.2-cp34-cp34m-macosx_10_6_intel.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.2.2-cp34-none-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.2.2-cp34-none-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.2.2-cp35-cp35m-macosx_10_6_intel.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.2.2-cp35-none-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.2.2-cp35-none-win_amd64.whl"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.2.2-py2.6-macosx-10.10-intel.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.2.2-py2.6-macosx-10.11-intel.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.2.2-py2.6-macosx-10.8-intel.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.2.2-py2.6-macosx-10.9-intel.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.2.2-py2.6-win32.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.2.2-py2.6-win-amd64.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.2.2-py2.7-macosx-10.10-intel.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.2.2-py2.7-macosx-10.11-intel.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.2.2-py2.7-macosx-10.8-intel.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.2.2-py2.7-macosx-10.9-intel.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.2.2-py2.7-win32.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.2.2-py2.7-win-amd64.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.2.2-py3.2-macosx-10.6-x86_64.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.2.2-py3.2-win32.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.2.2-py3.2-win-amd64.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.2.2-py3.3-macosx-10.6-x86_64.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.2.2-py3.3-win32.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.2.2-py3.3-win-amd64.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.2.2-py3.4-macosx-10.6-intel.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.2.2-py3.4-win32.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.2.2-py3.4-win-amd64.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.2.2-py3.5-macosx-10.6-intel.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.2.2-py3.5-win32.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.2.2-py3.5-win-amd64.egg"}, {"packagetype": "sdist", "filename": "pymongo-3.2.2.tar.gz"}, {"packagetype": "bdist_wininst", "filename": "pymongo-3.2.2.win32-py2.6.exe"}, {"packagetype": "bdist_wininst", "filename": "pymongo-3.2.2.win32-py2.7.exe"}, {"packagetype": "bdist_wininst", "filename": "pymongo-3.2.2.win32-py3.2.exe"}, {"packagetype": "bdist_wininst", "filename": "pymongo-3.2.2.win32-py3.3.exe"}, {"packagetype": "bdist_wininst", "filename": "pymongo-3.2.2.win32-py3.4.exe"}, {"packagetype": "bdist_wininst", "filename": "pymongo-3.2.2.win32-py3.5.exe"}, {"packagetype": "bdist_wininst", "filename": "pymongo-3.2.2.win-amd64-py2.6.exe"}, {"packagetype": "bdist_wininst", "filename": "pymongo-3.2.2.win-amd64-py2.7.exe"}, {"packagetype": "bdist_wininst", "filename": "pymongo-3.2.2.win-amd64-py3.2.exe"}, {"packagetype": "bdist_wininst", "filename": "pymongo-3.2.2.win-amd64-py3.3.exe"}, {"packagetype": "bdist_wininst", "filename": "pymongo-3.2.2.win-amd64-py3.4.exe"}, {"packagetype": "bdist_wininst", "filename": "pymongo-3.2.2.win-amd64-py3.5.exe"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: MacOS :: MacOS X", "Operating System :: Microsoft :: Windows", "Operating System :: POSIX", "Programming Language :: Python :: 2", "Programming Language :: Python :: 2.6", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Database"], "name": "pymongo", "requires_python": "", "version": "3.4.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "pymongo-3.4.0-cp26-cp26m-macosx_10_11_intel.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.4.0-cp26-cp26m-macosx_10_12_intel.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.4.0-cp26-cp26m-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.4.0-cp26-cp26m-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.4.0-cp26-cp26mu-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.4.0-cp26-cp26mu-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.4.0-cp26-none-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.4.0-cp26-none-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.4.0-cp27-cp27m-macosx_10_11_intel.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.4.0-cp27-cp27m-macosx_10_12_intel.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.4.0-cp27-cp27m-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.4.0-cp27-cp27m-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.4.0-cp27-cp27mu-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.4.0-cp27-cp27mu-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.4.0-cp27-none-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.4.0-cp27-none-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.4.0-cp33-cp33m-macosx_10_6_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.4.0-cp33-cp33m-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.4.0-cp33-cp33m-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.4.0-cp33-none-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.4.0-cp33-none-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.4.0-cp34-cp34m-macosx_10_6_intel.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.4.0-cp34-cp34m-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.4.0-cp34-cp34m-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.4.0-cp34-none-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.4.0-cp34-none-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.4.0-cp35-cp35m-macosx_10_6_intel.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.4.0-cp35-cp35m-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.4.0-cp35-cp35m-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.4.0-cp35-none-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.4.0-cp35-none-win_amd64.whl"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.4.0-py2.6-macosx-10.11-intel.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.4.0-py2.6-macosx-10.12-intel.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.4.0-py2.6-win32.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.4.0-py2.6-win-amd64.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.4.0-py2.7-macosx-10.11-intel.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.4.0-py2.7-macosx-10.12-intel.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.4.0-py2.7-win32.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.4.0-py2.7-win-amd64.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.4.0-py3.3-macosx-10.6-x86_64.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.4.0-py3.3-win32.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.4.0-py3.3-win-amd64.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.4.0-py3.4-macosx-10.6-intel.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.4.0-py3.4-win32.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.4.0-py3.4-win-amd64.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.4.0-py3.5-macosx-10.6-intel.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.4.0-py3.5-win32.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.4.0-py3.5-win-amd64.egg"}, {"packagetype": "sdist", "filename": "pymongo-3.4.0.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: MacOS :: MacOS X", "Operating System :: Microsoft :: Windows", "Operating System :: POSIX", "Programming Language :: Python :: 2", "Programming Language :: Python :: 2.6", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Database"], "name": "pymongo", "requires_python": "", "version": "3.5.1", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "pymongo-3.5.1-cp26-cp26m-macosx_10_12_intel.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.5.1-cp26-cp26m-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.5.1-cp26-cp26m-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.5.1-cp26-cp26mu-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.5.1-cp26-cp26mu-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.5.1-cp26-cp26m-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.5.1-cp26-cp26m-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.5.1-cp27-cp27m-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.5.1-cp27-cp27m-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.5.1-cp27-cp27mu-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.5.1-cp27-cp27mu-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.5.1-cp27-cp27m-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.5.1-cp27-cp27m-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.5.1-cp27-none-macosx_10_12_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.5.1-cp33-cp33m-macosx_10_6_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.5.1-cp33-cp33m-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.5.1-cp33-cp33m-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.5.1-cp33-cp33m-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.5.1-cp33-cp33m-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.5.1-cp34-cp34m-macosx_10_6_intel.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.5.1-cp34-cp34m-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.5.1-cp34-cp34m-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.5.1-cp34-cp34m-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.5.1-cp34-cp34m-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.5.1-cp35-cp35m-macosx_10_6_intel.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.5.1-cp35-cp35m-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.5.1-cp35-cp35m-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.5.1-cp35-cp35m-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.5.1-cp35-cp35m-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.5.1-cp36-cp36m-macosx_10_6_intel.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.5.1-cp36-cp36m-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.5.1-cp36-cp36m-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.5.1-cp36-cp36m-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.5.1-cp36-cp36m-win_amd64.whl"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.5.1-py2.6-macosx-10.12-intel.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.5.1-py2.6-win32.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.5.1-py2.6-win-amd64.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.5.1-py2.7-macosx-10.12-x86_64.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.5.1-py2.7-win32.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.5.1-py2.7-win-amd64.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.5.1-py3.3-macosx-10.6-x86_64.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.5.1-py3.3-win32.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.5.1-py3.3-win-amd64.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.5.1-py3.4-macosx-10.6-intel.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.5.1-py3.4-win32.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.5.1-py3.4-win-amd64.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.5.1-py3.5-macosx-10.6-intel.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.5.1-py3.5-win32.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.5.1-py3.5-win-amd64.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.5.1-py3.6-macosx-10.6-intel.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.5.1-py3.6-win32.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.5.1-py3.6-win-amd64.egg"}, {"packagetype": "sdist", "filename": "pymongo-3.5.1.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: MacOS :: MacOS X", "Operating System :: Microsoft :: Windows", "Operating System :: POSIX", "Programming Language :: Python :: 2", "Programming Language :: Python :: 2.6", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Database"], "name": "pymongo", "requires_python": "", "version": "3.6.1", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "pymongo-3.6.1-cp26-cp26m-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.6.1-cp26-cp26m-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.6.1-cp26-cp26mu-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.6.1-cp26-cp26mu-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.6.1-cp26-cp26m-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.6.1-cp26-cp26m-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.6.1-cp27-cp27m-macosx_10_13_intel.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.6.1-cp27-cp27m-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.6.1-cp27-cp27m-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.6.1-cp27-cp27mu-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.6.1-cp27-cp27mu-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.6.1-cp27-cp27m-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.6.1-cp27-cp27m-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.6.1-cp34-cp34m-macosx_10_6_intel.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.6.1-cp34-cp34m-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.6.1-cp34-cp34m-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.6.1-cp34-cp34m-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.6.1-cp34-cp34m-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.6.1-cp35-cp35m-macosx_10_6_intel.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.6.1-cp35-cp35m-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.6.1-cp35-cp35m-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.6.1-cp35-cp35m-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.6.1-cp35-cp35m-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.6.1-cp36-cp36m-macosx_10_6_intel.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.6.1-cp36-cp36m-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.6.1-cp36-cp36m-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.6.1-cp36-cp36m-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-3.6.1-cp36-cp36m-win_amd64.whl"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.6.1-py2.6-win32.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.6.1-py2.6-win-amd64.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.6.1-py2.7-macosx-10.13-intel.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.6.1-py2.7-win32.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.6.1-py2.7-win-amd64.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.6.1-py3.4-macosx-10.6-intel.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.6.1-py3.4-win32.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.6.1-py3.4-win-amd64.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.6.1-py3.5-macosx-10.6-intel.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.6.1-py3.5-win32.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.6.1-py3.5-win-amd64.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.6.1-py3.6-macosx-10.6-intel.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.6.1-py3.6-win32.egg"}, {"packagetype": "bdist_egg", "filename": "pymongo-3.6.1-py3.6-win-amd64.egg"}, {"packagetype": "sdist", "filename": "pymongo-3.6.1.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: MacOS :: MacOS X", "Operating System :: Microsoft :: Windows", "Operating System :: POSIX", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Database"], "name": "pymongo", "requires_python": ">=3.6", "version": "4.0.2", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp310-cp310-macosx_10_9_universal2.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp310-cp310-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp310-cp310-manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp310-cp310-manylinux2014_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp310-cp310-manylinux2014_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp310-cp310-manylinux2014_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp310-cp310-manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp310-cp310-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp310-cp310-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp36-cp36m-macosx_10_6_intel.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp36-cp36m-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp36-cp36m-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp36-cp36m-manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp36-cp36m-manylinux2014_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp36-cp36m-manylinux2014_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp36-cp36m-manylinux2014_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp36-cp36m-manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp36-cp36m-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp36-cp36m-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp37-cp37m-macosx_10_6_intel.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp37-cp37m-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp37-cp37m-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp37-cp37m-manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp37-cp37m-manylinux2014_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp37-cp37m-manylinux2014_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp37-cp37m-manylinux2014_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp37-cp37m-manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp37-cp37m-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp37-cp37m-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp38-cp38-macosx_10_9_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp38-cp38-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp38-cp38-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp38-cp38-manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp38-cp38-manylinux2014_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp38-cp38-manylinux2014_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp38-cp38-manylinux2014_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp38-cp38-manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp38-cp38-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp38-cp38-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp39-cp39-macosx_10_9_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp39-cp39-manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp39-cp39-manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp39-cp39-manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp39-cp39-manylinux2014_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp39-cp39-manylinux2014_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp39-cp39-manylinux2014_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp39-cp39-manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp39-cp39-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.0.2-cp39-cp39-win_amd64.whl"}, {"packagetype": "sdist", "filename": "pymongo-4.0.2.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: MacOS :: MacOS X", "Operating System :: Microsoft :: Windows", "Operating System :: POSIX", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Database", "Typing :: Typed"], "name": "pymongo", "requires_python": ">=3.9", "version": "4.15.5", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp310-cp310-macosx_10_9_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp310-cp310-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp310-cp310-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp310-cp310-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp310-cp310-win_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp311-cp311-macosx_10_9_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp311-cp311-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp311-cp311-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp311-cp311-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp311-cp311-win_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp312-cp312-macosx_10_13_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp312-cp312-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp312-cp312-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp312-cp312-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp312-cp312-win_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp313-cp313-macosx_10_13_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp313-cp313-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp313-cp313-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp313-cp313-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp313-cp313-win_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp314-cp314-macosx_10_15_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp314-cp314-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp314-cp314t-macosx_10_15_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp314-cp314t-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp314-cp314t-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp314-cp314t-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp314-cp314t-win_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp314-cp314-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp314-cp314-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp314-cp314-win_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp39-cp39-macosx_10_9_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp39-cp39-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp39-cp39-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp39-cp39-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "pymongo-4.15.5-cp39-cp39-win_arm64.whl"}, {"packagetype": "sdist", "filename": "pymongo-4.15.5.tar.gz"}]} +{"info": {"classifiers": ["Framework :: Pylons", "Intended Audience :: Developers", "License :: Repoze Public License", "Programming Language :: Python", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: WSGI"], "name": "pyramid", "requires_python": null, "version": "1.0.2", "yanked": false}, "urls": [{"packagetype": "sdist", "filename": "pyramid-1.0.2.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 6 - Mature", "Framework :: Pyramid", "Intended Audience :: Developers", "License :: Repoze Public License", "Programming Language :: Python", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: WSGI"], "name": "pyramid", "requires_python": ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*", "version": "1.10.8", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "pyramid-1.10.8-py2.py3-none-any.whl"}, {"packagetype": "sdist", "filename": "pyramid-1.10.8.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 6 - Mature", "Framework :: Pyramid", "Intended Audience :: Developers", "License :: Repoze Public License", "Programming Language :: Python", "Programming Language :: Python :: 2.6", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.2", "Programming Language :: Python :: 3.3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: WSGI"], "name": "pyramid", "requires_python": "", "version": "1.6.5", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "pyramid-1.6.5-py2.py3-none-any.whl"}, {"packagetype": "sdist", "filename": "pyramid-1.6.5.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 6 - Mature", "Framework :: Pyramid", "Intended Audience :: Developers", "License :: Repoze Public License", "Programming Language :: Python", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: WSGI"], "name": "pyramid", "requires_python": "", "version": "1.7.6", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "pyramid-1.7.6-py2.py3-none-any.whl"}, {"packagetype": "sdist", "filename": "pyramid-1.7.6.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 6 - Mature", "Framework :: Pyramid", "Intended Audience :: Developers", "License :: Repoze Public License", "Programming Language :: Python", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: WSGI"], "name": "pyramid", "requires_python": "", "version": "1.8.6", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "pyramid-1.8.6-py2.py3-none-any.whl"}, {"packagetype": "sdist", "filename": "pyramid-1.8.6.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 6 - Mature", "Framework :: Pyramid", "Intended Audience :: Developers", "License :: Repoze Public License", "Programming Language :: Python", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: WSGI"], "name": "pyramid", "requires_python": ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*", "version": "1.9.4", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "pyramid-1.9.4-py2.py3-none-any.whl"}, {"packagetype": "sdist", "filename": "pyramid-1.9.4.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 6 - Mature", "Framework :: Pyramid", "Intended Audience :: Developers", "License :: Repoze Public License", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: WSGI"], "name": "pyramid", "requires_python": ">=3.6", "version": "2.0.2", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "pyramid-2.0.2-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "pyramid-2.0.2.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "License :: OSI Approved :: Apache Software License", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy"], "name": "pyspark", "requires_python": "", "version": "2.1.3", "yanked": false}, "urls": [{"packagetype": "sdist", "filename": "pyspark-2.1.3.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "License :: OSI Approved :: Apache Software License", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy"], "name": "pyspark", "requires_python": "", "version": "2.4.8", "yanked": false}, "urls": [{"packagetype": "sdist", "filename": "pyspark-2.4.8.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "License :: OSI Approved :: Apache Software License", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy"], "name": "pyspark", "requires_python": "", "version": "3.0.3", "yanked": false}, "urls": [{"packagetype": "sdist", "filename": "pyspark-3.0.3.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "License :: OSI Approved :: Apache Software License", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Typing :: Typed"], "name": "pyspark", "requires_python": ">=3.6", "version": "3.1.3", "yanked": false}, "urls": [{"packagetype": "sdist", "filename": "pyspark-3.1.3.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "License :: OSI Approved :: Apache Software License", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Typing :: Typed"], "name": "pyspark", "requires_python": ">=3.6", "version": "3.2.4", "yanked": false}, "urls": [{"packagetype": "sdist", "filename": "pyspark-3.2.4.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "License :: OSI Approved :: Apache Software License", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Typing :: Typed"], "name": "pyspark", "requires_python": ">=3.8", "version": "3.5.7", "yanked": false}, "urls": [{"packagetype": "sdist", "filename": "pyspark-3.5.7.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Typing :: Typed"], "name": "pyspark", "requires_python": ">=3.10", "version": "4.1.0", "yanked": false}, "urls": [{"packagetype": "sdist", "filename": "pyspark-4.1.0.tar.gz"}]} +{"info": {"classifiers": ["Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9"], "name": "ray", "requires_python": ">=3.8", "version": "2.36.1", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "ray-2.36.1-cp310-cp310-macosx_10_15_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.36.1-cp310-cp310-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.36.1-cp310-cp310-manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.36.1-cp310-cp310-manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.36.1-cp310-cp310-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.36.1-cp311-cp311-macosx_10_15_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.36.1-cp311-cp311-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.36.1-cp311-cp311-manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.36.1-cp311-cp311-manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.36.1-cp311-cp311-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.36.1-cp312-cp312-macosx_10_15_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.36.1-cp312-cp312-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.36.1-cp312-cp312-manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.36.1-cp312-cp312-manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.36.1-cp312-cp312-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.36.1-cp39-cp39-macosx_10_15_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.36.1-cp39-cp39-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.36.1-cp39-cp39-manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.36.1-cp39-cp39-manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.36.1-cp39-cp39-win_amd64.whl"}]} +{"info": {"classifiers": ["Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.9"], "name": "ray", "requires_python": ">=3.9", "version": "2.45.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "ray-2.45.0-cp310-cp310-macosx_10_15_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.45.0-cp310-cp310-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.45.0-cp310-cp310-manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.45.0-cp310-cp310-manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.45.0-cp310-cp310-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.45.0-cp311-cp311-macosx_10_15_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.45.0-cp311-cp311-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.45.0-cp311-cp311-manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.45.0-cp311-cp311-manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.45.0-cp311-cp311-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.45.0-cp312-cp312-macosx_10_15_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.45.0-cp312-cp312-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.45.0-cp312-cp312-manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.45.0-cp312-cp312-manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.45.0-cp312-cp312-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.45.0-cp313-cp313-macosx_10_15_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.45.0-cp313-cp313-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.45.0-cp313-cp313-manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.45.0-cp313-cp313-manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.45.0-cp39-cp39-macosx_10_15_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.45.0-cp39-cp39-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.45.0-cp39-cp39-manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.45.0-cp39-cp39-manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.45.0-cp39-cp39-win_amd64.whl"}]} +{"info": {"classifiers": ["Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9"], "name": "ray", "requires_python": ">=3.9", "version": "2.49.2", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "ray-2.49.2-cp310-cp310-macosx_12_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.49.2-cp310-cp310-macosx_12_0_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.49.2-cp310-cp310-manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.49.2-cp310-cp310-manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.49.2-cp310-cp310-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.49.2-cp311-cp311-macosx_12_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.49.2-cp311-cp311-macosx_12_0_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.49.2-cp311-cp311-manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.49.2-cp311-cp311-manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.49.2-cp311-cp311-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.49.2-cp312-cp312-macosx_12_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.49.2-cp312-cp312-macosx_12_0_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.49.2-cp312-cp312-manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.49.2-cp312-cp312-manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.49.2-cp312-cp312-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.49.2-cp313-cp313-macosx_12_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.49.2-cp313-cp313-macosx_12_0_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.49.2-cp313-cp313-manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.49.2-cp313-cp313-manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.49.2-cp39-cp39-macosx_12_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.49.2-cp39-cp39-macosx_12_0_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.49.2-cp39-cp39-manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.49.2-cp39-cp39-manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.49.2-cp39-cp39-win_amd64.whl"}]} +{"info": {"classifiers": ["Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9"], "name": "ray", "requires_python": ">=3.9", "version": "2.51.2", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "ray-2.51.2-cp310-cp310-macosx_12_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.51.2-cp310-cp310-manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.51.2-cp310-cp310-manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.51.2-cp310-cp310-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.51.2-cp311-cp311-macosx_12_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.51.2-cp311-cp311-manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.51.2-cp311-cp311-manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.51.2-cp311-cp311-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.51.2-cp312-cp312-macosx_12_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.51.2-cp312-cp312-manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.51.2-cp312-cp312-manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.51.2-cp312-cp312-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.51.2-cp313-cp313-macosx_12_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.51.2-cp313-cp313-manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.51.2-cp313-cp313-manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.51.2-cp39-cp39-macosx_12_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.51.2-cp39-cp39-manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.51.2-cp39-cp39-manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.51.2-cp39-cp39-win_amd64.whl"}]} +{"info": {"classifiers": ["Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9"], "name": "ray", "requires_python": ">=3.9", "version": "2.52.1", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "ray-2.52.1-cp310-cp310-macosx_12_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.52.1-cp310-cp310-manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.52.1-cp310-cp310-manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.52.1-cp310-cp310-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.52.1-cp311-cp311-macosx_12_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.52.1-cp311-cp311-manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.52.1-cp311-cp311-manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.52.1-cp311-cp311-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.52.1-cp312-cp312-macosx_12_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.52.1-cp312-cp312-manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.52.1-cp312-cp312-manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.52.1-cp312-cp312-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.52.1-cp313-cp313-macosx_12_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.52.1-cp313-cp313-manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.52.1-cp313-cp313-manylinux2014_x86_64.whl"}]} +{"info": {"classifiers": ["Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9"], "name": "ray", "requires_python": "", "version": "2.7.2", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "ray-2.7.2-cp310-cp310-macosx_10_15_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.7.2-cp310-cp310-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.7.2-cp310-cp310-manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.7.2-cp310-cp310-manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.7.2-cp310-cp310-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.7.2-cp311-cp311-macosx_10_15_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.7.2-cp311-cp311-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.7.2-cp311-cp311-manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.7.2-cp311-cp311-manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.7.2-cp311-cp311-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.7.2-cp37-cp37m-macosx_10_15_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.7.2-cp37-cp37m-manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.7.2-cp37-cp37m-manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.7.2-cp37-cp37m-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.7.2-cp38-cp38-macosx_10_15_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.7.2-cp38-cp38-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.7.2-cp38-cp38-manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.7.2-cp38-cp38-manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.7.2-cp38-cp38-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.7.2-cp39-cp39-macosx_10_15_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.7.2-cp39-cp39-macosx_11_0_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.7.2-cp39-cp39-manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.7.2-cp39-cp39-manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "ray-2.7.2-cp39-cp39-win_amd64.whl"}]} +{"info": {"classifiers": ["Development Status :: 4 - Beta", "Environment :: Console", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python"], "name": "redis", "requires_python": null, "version": "0.6.1", "yanked": false}, "urls": [{"packagetype": "sdist", "filename": "redis-0.6.1.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Console", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 2", "Programming Language :: Python :: 2.6", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6"], "name": "redis", "requires_python": "", "version": "2.10.6", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "redis-2.10.6-py2.py3-none-any.whl"}, {"packagetype": "sdist", "filename": "redis-2.10.6.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Console", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 2.5", "Programming Language :: Python :: 2.6", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.2", "Programming Language :: Python :: 3.3"], "name": "redis", "requires_python": null, "version": "2.9.1", "yanked": false}, "urls": [{"packagetype": "sdist", "filename": "redis-2.9.1.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Console", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 2", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6"], "name": "redis", "requires_python": ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*", "version": "3.0.1", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "redis-3.0.1-py2.py3-none-any.whl"}, {"packagetype": "sdist", "filename": "redis-3.0.1.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Console", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 2", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7"], "name": "redis", "requires_python": ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*", "version": "3.1.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "redis-3.1.0-py2.py3-none-any.whl"}, {"packagetype": "sdist", "filename": "redis-3.1.0.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Console", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 2", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7"], "name": "redis", "requires_python": ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*", "version": "3.3.11", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "redis-3.3.11-py2.py3-none-any.whl"}, {"packagetype": "sdist", "filename": "redis-3.3.11.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Console", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 2", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy"], "name": "redis", "requires_python": ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*", "version": "3.5.3", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "redis-3.5.3-py2.py3-none-any.whl"}, {"packagetype": "sdist", "filename": "redis-3.5.3.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Console", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy"], "name": "redis", "requires_python": ">=3.6", "version": "4.1.4", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "redis-4.1.4-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "redis-4.1.4.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Console", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy"], "name": "redis", "requires_python": ">=3.7", "version": "4.6.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "redis-4.6.0-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "redis-4.6.0.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Console", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy"], "name": "redis", "requires_python": ">=3.8", "version": "5.3.1", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "redis-5.3.1-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "redis-5.3.1.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Console", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy"], "name": "redis", "requires_python": ">=3.9", "version": "6.4.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "redis-6.4.0-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "redis-6.4.0.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Console", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy"], "name": "redis", "requires_python": ">=3.10", "version": "7.1.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "redis-7.1.0-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "redis-7.1.0.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 3 - Alpha", "Environment :: Web Environment", "Operating System :: POSIX", "Programming Language :: Python", "Programming Language :: Python :: 2", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.2", "Programming Language :: Python :: 3.3", "Programming Language :: Python :: 3.4"], "name": "redis-py-cluster", "requires_python": null, "version": "0.1.0", "yanked": false}, "urls": [{"packagetype": "sdist", "filename": "redis-py-cluster-0.1.0.tar.gz"}]} +{"info": {"classifiers": [], "name": "redis-py-cluster", "requires_python": null, "version": "1.1.0", "yanked": false}, "urls": [{"packagetype": "sdist", "filename": "redis-py-cluster-1.1.0.tar.gz"}]} +{"info": {"classifiers": [], "name": "redis-py-cluster", "requires_python": null, "version": "1.2.0", "yanked": false}, "urls": [{"packagetype": "sdist", "filename": "redis-py-cluster-1.2.0.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "License :: OSI Approved :: MIT License", "Operating System :: POSIX", "Programming Language :: Python", "Programming Language :: Python :: 2", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6"], "name": "redis-py-cluster", "requires_python": "", "version": "1.3.6", "yanked": false}, "urls": [{"packagetype": "sdist", "filename": "redis-py-cluster-1.3.6.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "License :: OSI Approved :: MIT License", "Operating System :: POSIX", "Programming Language :: Python", "Programming Language :: Python :: 2", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7"], "name": "redis-py-cluster", "requires_python": ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*", "version": "2.0.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "redis_py_cluster-2.0.0-py2.py3-none-any.whl"}, {"packagetype": "sdist", "filename": "redis-py-cluster-2.0.0.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "License :: OSI Approved :: MIT License", "Operating System :: POSIX", "Programming Language :: Python", "Programming Language :: Python :: 2", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8"], "name": "redis-py-cluster", "requires_python": ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4", "version": "2.1.3", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "redis_py_cluster-2.1.3-py2.py3-none-any.whl"}, {"packagetype": "sdist", "filename": "redis-py-cluster-2.1.3.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Natural Language :: English", "Programming Language :: Python", "Programming Language :: Python :: 2.6", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.3"], "name": "requests", "requires_python": null, "version": "2.0.1", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "requests-2.0.1-py2.py3-none-any.whl"}, {"packagetype": "sdist", "filename": "requests-2.0.1.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Natural Language :: English", "Programming Language :: Python", "Programming Language :: Python :: 2.6", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy"], "name": "requests", "requires_python": null, "version": "2.10.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "requests-2.10.0-py2.py3-none-any.whl"}, {"packagetype": "sdist", "filename": "requests-2.10.0.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Natural Language :: English", "Programming Language :: Python", "Programming Language :: Python :: 2.6", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy"], "name": "requests", "requires_python": null, "version": "2.11.1", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "requests-2.11.1-py2.py3-none-any.whl"}, {"packagetype": "sdist", "filename": "requests-2.11.1.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Natural Language :: English", "Programming Language :: Python", "Programming Language :: Python :: 2.6", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy"], "name": "requests", "requires_python": "", "version": "2.12.5", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "requests-2.12.5-py2.py3-none-any.whl"}, {"packagetype": "sdist", "filename": "requests-2.12.5.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Natural Language :: English", "Programming Language :: Python", "Programming Language :: Python :: 2.6", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy"], "name": "requests", "requires_python": "", "version": "2.16.5", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "requests-2.16.5-py2.py3-none-any.whl"}, {"packagetype": "sdist", "filename": "requests-2.16.5.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Natural Language :: English", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Internet :: WWW/HTTP", "Topic :: Software Development :: Libraries"], "name": "requests", "requires_python": ">=3.9", "version": "2.32.5", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "requests-2.32.5-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "requests-2.32.5.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Natural Language :: English", "Programming Language :: Python", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5"], "name": "requests", "requires_python": null, "version": "2.8.1", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "requests-2.8.1-py2.py3-none-any.whl"}, {"packagetype": "sdist", "filename": "requests-2.8.1.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Intended Audience :: End Users/Desktop", "Intended Audience :: Information Technology", "Intended Audience :: Science/Research", "Intended Audience :: System Administrators", "License :: OSI Approved :: BSD License", "Operating System :: MacOS", "Operating System :: POSIX", "Operating System :: Unix", "Programming Language :: Python", "Programming Language :: Python :: 2", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Topic :: Internet", "Topic :: Scientific/Engineering", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: System :: Distributed Computing", "Topic :: System :: Monitoring", "Topic :: System :: Systems Administration"], "name": "rq", "requires_python": "", "version": "0.10.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "rq-0.10.0-py2.py3-none-any.whl"}, {"packagetype": "sdist", "filename": "rq-0.10.0.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Intended Audience :: End Users/Desktop", "Intended Audience :: Information Technology", "Intended Audience :: Science/Research", "Intended Audience :: System Administrators", "License :: OSI Approved :: BSD License", "Operating System :: MacOS", "Operating System :: POSIX", "Operating System :: Unix", "Programming Language :: Python", "Programming Language :: Python :: 2", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Topic :: Internet", "Topic :: Scientific/Engineering", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: System :: Distributed Computing", "Topic :: System :: Monitoring", "Topic :: System :: Systems Administration"], "name": "rq", "requires_python": "", "version": "0.13.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "rq-0.13.0-py2.py3-none-any.whl"}, {"packagetype": "sdist", "filename": "rq-0.13.0.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Intended Audience :: End Users/Desktop", "Intended Audience :: Information Technology", "Intended Audience :: Science/Research", "Intended Audience :: System Administrators", "License :: OSI Approved :: BSD License", "Operating System :: MacOS", "Operating System :: POSIX", "Operating System :: Unix", "Programming Language :: Python", "Programming Language :: Python :: 2", "Programming Language :: Python :: 3", "Topic :: Internet", "Topic :: Scientific/Engineering", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: System :: Distributed Computing", "Topic :: System :: Monitoring", "Topic :: System :: Systems Administration"], "name": "rq", "requires_python": "", "version": "0.6.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "rq-0.6.0-py2.py3-none-any.whl"}, {"packagetype": "sdist", "filename": "rq-0.6.0.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Intended Audience :: End Users/Desktop", "Intended Audience :: Information Technology", "Intended Audience :: Science/Research", "Intended Audience :: System Administrators", "License :: OSI Approved :: BSD License", "Operating System :: MacOS", "Operating System :: POSIX", "Operating System :: Unix", "Programming Language :: Python", "Programming Language :: Python :: 2", "Programming Language :: Python :: 3", "Topic :: Internet", "Topic :: Scientific/Engineering", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: System :: Distributed Computing", "Topic :: System :: Monitoring", "Topic :: System :: Systems Administration"], "name": "rq", "requires_python": "", "version": "0.7.1", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "rq-0.7.1-py2.py3-none-any.whl"}, {"packagetype": "sdist", "filename": "rq-0.7.1.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Intended Audience :: End Users/Desktop", "Intended Audience :: Information Technology", "Intended Audience :: Science/Research", "Intended Audience :: System Administrators", "License :: OSI Approved :: BSD License", "Operating System :: MacOS", "Operating System :: POSIX", "Operating System :: Unix", "Programming Language :: Python", "Programming Language :: Python :: 2", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Topic :: Internet", "Topic :: Scientific/Engineering", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: System :: Distributed Computing", "Topic :: System :: Monitoring", "Topic :: System :: Systems Administration"], "name": "rq", "requires_python": "", "version": "0.8.2", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "rq-0.8.2-py2.py3-none-any.whl"}, {"packagetype": "sdist", "filename": "rq-0.8.2.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Intended Audience :: End Users/Desktop", "Intended Audience :: Information Technology", "Intended Audience :: Science/Research", "Intended Audience :: System Administrators", "License :: OSI Approved :: BSD License", "Operating System :: MacOS", "Operating System :: POSIX", "Operating System :: Unix", "Programming Language :: Python", "Programming Language :: Python :: 2", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Topic :: Internet", "Topic :: Scientific/Engineering", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: System :: Distributed Computing", "Topic :: System :: Monitoring", "Topic :: System :: Systems Administration"], "name": "rq", "requires_python": ">=2.7", "version": "1.0", "yanked": false}, "urls": [{"packagetype": "sdist", "filename": "rq-1.0.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Intended Audience :: End Users/Desktop", "Intended Audience :: Information Technology", "Intended Audience :: Science/Research", "Intended Audience :: System Administrators", "License :: OSI Approved :: BSD License", "Operating System :: MacOS", "Operating System :: POSIX", "Operating System :: Unix", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Scientific/Engineering", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: System :: Distributed Computing", "Topic :: System :: Monitoring", "Topic :: System :: Systems Administration"], "name": "rq", "requires_python": ">=3.7", "version": "1.16.2", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "rq-1.16.2-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "rq-1.16.2.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Intended Audience :: End Users/Desktop", "Intended Audience :: Information Technology", "Intended Audience :: Science/Research", "Intended Audience :: System Administrators", "License :: OSI Approved :: BSD License", "Operating System :: MacOS", "Operating System :: POSIX", "Operating System :: Unix", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Scientific/Engineering", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: System :: Distributed Computing", "Topic :: System :: Monitoring", "Topic :: System :: Systems Administration"], "name": "rq", "requires_python": ">=3.5", "version": "1.8.1", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "rq-1.8.1-py2.py3-none-any.whl"}, {"packagetype": "sdist", "filename": "rq-1.8.1.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Intended Audience :: End Users/Desktop", "Intended Audience :: Information Technology", "Intended Audience :: Science/Research", "Intended Audience :: System Administrators", "License :: OSI Approved :: BSD License", "Operating System :: MacOS", "Operating System :: POSIX", "Operating System :: Unix", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Scientific/Engineering", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: System :: Distributed Computing", "Topic :: System :: Monitoring", "Topic :: System :: Systems Administration"], "name": "rq", "requires_python": ">=3.9", "version": "2.6.1", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "rq-2.6.1-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "rq-2.6.1.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 4 - Beta", "Environment :: Web Environment", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6"], "name": "sanic", "requires_python": "", "version": "0.8.3", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "sanic-0.8.3-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "sanic-0.8.3.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 4 - Beta", "Environment :: Web Environment", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9"], "name": "sanic", "requires_python": ">=3.6", "version": "20.12.7", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "sanic-20.12.7-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "sanic-20.12.7.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 4 - Beta", "Environment :: Web Environment", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9"], "name": "sanic", "requires_python": ">=3.8", "version": "23.12.2", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "sanic-23.12.2-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "sanic-23.12.2.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 4 - Beta", "Environment :: Web Environment", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9"], "name": "sanic", "requires_python": ">=3.8", "version": "25.3.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "sanic-25.3.0-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "sanic-25.3.0.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 3 - Alpha", "Environment :: Web Environment", "Intended Audience :: Developers", "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Internet :: WWW/HTTP"], "name": "starlette", "requires_python": ">=3.6", "version": "0.16.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "starlette-0.16.0-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "starlette-0.16.0.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 3 - Alpha", "Environment :: Web Environment", "Framework :: AnyIO", "Intended Audience :: Developers", "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Internet :: WWW/HTTP"], "name": "starlette", "requires_python": ">=3.7", "version": "0.27.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "starlette-0.27.0-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "starlette-0.27.0.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 3 - Alpha", "Environment :: Web Environment", "Framework :: AnyIO", "Intended Audience :: Developers", "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Internet :: WWW/HTTP"], "name": "starlette", "requires_python": ">=3.8", "version": "0.38.6", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "starlette-0.38.6-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "starlette-0.38.6.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 3 - Alpha", "Environment :: Web Environment", "Framework :: AnyIO", "Intended Audience :: Developers", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Topic :: Internet :: WWW/HTTP"], "name": "starlette", "requires_python": ">=3.10", "version": "0.50.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "starlette-0.50.0-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "starlette-0.50.0.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "License :: OSI Approved :: MIT License", "Natural Language :: English", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Internet :: WWW/HTTP", "Topic :: Software Development", "Topic :: Software Development :: Libraries", "Typing :: Typed"], "name": "starlite", "requires_python": ">=3.8,<4.0", "version": "1.48.1", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "starlite-1.48.1-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "starlite-1.48.1.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "License :: OSI Approved :: MIT License", "Natural Language :: English", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Internet :: WWW/HTTP", "Topic :: Software Development", "Topic :: Software Development :: Libraries", "Typing :: Typed"], "name": "starlite", "requires_python": "<4.0,>=3.8", "version": "1.51.16", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "starlite-1.51.16-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "starlite-1.51.16.tar.gz"}]} +{"info": {"classifiers": ["Intended Audience :: Developers", "Programming Language :: Python", "Programming Language :: Python :: 3", "Topic :: Software Development :: Libraries"], "name": "statsig", "requires_python": ">=3.7", "version": "0.55.3", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "statsig-0.55.3-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "statsig-0.55.3.tar.gz"}]} +{"info": {"classifiers": ["Intended Audience :: Developers", "Programming Language :: Python", "Programming Language :: Python :: 3", "Topic :: Software Development :: Libraries"], "name": "statsig", "requires_python": ">=3.7", "version": "0.66.2", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "statsig-0.66.2-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "statsig-0.66.2.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "strawberry-graphql", "requires_python": ">=3.8,<4.0", "version": "0.209.8", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "strawberry_graphql-0.209.8-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "strawberry_graphql-0.209.8.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "strawberry-graphql", "requires_python": "<4.0,>=3.10", "version": "0.287.3", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "strawberry_graphql-0.287.3-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "strawberry_graphql-0.287.3.tar.gz"}]} +{"info": {"classifiers": ["License :: OSI Approved :: Apache Software License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy"], "name": "tornado", "requires_python": ">= 3.5", "version": "6.0.4", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "tornado-6.0.4-cp35-cp35m-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "tornado-6.0.4-cp35-cp35m-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "tornado-6.0.4-cp36-cp36m-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "tornado-6.0.4-cp36-cp36m-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "tornado-6.0.4-cp37-cp37m-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "tornado-6.0.4-cp37-cp37m-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "tornado-6.0.4-cp38-cp38-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "tornado-6.0.4-cp38-cp38-win_amd64.whl"}, {"packagetype": "sdist", "filename": "tornado-6.0.4.tar.gz"}]} +{"info": {"classifiers": ["License :: OSI Approved :: Apache Software License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy"], "name": "tornado", "requires_python": ">=3.9", "version": "6.5.4", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "tornado-6.5.4-cp39-abi3-macosx_10_9_universal2.whl"}, {"packagetype": "bdist_wheel", "filename": "tornado-6.5.4-cp39-abi3-macosx_10_9_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "tornado-6.5.4-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "tornado-6.5.4-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "tornado-6.5.4-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "tornado-6.5.4-cp39-abi3-musllinux_1_2_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "tornado-6.5.4-cp39-abi3-musllinux_1_2_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "tornado-6.5.4-cp39-abi3-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "tornado-6.5.4-cp39-abi3-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "tornado-6.5.4-cp39-abi3-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "tornado-6.5.4-cp39-abi3-win_arm64.whl"}, {"packagetype": "sdist", "filename": "tornado-6.5.4.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: No Input/Output (Daemon)", "Intended Audience :: Developers", "License :: OSI Approved :: GNU General Public License (GPL)", "Natural Language :: English", "Natural Language :: French", "Natural Language :: German", "Natural Language :: Spanish", "Operating System :: OS Independent", "Programming Language :: Python", "Topic :: Software Development :: Libraries :: Application Frameworks"], "name": "trytond", "requires_python": null, "version": "1.2.10", "yanked": false}, "urls": [{"packagetype": "sdist", "filename": "trytond-1.2.10.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: No Input/Output (Daemon)", "Framework :: Tryton", "Intended Audience :: Developers", "License :: OSI Approved :: GNU General Public License (GPL)", "Natural Language :: Bulgarian", "Natural Language :: Catalan", "Natural Language :: Czech", "Natural Language :: Dutch", "Natural Language :: English", "Natural Language :: French", "Natural Language :: German", "Natural Language :: Russian", "Natural Language :: Spanish", "Operating System :: OS Independent", "Programming Language :: Python :: 2.6", "Programming Language :: Python :: 2.7", "Topic :: Software Development :: Libraries :: Application Frameworks"], "name": "trytond", "requires_python": null, "version": "2.8.16", "yanked": false}, "urls": [{"packagetype": "sdist", "filename": "trytond-2.8.16.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: No Input/Output (Daemon)", "Framework :: Tryton", "Intended Audience :: Developers", "License :: OSI Approved :: GNU General Public License (GPL)", "Natural Language :: Bulgarian", "Natural Language :: Catalan", "Natural Language :: Czech", "Natural Language :: Dutch", "Natural Language :: English", "Natural Language :: French", "Natural Language :: German", "Natural Language :: Hungarian", "Natural Language :: Italian", "Natural Language :: Portuguese (Brazilian)", "Natural Language :: Russian", "Natural Language :: Slovenian", "Natural Language :: Spanish", "Operating System :: OS Independent", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Software Development :: Libraries :: Application Frameworks"], "name": "trytond", "requires_python": "", "version": "3.8.18", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "trytond-3.8.18-py2-none-any.whl"}, {"packagetype": "sdist", "filename": "trytond-3.8.18.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: No Input/Output (Daemon)", "Framework :: Tryton", "Intended Audience :: Developers", "License :: OSI Approved :: GNU General Public License (GPL)", "Natural Language :: Bulgarian", "Natural Language :: Catalan", "Natural Language :: Chinese (Simplified)", "Natural Language :: Czech", "Natural Language :: Dutch", "Natural Language :: English", "Natural Language :: French", "Natural Language :: German", "Natural Language :: Hungarian", "Natural Language :: Italian", "Natural Language :: Polish", "Natural Language :: Portuguese (Brazilian)", "Natural Language :: Russian", "Natural Language :: Slovenian", "Natural Language :: Spanish", "Operating System :: OS Independent", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3.3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Software Development :: Libraries :: Application Frameworks"], "name": "trytond", "requires_python": "", "version": "4.2.22", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "trytond-4.2.22-py2-none-any.whl"}, {"packagetype": "bdist_wheel", "filename": "trytond-4.2.22-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "trytond-4.2.22.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: No Input/Output (Daemon)", "Framework :: Tryton", "Intended Audience :: Developers", "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", "Natural Language :: Bulgarian", "Natural Language :: Catalan", "Natural Language :: Chinese (Simplified)", "Natural Language :: Czech", "Natural Language :: Dutch", "Natural Language :: English", "Natural Language :: French", "Natural Language :: German", "Natural Language :: Hungarian", "Natural Language :: Italian", "Natural Language :: Polish", "Natural Language :: Portuguese (Brazilian)", "Natural Language :: Russian", "Natural Language :: Slovenian", "Natural Language :: Spanish", "Operating System :: OS Independent", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3.3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Software Development :: Libraries :: Application Frameworks"], "name": "trytond", "requires_python": "", "version": "4.4.27", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "trytond-4.4.27-py2-none-any.whl"}, {"packagetype": "bdist_wheel", "filename": "trytond-4.4.27-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "trytond-4.4.27.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: No Input/Output (Daemon)", "Framework :: Tryton", "Intended Audience :: Developers", "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", "Natural Language :: Bulgarian", "Natural Language :: Catalan", "Natural Language :: Chinese (Simplified)", "Natural Language :: Czech", "Natural Language :: Dutch", "Natural Language :: English", "Natural Language :: French", "Natural Language :: German", "Natural Language :: Hungarian", "Natural Language :: Italian", "Natural Language :: Polish", "Natural Language :: Portuguese (Brazilian)", "Natural Language :: Russian", "Natural Language :: Slovenian", "Natural Language :: Spanish", "Operating System :: OS Independent", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Software Development :: Libraries :: Application Frameworks"], "name": "trytond", "requires_python": "", "version": "4.6.22", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "trytond-4.6.22-py2-none-any.whl"}, {"packagetype": "bdist_wheel", "filename": "trytond-4.6.22-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "trytond-4.6.22.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: No Input/Output (Daemon)", "Framework :: Tryton", "Intended Audience :: Developers", "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", "Natural Language :: Bulgarian", "Natural Language :: Catalan", "Natural Language :: Chinese (Simplified)", "Natural Language :: Czech", "Natural Language :: Dutch", "Natural Language :: English", "Natural Language :: French", "Natural Language :: German", "Natural Language :: Hungarian", "Natural Language :: Italian", "Natural Language :: Persian", "Natural Language :: Polish", "Natural Language :: Portuguese (Brazilian)", "Natural Language :: Russian", "Natural Language :: Slovenian", "Natural Language :: Spanish", "Operating System :: OS Independent", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Software Development :: Libraries :: Application Frameworks"], "name": "trytond", "requires_python": "", "version": "4.8.18", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "trytond-4.8.18-py2-none-any.whl"}, {"packagetype": "bdist_wheel", "filename": "trytond-4.8.18-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "trytond-4.8.18.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Console", "Environment :: No Input/Output (Daemon)", "Framework :: Tryton", "Intended Audience :: Developers", "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", "Natural Language :: Bulgarian", "Natural Language :: Catalan", "Natural Language :: Chinese (Simplified)", "Natural Language :: Czech", "Natural Language :: Dutch", "Natural Language :: English", "Natural Language :: Finnish", "Natural Language :: French", "Natural Language :: German", "Natural Language :: Hungarian", "Natural Language :: Indonesian", "Natural Language :: Italian", "Natural Language :: Persian", "Natural Language :: Polish", "Natural Language :: Portuguese (Brazilian)", "Natural Language :: Russian", "Natural Language :: Slovenian", "Natural Language :: Spanish", "Natural Language :: Turkish", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Software Development :: Libraries :: Application Frameworks"], "name": "trytond", "requires_python": ">=3.6", "version": "5.8.16", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "trytond-5.8.16-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "trytond-5.8.16.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Console", "Environment :: No Input/Output (Daemon)", "Framework :: Tryton", "Intended Audience :: Developers", "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", "Natural Language :: Bulgarian", "Natural Language :: Catalan", "Natural Language :: Chinese (Simplified)", "Natural Language :: Czech", "Natural Language :: Dutch", "Natural Language :: English", "Natural Language :: Finnish", "Natural Language :: French", "Natural Language :: German", "Natural Language :: Hungarian", "Natural Language :: Indonesian", "Natural Language :: Italian", "Natural Language :: Persian", "Natural Language :: Polish", "Natural Language :: Portuguese (Brazilian)", "Natural Language :: Romanian", "Natural Language :: Russian", "Natural Language :: Slovenian", "Natural Language :: Spanish", "Natural Language :: Turkish", "Natural Language :: Ukrainian", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: CPython", "Topic :: Software Development :: Libraries :: Application Frameworks"], "name": "trytond", "requires_python": ">=3.8", "version": "6.8.17", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "trytond-6.8.17-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "trytond-6.8.17.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Console", "Environment :: No Input/Output (Daemon)", "Framework :: Tryton", "Intended Audience :: Developers", "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", "Natural Language :: Bulgarian", "Natural Language :: Catalan", "Natural Language :: Chinese (Simplified)", "Natural Language :: Czech", "Natural Language :: Dutch", "Natural Language :: English", "Natural Language :: Finnish", "Natural Language :: French", "Natural Language :: German", "Natural Language :: Hungarian", "Natural Language :: Indonesian", "Natural Language :: Italian", "Natural Language :: Persian", "Natural Language :: Polish", "Natural Language :: Portuguese (Brazilian)", "Natural Language :: Romanian", "Natural Language :: Russian", "Natural Language :: Slovenian", "Natural Language :: Spanish", "Natural Language :: Turkish", "Natural Language :: Ukrainian", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: CPython", "Topic :: Software Development :: Libraries :: Application Frameworks"], "name": "trytond", "requires_python": ">=3.9", "version": "7.8.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "trytond-7.8.0-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "trytond-7.8.0.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 4 - Beta", "Intended Audience :: Developers", "Intended Audience :: Information Technology", "Intended Audience :: System Administrators", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Software Development", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "typer", "requires_python": ">=3.7", "version": "0.15.4", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "typer-0.15.4-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "typer-0.15.4.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 4 - Beta", "Intended Audience :: Developers", "Intended Audience :: Information Technology", "Intended Audience :: System Administrators", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Software Development", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "typer", "requires_python": ">=3.8", "version": "0.20.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "typer-0.20.0-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "typer-0.20.0.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 3 - Alpha", "Intended Audience :: Developers", "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9", "Programming Language :: Rust"], "name": "uuid-utils", "requires_python": ">=3.9", "version": "0.12.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "uuid_utils-0.12.0-cp39-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl"}, {"packagetype": "bdist_wheel", "filename": "uuid_utils-0.12.0-cp39-abi3-macosx_10_12_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "uuid_utils-0.12.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "uuid_utils-0.12.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl"}, {"packagetype": "bdist_wheel", "filename": "uuid_utils-0.12.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "uuid_utils-0.12.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "uuid_utils-0.12.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "uuid_utils-0.12.0-cp39-abi3-musllinux_1_2_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "uuid_utils-0.12.0-cp39-abi3-musllinux_1_2_armv7l.whl"}, {"packagetype": "bdist_wheel", "filename": "uuid_utils-0.12.0-cp39-abi3-musllinux_1_2_i686.whl"}, {"packagetype": "bdist_wheel", "filename": "uuid_utils-0.12.0-cp39-abi3-musllinux_1_2_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "uuid_utils-0.12.0-cp39-abi3-win32.whl"}, {"packagetype": "bdist_wheel", "filename": "uuid_utils-0.12.0-cp39-abi3-win_amd64.whl"}, {"packagetype": "bdist_wheel", "filename": "uuid_utils-0.12.0-cp39-abi3-win_arm64.whl"}, {"packagetype": "bdist_wheel", "filename": "uuid_utils-0.12.0-pp311-pypy311_pp73-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl"}, {"packagetype": "bdist_wheel", "filename": "uuid_utils-0.12.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "uuid_utils-0.12.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl"}, {"packagetype": "bdist_wheel", "filename": "uuid_utils-0.12.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl"}, {"packagetype": "bdist_wheel", "filename": "uuid_utils-0.12.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl"}, {"packagetype": "bdist_wheel", "filename": "uuid_utils-0.12.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"}, {"packagetype": "bdist_wheel", "filename": "uuid_utils-0.12.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl"}, {"packagetype": "sdist", "filename": "uuid_utils-0.12.0.tar.gz"}]} diff --git a/scripts/populate_tox/requirements.txt b/scripts/populate_tox/requirements.txt new file mode 100644 index 0000000000..0402fac5ab --- /dev/null +++ b/scripts/populate_tox/requirements.txt @@ -0,0 +1,3 @@ +jinja2 +packaging +requests diff --git a/scripts/populate_tox/tox.jinja b/scripts/populate_tox/tox.jinja new file mode 100755 index 0000000000..e01832abcb --- /dev/null +++ b/scripts/populate_tox/tox.jinja @@ -0,0 +1,232 @@ +# DON'T EDIT THIS FILE BY HAND. This file has been generated from a template by +# `scripts/populate_tox/populate_tox.py`. +# +# Any changes to the test matrix should be made +# - either in the script config in `scripts/populate_tox/config.py` (if you want +# to change the auto-generated part) +# - or in the template in `scripts/populate_tox/tox.jinja` (if you want to change +# a hardcoded part of the file) +# +# This file (and all resulting CI YAMLs) then needs to be regenerated via +# `scripts/generate-test-files.sh`. +# +# See also `scripts/populate_tox/README.md` for more info. + +[tox] +requires = + # This version introduced using pip 24.1 which does not work with older Celery and HTTPX versions. + virtualenv<20.26.3 +envlist = + # === Common === + {py3.6,py3.7,py3.8,py3.9,py3.10,py3.11,py3.12,py3.13,py3.14,py3.14t}-common + + # === Gevent === + {py3.6,py3.8,py3.10,py3.11,py3.12}-gevent + + # === Integration Deactivation === + {py3.9,py3.10,py3.11,py3.12,py3.13,py3.14}-integration_deactivation + + # === Shadowed Module === + {py3.7,py3.8,py3.9,py3.10,py3.11,py3.12,py3.13,py3.14,py3.14t}-shadowed_module + + # === Integrations === + + # Asgi + {py3.7,py3.12,py3.13,py3.14,py3.14t}-asgi + + # AWS Lambda + {py3.8,py3.9,py3.11,py3.13}-aws_lambda + + # Cloud Resource Context + {py3.6,py3.12,py3.13}-cloud_resource_context + + # GCP + {py3.7}-gcp + + # OpenTelemetry (OTel) + {py3.7,py3.9,py3.12,py3.13,py3.14,py3.14t}-opentelemetry + + # OpenTelemetry with OTLP + {py3.7,py3.9,py3.12,py3.13,py3.14}-otlp + + # OpenTelemetry Experimental (POTel) + {py3.8,py3.9,py3.10,py3.11,py3.12,py3.13}-potel + + # === Integrations - Auto-generated === + # These come from the populate_tox.py script. + + {% for group, integrations in groups.items() %} + # ~~~ {{ group }} ~~~ + {% for integration in integrations %} + {% for release in integration.releases %} + {{ release.rendered_python_versions }}-{{ integration.name }}-v{{ release }} + {% endfor %} + + {% endfor %} + + {% endfor %} + +[testenv] +deps = + # if you change requirements-testing.txt and your change is not being reflected + # in what's installed by tox (when running tox locally), try running tox + # with the -r flag + -r requirements-testing.txt + + linters: -r requirements-linting.txt + linters: werkzeug<2.3.0 + + # === Common === + py3.8-common: hypothesis + 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 + {py3.6,py3.7}-common: pytest<7.0.0 + {py3.8,py3.9,py3.10,py3.11,py3.12,py3.13,py3.14,py3.14t}-common: pytest + # coverage 7.11.1-7.11.3 makes some of our tests flake + {py3.14,py3.14t}-common: coverage==7.11.0 + + # === Gevent === + {py3.6,py3.7,py3.8,py3.9,py3.10,py3.11}-gevent: gevent>=22.10.0, <22.11.0 + {py3.12}-gevent: gevent + # 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 + {py3.6,py3.7}-gevent: pytest<7.0.0 + {py3.8,py3.9,py3.10,py3.11,py3.12}-gevent: pytest + gevent: pytest-asyncio + {py3.10,py3.11}-gevent: zope.event<5.0.0 + {py3.10,py3.11}-gevent: zope.interface<8.0 + + # === Integration Deactivation === + integration_deactivation: openai + integration_deactivation: anthropic + integration_deactivation: langchain + + # === Integrations === + + # Asgi + asgi: pytest-asyncio + asgi: async-asgi-testclient + + # AWS Lambda + aws_lambda: aws-cdk-lib + aws_lambda: aws-sam-cli + aws_lambda: boto3 + aws_lambda: fastapi + aws_lambda: requests + aws_lambda: uvicorn + + # OpenTelemetry (OTel) + opentelemetry: opentelemetry-distro + + # OpenTelemetry with OTLP + otlp: opentelemetry-distro[otlp] + + # OpenTelemetry Experimental (POTel) + potel: -e .[opentelemetry-experimental] + + # === Integrations - Auto-generated === + # These come from the populate_tox.py script. + + {% for group, integrations in groups.items() %} + # ~~~ {{ group }} ~~~ + {% for integration in integrations %} + {% for release in integration.releases %} + {% if integration.extra %} + {{ integration.name }}-v{{ release }}: {{ integration.package }}[{{ integration.extra }}]=={{ release }} + {% else %} + {{ integration.name }}-v{{ release }}: {{ integration.package }}=={{ release }} + {% endif %} + {% endfor %} + {% for dep in integration.dependencies %} + {{ dep }} + {% endfor %} + + {% endfor %} + + {% endfor %} + +setenv = + PYTHONDONTWRITEBYTECODE=1 + OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES + COVERAGE_FILE=.coverage-sentry-{envname} + py3.6: COVERAGE_RCFILE=.coveragerc36 + # Lowest version to support free-threading + # https://discuss.python.org/t/announcement-pip-24-1-release/56281 + py3.14t: VIRTUALENV_PIP=24.1 + + django: DJANGO_SETTINGS_MODULE=tests.integrations.django.myapp.settings + spark-v{3.0.3,3.5.6}: JAVA_HOME=/usr/lib/jvm/temurin-11-jdk-amd64 + + # Avoid polluting test suite with imports + common: PYTEST_ADDOPTS="--ignore=tests/test_shadowed_module.py" + gevent: PYTEST_ADDOPTS="--ignore=tests/test_shadowed_module.py" + + # TESTPATH definitions for test suites not managed by toxgen + common: TESTPATH=tests + gevent: TESTPATH=tests + integration_deactivation: TESTPATH=tests/test_ai_integration_deactivation.py + shadowed_module: TESTPATH=tests/test_shadowed_module.py + asgi: TESTPATH=tests/integrations/asgi + aws_lambda: TESTPATH=tests/integrations/aws_lambda + cloud_resource_context: TESTPATH=tests/integrations/cloud_resource_context + gcp: TESTPATH=tests/integrations/gcp + opentelemetry: TESTPATH=tests/integrations/opentelemetry + otlp: TESTPATH=tests/integrations/otlp + potel: TESTPATH=tests/integrations/opentelemetry + socket: TESTPATH=tests/integrations/socket + + # These TESTPATH definitions are auto-generated by toxgen + {% for integration, testpath in testpaths %} + {{ integration }}: TESTPATH={{ testpath }} + {% endfor %} + +passenv = + SENTRY_PYTHON_TEST_POSTGRES_HOST + SENTRY_PYTHON_TEST_POSTGRES_USER + SENTRY_PYTHON_TEST_POSTGRES_PASSWORD + SENTRY_PYTHON_TEST_POSTGRES_NAME + +usedevelop = True + +extras = + bottle: bottle + falcon: falcon + flask: flask + pymongo: pymongo + +basepython = + py3.6: python3.6 + py3.7: python3.7 + py3.8: python3.8 + py3.9: python3.9 + py3.10: python3.10 + py3.11: python3.11 + py3.12: python3.12 + py3.13: python3.13 + py3.14: python3.14 + py3.14t: python3.14t + + # Python version is pinned here for consistency across environments. + # Tools like ruff and mypy have options that pin the target Python + # version (configured in pyproject.toml), ensuring consistent behavior. + linters: python3.14 + +commands = + {py3.7,py3.8}-boto3: pip install urllib3<2.0.0 + + ; https://github.com/pallets/flask/issues/4455 + {py3.7,py3.8,py3.9,py3.10,py3.11}-flask-v{1}: pip install "itsdangerous>=0.24,<2.0" "markupsafe<2.0.0" "jinja2<3.1.1" + + ; Running `pytest` as an executable suffers from an import error + ; when loading tests in scenarios. In particular, django fails to + ; load the settings from the test module. + python -m pytest -W error::pytest.PytestUnraisableExceptionWarning {env:TESTPATH} -o junit_suite_name={envname} {posargs} + +[testenv:linters] +commands = + ruff check tests sentry_sdk + ruff format --check tests sentry_sdk + mypy sentry_sdk diff --git a/scripts/ready_yet/main.py b/scripts/ready_yet/main.py new file mode 100644 index 0000000000..1f94adeb70 --- /dev/null +++ b/scripts/ready_yet/main.py @@ -0,0 +1,124 @@ +import time +import re +import sys + +import requests + +from collections import defaultdict + +from pathlib import Path + +from tox.config.cli.parse import get_options +from tox.session.state import State +from tox.config.sets import CoreConfigSet +from tox.config.source.tox_ini import ToxIni + +PYTHON_VERSION = "3.13" + +MATCH_LIB_SENTRY_REGEX = r"py[\d\.]*-(.*)-.*" + +PYPI_PROJECT_URL = "https://pypi.python.org/pypi/{project}/json" +PYPI_VERSION_URL = "https://pypi.python.org/pypi/{project}/{version}/json" + + +def get_tox_envs(tox_ini_path: Path) -> list: + tox_ini = ToxIni(tox_ini_path) + conf = State(get_options(), []).conf + tox_section = next(tox_ini.sections()) + core_config_set = CoreConfigSet( + conf, tox_section, tox_ini_path.parent, tox_ini_path + ) + ( + core_config_set.loaders.extend( + tox_ini.get_loaders( + tox_section, + base=[], + override_map=defaultdict(list, {}), + conf=core_config_set, + ) + ) + ) + return core_config_set.load("env_list") + + +def get_libs(tox_ini: Path, regex: str) -> set: + libs = set() + for env in get_tox_envs(tox_ini): + match = re.match(regex, env) + if match: + libs.add(match.group(1)) + + return sorted(libs) + + +def main(): + """ + Check if libraries in our tox.ini are ready for Python version defined in `PYTHON_VERSION`. + """ + print(f"Checking libs from tox.ini for Python {PYTHON_VERSION} compatibility:") + + ready = set() + not_ready = set() + not_found = set() + + tox_ini = Path(__file__).parent.parent.parent.joinpath("tox.ini") + + libs = get_libs(tox_ini, MATCH_LIB_SENTRY_REGEX) + + for lib in libs: + print(".", end="") + sys.stdout.flush() + + # Get latest version of lib + url = PYPI_PROJECT_URL.format(project=lib) + pypi_data = requests.get(url) + + if pypi_data.status_code != 200: + not_found.add(lib) + continue + + latest_version = pypi_data.json()["info"]["version"] + + # Get supported Python version of latest version of lib + url = PYPI_PROJECT_URL.format(project=lib, version=latest_version) + pypi_data = requests.get(url) + + if pypi_data.status_code != 200: + continue + + classifiers = pypi_data.json()["info"]["classifiers"] + + if f"Programming Language :: Python :: {PYTHON_VERSION}" in classifiers: + ready.add(lib) + else: + not_ready.add(lib) + + # cut pypi some slack + time.sleep(0.1) + + # Print report + print("\n") + print(f"\nReady for Python {PYTHON_VERSION}:") + if len(ready) == 0: + print("- None ") + + for x in sorted(ready): + print(f"- {x}") + + print(f"\nNOT ready for Python {PYTHON_VERSION}:") + if len(not_ready) == 0: + print("- None ") + + for x in sorted(not_ready): + print(f"- {x}") + + print("\nNot found on PyPI:") + if len(not_found) == 0: + print("- None ") + + for x in sorted(not_found): + print(f"- {x}") + + +if __name__ == "__main__": + main() diff --git a/scripts/ready_yet/requirements.txt b/scripts/ready_yet/requirements.txt new file mode 100644 index 0000000000..69f9472fa5 --- /dev/null +++ b/scripts/ready_yet/requirements.txt @@ -0,0 +1,2 @@ +requests +tox diff --git a/scripts/ready_yet/run.sh b/scripts/ready_yet/run.sh new file mode 100755 index 0000000000..f872079c87 --- /dev/null +++ b/scripts/ready_yet/run.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + +# exit on first error +set -xe + +reset + +# create and activate virtual environment +python -m venv .venv +source .venv/bin/activate + +# Install (or update) requirements +python -m pip install -r requirements.txt + +# Run the script +python main.py diff --git a/scripts/runtox.sh b/scripts/runtox.sh index 31be9bfb4b..bd6e954947 100755 --- a/scripts/runtox.sh +++ b/scripts/runtox.sh @@ -1,7 +1,7 @@ #!/bin/bash -# Usage: sh scripts/runtox.sh py3.7 -# Runs all environments with substring py3.7 and the given arguments for pytest +# Usage: sh scripts/runtox.sh py3.12 +# Runs all environments with substring py3.12 and the given arguments for pytest set -ex @@ -15,13 +15,11 @@ fi searchstring="$1" -export TOX_PARALLEL_NO_SPINNER=1 -ENV="$($TOXPATH -l | grep "$searchstring" | tr $'\n' ',')" +ENV="$($TOXPATH -l | grep -- "$searchstring" | tr $'\n' ',')" -# Run the common 2.7 suite without the -p flag, otherwise we hit an encoding -# issue in tox. -if [ "$ENV" = py2.7-common, ] || [ "$ENV" = py2.7-gevent, ]; then - exec $TOXPATH -vv -e "$ENV" -- "${@:2}" -else - exec $TOXPATH -vv -e "$ENV" -- "${@:2}" +if [ -z "${ENV}" ]; then + echo "No targets found. Skipping." + exit 0 fi + +exec $TOXPATH -p auto -o -e "$ENV" -- "${@:2}" diff --git a/scripts/split-tox-gh-actions/ci-yaml-services.txt b/scripts/split-tox-gh-actions/ci-yaml-services.txt deleted file mode 100644 index 01bb9566b0..0000000000 --- a/scripts/split-tox-gh-actions/ci-yaml-services.txt +++ /dev/null @@ -1,19 +0,0 @@ - services: - postgres: - image: postgres - env: - POSTGRES_PASSWORD: sentry - # Set health checks to wait until postgres has started - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - # Maps tcp port 5432 on service container to the host - ports: - - 5432:5432 - env: - SENTRY_PYTHON_TEST_POSTGRES_USER: postgres - SENTRY_PYTHON_TEST_POSTGRES_PASSWORD: sentry - SENTRY_PYTHON_TEST_POSTGRES_NAME: ci_test - SENTRY_PYTHON_TEST_POSTGRES_HOST: {{ postgres_host }} diff --git a/scripts/split-tox-gh-actions/ci-yaml-setup-db.txt b/scripts/split-tox-gh-actions/ci-yaml-setup-db.txt deleted file mode 100644 index 2dc7ab5604..0000000000 --- a/scripts/split-tox-gh-actions/ci-yaml-setup-db.txt +++ /dev/null @@ -1,2 +0,0 @@ - psql postgresql://postgres:sentry@localhost:5432 -c "create database ${SENTRY_PYTHON_TEST_POSTGRES_NAME};" || true - psql postgresql://postgres:sentry@localhost:5432 -c "grant all privileges on database ${SENTRY_PYTHON_TEST_POSTGRES_NAME} to ${SENTRY_PYTHON_TEST_POSTGRES_USER};" || true diff --git a/scripts/split-tox-gh-actions/ci-yaml-test-py27-snippet.txt b/scripts/split-tox-gh-actions/ci-yaml-test-py27-snippet.txt deleted file mode 100644 index 94723c1658..0000000000 --- a/scripts/split-tox-gh-actions/ci-yaml-test-py27-snippet.txt +++ /dev/null @@ -1,29 +0,0 @@ - test-py27: - name: {{ framework }}, python 2.7, ubuntu-20.04 - runs-on: ubuntu-20.04 - container: python:2.7 - timeout-minutes: 30 -{{ services }} - - steps: - - uses: actions/checkout@v4 - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - - - name: Test {{ framework }} - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py2.7-{{ framework }}" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i diff --git a/scripts/split-tox-gh-actions/ci-yaml-test-snippet.txt b/scripts/split-tox-gh-actions/ci-yaml-test-snippet.txt deleted file mode 100644 index c2d10596ea..0000000000 --- a/scripts/split-tox-gh-actions/ci-yaml-test-snippet.txt +++ /dev/null @@ -1,39 +0,0 @@ - test: - name: {{ framework }}, python ${{ matrix.python-version }}, ${{ matrix.os }} - runs-on: ${{ matrix.os }} - timeout-minutes: 30 -{{ strategy_matrix }} -{{ services }} - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} -{{ additional_uses }} - - - name: Setup Test Env - run: | - pip install coverage "tox>=3,<4" - {{ setup_postgres }} - - - name: Test {{ framework }} - uses: nick-fields/retry@v2 - with: - timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 5 - shell: bash - command: | - set -x # print commands that are executed - coverage erase - - # Run tests - ./scripts/runtox.sh "py${{ matrix.python-version }}-{{ framework }}" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch && - coverage combine .coverage* && - coverage xml -i - - - uses: codecov/codecov-action@v3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: coverage.xml diff --git a/scripts/split-tox-gh-actions/ci-yaml.txt b/scripts/split-tox-gh-actions/ci-yaml.txt deleted file mode 100644 index 99d8154c60..0000000000 --- a/scripts/split-tox-gh-actions/ci-yaml.txt +++ /dev/null @@ -1,41 +0,0 @@ -name: Test {{ framework }} - -on: - push: - branches: - - master - - release/** - - pull_request: - -# Cancel in progress workflows on pull_requests. -# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - -env: - BUILD_CACHE_KEY: ${{ github.sha }} - CACHED_BUILD_PATHS: | - ${{ github.workspace }}/dist-serverless - -jobs: -{{ test }} - -{{ test_py27 }} - - check_required_tests: - name: All {{ framework }} tests passed or skipped -{{ check_needs }} - # Always run this, even if a dependent job failed - if: always() - runs-on: ubuntu-20.04 - steps: - - name: Check for failures - if: contains(needs.test.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 -{{ check_py27 }} diff --git a/scripts/split-tox-gh-actions/split-tox-gh-actions.py b/scripts/split-tox-gh-actions/split-tox-gh-actions.py deleted file mode 100755 index 15f85391ed..0000000000 --- a/scripts/split-tox-gh-actions/split-tox-gh-actions.py +++ /dev/null @@ -1,241 +0,0 @@ -"""Split Tox to GitHub Actions - -This is a small script to split a tox.ini config file into multiple GitHub actions configuration files. -This way each framework defined in tox.ini will get its own GitHub actions configuration file -which allows them to be run in parallel in GitHub actions. - -This will generate/update several configuration files, that need to be commited to Git afterwards. -Whenever tox.ini is changed, this script needs to be run. - -Usage: - python split-tox-gh-actions.py [--fail-on-changes] - -If the parameter `--fail-on-changes` is set, the script will raise a RuntimeError in case the yaml -files have been changed by the scripts execution. This is used in CI to check if the yaml files -represent the current tox.ini file. (And if not the CI run fails.) -""" - -import configparser -import hashlib -import sys -from collections import defaultdict -from glob import glob -from pathlib import Path - -OUT_DIR = Path(__file__).resolve().parent.parent.parent / ".github" / "workflows" -TOX_FILE = Path(__file__).resolve().parent.parent.parent / "tox.ini" -TEMPLATE_DIR = Path(__file__).resolve().parent -TEMPLATE_FILE = TEMPLATE_DIR / "ci-yaml.txt" -TEMPLATE_FILE_SERVICES = TEMPLATE_DIR / "ci-yaml-services.txt" -TEMPLATE_FILE_SETUP_DB = TEMPLATE_DIR / "ci-yaml-setup-db.txt" -TEMPLATE_SNIPPET_TEST = TEMPLATE_DIR / "ci-yaml-test-snippet.txt" -TEMPLATE_SNIPPET_TEST_PY27 = TEMPLATE_DIR / "ci-yaml-test-py27-snippet.txt" - -FRAMEWORKS_NEEDING_POSTGRES = [ - "django", - "asyncpg", -] - -FRAMEWORKS_NEEDING_CLICKHOUSE = [ - "clickhouse_driver", -] - -MATRIX_DEFINITION = """ - strategy: - fail-fast: false - matrix: - python-version: [{{ python-version }}] - # python3.6 reached EOL and is no longer being supported on - # new versions of hosted runners on Github Actions - # ubuntu-20.04 is the last version that supported python3.6 - # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 - os: [ubuntu-20.04] -""" - -ADDITIONAL_USES_CLICKHOUSE = """\ - - - uses: getsentry/action-clickhouse-in-ci@v1 -""" - -CHECK_NEEDS = """\ - needs: test -""" -CHECK_NEEDS_PY27 = """\ - needs: [test, test-py27] -""" - -CHECK_PY27 = """\ - - name: Check for 2.7 failures - if: contains(needs.test-py27.result, 'failure') - run: | - echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 -""" - - -def write_yaml_file( - template, - current_framework, - python_versions, -): - """Write the YAML configuration file for one framework to disk.""" - py_versions = [py.replace("py", "") for py in python_versions] - py27_supported = "2.7" in py_versions - - test_loc = template.index("{{ test }}\n") - f = open(TEMPLATE_SNIPPET_TEST, "r") - test_snippet = f.readlines() - template = template[:test_loc] + test_snippet + template[test_loc + 1 :] - f.close() - - test_py27_loc = template.index("{{ test_py27 }}\n") - if py27_supported: - f = open(TEMPLATE_SNIPPET_TEST_PY27, "r") - test_py27_snippet = f.readlines() - template = ( - template[:test_py27_loc] + test_py27_snippet + template[test_py27_loc + 1 :] - ) - f.close() - - py_versions.remove("2.7") - else: - template.pop(test_py27_loc) - - out = "" - py27_test_part = False - for template_line in template: - if template_line.strip() == "{{ strategy_matrix }}": - m = MATRIX_DEFINITION - m = m.replace("{{ framework }}", current_framework).replace( - "{{ python-version }}", ",".join([f'"{v}"' for v in py_versions]) - ) - out += m - - elif template_line.strip() == "{{ services }}": - if current_framework in FRAMEWORKS_NEEDING_POSTGRES: - f = open(TEMPLATE_FILE_SERVICES, "r") - lines = [ - line.replace( - "{{ postgres_host }}", - "postgres" if py27_test_part else "localhost", - ) - for line in f.readlines() - ] - out += "".join(lines) - f.close() - - elif template_line.strip() == "{{ setup_postgres }}": - if current_framework in FRAMEWORKS_NEEDING_POSTGRES: - f = open(TEMPLATE_FILE_SETUP_DB, "r") - out += "".join(f.readlines()) - - elif template_line.strip() == "{{ additional_uses }}": - if current_framework in FRAMEWORKS_NEEDING_CLICKHOUSE: - out += ADDITIONAL_USES_CLICKHOUSE - - elif template_line.strip() == "{{ check_needs }}": - if py27_supported: - out += CHECK_NEEDS_PY27 - else: - out += CHECK_NEEDS - - elif template_line.strip() == "{{ check_py27 }}": - if py27_supported: - out += CHECK_PY27 - - else: - if template_line.strip() == "test-py27:": - py27_test_part = True - - out += template_line.replace("{{ framework }}", current_framework) - - # write rendered template - if current_framework == "common": - outfile_name = OUT_DIR / f"test-{current_framework}.yml" - else: - outfile_name = OUT_DIR / f"test-integration-{current_framework}.yml" - - print(f"Writing {outfile_name}") - f = open(outfile_name, "w") - f.writelines(out) - f.close() - - -def get_yaml_files_hash(): - """Calculate a hash of all the yaml configuration files""" - - hasher = hashlib.md5() - path_pattern = (OUT_DIR / "test-integration-*.yml").as_posix() - for file in glob(path_pattern): - with open(file, "rb") as f: - buf = f.read() - hasher.update(buf) - - return hasher.hexdigest() - - -def main(fail_on_changes): - """Create one CI workflow for each framework defined in tox.ini""" - if fail_on_changes: - old_hash = get_yaml_files_hash() - - print("Read GitHub actions config file template") - f = open(TEMPLATE_FILE, "r") - template = f.readlines() - f.close() - - print("Read tox.ini") - config = configparser.ConfigParser() - config.read(TOX_FILE) - lines = [x for x in config["tox"]["envlist"].split("\n") if len(x) > 0] - - python_versions = defaultdict(list) - - print("Parse tox.ini envlist") - - for line in lines: - # normalize lines - line = line.strip().lower() - - # ignore comments - if line.startswith("#"): - continue - - try: - # parse tox environment definition - try: - (raw_python_versions, framework, _) = line.split("-") - except ValueError: - (raw_python_versions, framework) = line.split("-") - - # collect python versions to test the framework in - for python_version in ( - raw_python_versions.replace("{", "").replace("}", "").split(",") - ): - if python_version not in python_versions[framework]: - python_versions[framework].append(python_version) - - except ValueError: - print(f"ERROR reading line {line}") - - for framework in python_versions: - write_yaml_file(template, framework, python_versions[framework]) - - if fail_on_changes: - new_hash = get_yaml_files_hash() - - if old_hash != new_hash: - raise RuntimeError( - "The yaml configuration files have changed. This means that tox.ini has changed " - "but the changes have not been propagated to the GitHub actions config files. " - "Please run `python scripts/split-tox-gh-actions/split-tox-gh-actions.py` " - "locally and commit the changes of the yaml configuration files to continue. " - ) - - print("All done. Have a nice day!") - - -if __name__ == "__main__": - fail_on_changes = ( - True if len(sys.argv) == 2 and sys.argv[1] == "--fail-on-changes" else False - ) - main(fail_on_changes) diff --git a/sentry_sdk/db/__init__.py b/scripts/split_tox_gh_actions/__init__.py similarity index 100% rename from sentry_sdk/db/__init__.py rename to scripts/split_tox_gh_actions/__init__.py diff --git a/scripts/split_tox_gh_actions/requirements.txt b/scripts/split_tox_gh_actions/requirements.txt new file mode 100644 index 0000000000..7f7afbf3bf --- /dev/null +++ b/scripts/split_tox_gh_actions/requirements.txt @@ -0,0 +1 @@ +jinja2 diff --git a/scripts/split_tox_gh_actions/split_tox_gh_actions.py b/scripts/split_tox_gh_actions/split_tox_gh_actions.py new file mode 100755 index 0000000000..b59e768a56 --- /dev/null +++ b/scripts/split_tox_gh_actions/split_tox_gh_actions.py @@ -0,0 +1,350 @@ +"""Split Tox to GitHub Actions + +This is a small script to split a tox.ini config file into multiple GitHub actions configuration files. +This way each group of frameworks defined in tox.ini will get its own GitHub actions configuration file +which allows them to be run in parallel in GitHub actions. + +This will generate/update several configuration files, that need to be commited to Git afterwards. +Whenever tox.ini is changed, this script needs to be run. + +Usage: + python split_tox_gh_actions.py [--fail-on-changes] + +If the parameter `--fail-on-changes` is set, the script will raise a RuntimeError in case the yaml +files have been changed by the scripts execution. This is used in CI to check if the yaml files +represent the current tox.ini file. (And if not the CI run fails.) +""" + +import configparser +import hashlib +import re +import sys +from collections import defaultdict +from functools import reduce +from glob import glob +from pathlib import Path + +from jinja2 import Environment, FileSystemLoader + +TOXENV_REGEX = re.compile( + r""" + {?(?P(py\d+\.\d+t?,?)+)}? + -(?P[a-z](?:[a-z_]|-(?!v{?\d))*[a-z0-9]) + (?:-( + (v{?(?P[0-9.]+[0-9a-z,.]*}?)) + ))? +""", + re.VERBOSE, +) + +OUT_DIR = Path(__file__).resolve().parent.parent.parent / ".github" / "workflows" +TOX_FILE = Path(__file__).resolve().parent.parent.parent / "tox.ini" +TEMPLATE_DIR = Path(__file__).resolve().parent / "templates" + +FRAMEWORKS_NEEDING_POSTGRES = { + "django", + "asyncpg", +} + +FRAMEWORKS_NEEDING_REDIS = { + "celery", +} + +FRAMEWORKS_NEEDING_CLICKHOUSE = { + "clickhouse_driver", +} + +FRAMEWORKS_NEEDING_DOCKER = { + "aws_lambda", +} + +FRAMEWORKS_NEEDING_JAVA = { + "spark", +} + +# Frameworks grouped here will be tested together to not hog all GitHub runners. +# If you add or remove a group, make sure to git rm the generated YAML file as +# well. +GROUPS = { + "Common": [ + "common", + ], + "MCP": [ + "mcp", + "fastmcp", + ], + "Agents": [ + "openai_agents", + "pydantic_ai", + ], + "AI Workflow": [ + "langchain-base", + "langchain-notiktoken", + "langgraph", + ], + "AI": [ + "anthropic", + "cohere", + "google_genai", + "huggingface_hub", + "litellm", + "openai-base", + "openai-notiktoken", + ], + "Cloud": [ + "aws_lambda", + "boto3", + "chalice", + "cloud_resource_context", + "gcp", + ], + "DBs": [ + "asyncpg", + "clickhouse_driver", + "pymongo", + "redis", + "redis_py_cluster_legacy", + "sqlalchemy", + ], + "Flags": [ + "launchdarkly", + "openfeature", + "statsig", + "unleash", + ], + "Gevent": [ + "gevent", + ], + "GraphQL": [ + "ariadne", + "gql", + "graphene", + "strawberry", + ], + "Network": [ + "grpc", + "httpx", + "requests", + ], + "Tasks": [ + "arq", + "beam", + "celery", + "dramatiq", + "huey", + "ray", + "rq", + "spark", + ], + "Web 1": [ + "django", + "flask", + "starlette", + "fastapi", + ], + "Web 2": [ + "aiohttp", + "asgi", + "bottle", + "falcon", + "litestar", + "pyramid", + "quart", + "sanic", + "starlite", + "tornado", + ], + "Misc": [ + "loguru", + "opentelemetry", + "otlp", + "potel", + "pure_eval", + "trytond", + "typer", + "integration_deactivation", + "shadowed_module", + ], +} + + +ENV = Environment( + loader=FileSystemLoader(TEMPLATE_DIR), +) + + +def main(fail_on_changes): + """Create one CI workflow for each framework defined in tox.ini.""" + if fail_on_changes: + old_hash = get_files_hash() + + print("Parsing tox.ini...") + py_versions = parse_tox() + + if fail_on_changes: + print("Checking if all frameworks belong in a group...") + missing_frameworks = find_frameworks_missing_from_groups(py_versions) + if missing_frameworks: + raise RuntimeError( + "Please add the following frameworks to the corresponding group " + "in `GROUPS` in `scripts/split_tox_gh_actions/split_tox_gh_actions.py: " + + ", ".join(missing_frameworks) + ) + + print("Rendering templates...") + for group, frameworks in GROUPS.items(): + contents = render_template(group, frameworks, py_versions) + filename = write_file(contents, group) + print(f"Created {filename}") + + if fail_on_changes: + new_hash = get_files_hash() + + if old_hash != new_hash: + raise RuntimeError( + "The yaml configuration files have changed. This means that either `tox.ini` " + "or one of the constants in `split_tox_gh_actions.py` has changed " + "but the changes have not been propagated to the GitHub actions config files. " + "Please run `python scripts/split_tox_gh_actions/split_tox_gh_actions.py` " + "locally and commit the changes of the yaml configuration files to continue. " + ) + + print("All done. Have a nice day!") + + +def parse_tox(): + config = configparser.ConfigParser() + config.read(TOX_FILE) + lines = [ + line + for line in config["tox"]["envlist"].split("\n") + if line.strip() and not line.strip().startswith("#") + ] + + py_versions = defaultdict(set) + + parsed_correctly = True + + for line in lines: + # normalize lines + line = line.strip().lower() + + try: + # parse tox environment definition + parsed = TOXENV_REGEX.match(line) + if not parsed: + print(f"ERROR reading line {line}") + raise ValueError("Failed to parse tox environment definition") + + groups = parsed.groupdict() + raw_python_versions = groups["py_versions"] + framework = groups["framework"] + + # collect python versions to test the framework in + raw_python_versions = set(raw_python_versions.split(",")) + py_versions[framework] |= raw_python_versions + + except Exception: + print(f"ERROR reading line {line}") + parsed_correctly = False + + if not parsed_correctly: + raise RuntimeError("Failed to parse tox.ini") + + py_versions = _normalize_py_versions(py_versions) + + return py_versions + + +def find_frameworks_missing_from_groups(py_versions): + frameworks_in_a_group = _union(GROUPS.values()) + all_frameworks = set(py_versions) + return all_frameworks - frameworks_in_a_group + + +def _version_key(v): + major_version, minor_version_and_suffix = v.split(".") + if minor_version_and_suffix.endswith("t"): + return int(major_version), int(minor_version_and_suffix.rstrip("t")), 1 + + return int(major_version), int(minor_version_and_suffix), 0 + + +def _normalize_py_versions(py_versions): + def replace_and_sort(versions): + return sorted( + [py.replace("py", "") for py in versions], + key=_version_key, + ) + + if isinstance(py_versions, dict): + normalized = defaultdict(set) + normalized |= { + framework: replace_and_sort(versions) + for framework, versions in py_versions.items() + } + + elif isinstance(py_versions, set): + normalized = replace_and_sort(py_versions) + + return normalized + + +def get_files_hash(): + """Calculate a hash of all the yaml configuration files""" + hasher = hashlib.md5() + path_pattern = (OUT_DIR / "test-integrations-*.yml").as_posix() + for file in glob(path_pattern): + with open(file, "rb") as f: + buf = f.read() + hasher.update(buf) + + return hasher.hexdigest() + + +def _union(seq): + return reduce(lambda x, y: set(x) | set(y), seq) + + +def render_template(group, frameworks, py_versions): + template = ENV.get_template("base.jinja") + + py_versions_final = set() + for framework in frameworks: + py_versions_final |= set(py_versions[framework]) + + context = { + "group": group, + "frameworks": frameworks, + "needs_clickhouse": bool(set(frameworks) & FRAMEWORKS_NEEDING_CLICKHOUSE), + "needs_docker": bool(set(frameworks) & FRAMEWORKS_NEEDING_DOCKER), + "needs_postgres": bool(set(frameworks) & FRAMEWORKS_NEEDING_POSTGRES), + "needs_redis": bool(set(frameworks) & FRAMEWORKS_NEEDING_REDIS), + "needs_java": bool(set(frameworks) & FRAMEWORKS_NEEDING_JAVA), + "py_versions": [ + f'"{version}"' for version in _normalize_py_versions(py_versions_final) + ], + } + + rendered = template.render(context) + rendered = postprocess_template(rendered) + return rendered + + +def postprocess_template(rendered): + return "\n".join([line for line in rendered.split("\n") if line.strip()]) + "\n" + + +def write_file(contents, group): + group = group.lower().replace(" ", "-") + outfile = OUT_DIR / f"test-integrations-{group}.yml" + + with open(outfile, "w") as file: + file.write(contents) + + return outfile + + +if __name__ == "__main__": + fail_on_changes = len(sys.argv) == 2 and sys.argv[1] == "--fail-on-changes" + main(fail_on_changes) diff --git a/scripts/split_tox_gh_actions/templates/base.jinja b/scripts/split_tox_gh_actions/templates/base.jinja new file mode 100644 index 0000000000..8d618d228c --- /dev/null +++ b/scripts/split_tox_gh_actions/templates/base.jinja @@ -0,0 +1,35 @@ +# Do not edit this YAML file. This file is generated automatically by executing +# python scripts/split_tox_gh_actions/split_tox_gh_actions.py +# The template responsible for it is in +# scripts/split_tox_gh_actions/templates/base.jinja + +{% with lowercase_group=group | replace(" ", "_") | lower %} +name: Test {{ group }} + +on: + push: + branches: + - master + - release/** + - major/** + + pull_request: + +# Cancel in progress workflows on pull_requests. +# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value +concurrency: + group: {% raw %}${{ github.workflow }}-${{ github.head_ref || github.run_id }}{% endraw %} + cancel-in-progress: true + +permissions: + contents: read + +env: + BUILD_CACHE_KEY: {% raw %}${{ github.sha }}{% endraw %} + CACHED_BUILD_PATHS: | + {% raw %}${{ github.workspace }}/dist-serverless{% endraw %} + +jobs: +{% include "test_group.jinja" %} +{% include "check_required.jinja" %} +{% endwith %} diff --git a/scripts/split_tox_gh_actions/templates/check_required.jinja b/scripts/split_tox_gh_actions/templates/check_required.jinja new file mode 100644 index 0000000000..37d4f8fd78 --- /dev/null +++ b/scripts/split_tox_gh_actions/templates/check_required.jinja @@ -0,0 +1,11 @@ + check_required_tests: + name: All {{ group }} tests passed + needs: test-{{ group | replace(" ", "_") | lower }} + # Always run this, even if a dependent job failed + if: always() + runs-on: ubuntu-22.04 + steps: + - name: Check for failures + if: needs.test-{{ lowercase_group }}.result != 'success' + run: | + echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/scripts/split_tox_gh_actions/templates/test_group.jinja b/scripts/split_tox_gh_actions/templates/test_group.jinja new file mode 100644 index 0000000000..3e1ab30290 --- /dev/null +++ b/scripts/split_tox_gh_actions/templates/test_group.jinja @@ -0,0 +1,113 @@ + test-{{ lowercase_group }}: + name: {{ group }} + timeout-minutes: 30 + runs-on: {% raw %}${{ matrix.os }}{% endraw %} + strategy: + fail-fast: false + matrix: + python-version: [{{ py_versions|join(",") }}] + # python3.6 reached EOL and is no longer being supported on + # new versions of hosted runners on Github Actions + # ubuntu-20.04 is the last version that supported python3.6 + # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 + os: [ubuntu-22.04] + + {% if needs_docker %} + services: + docker: + image: docker:dind # Required for Docker network management + options: --privileged # Required for Docker-in-Docker operations + {% endif %} + {% if needs_postgres %} + services: + postgres: + image: postgres + env: + POSTGRES_PASSWORD: sentry + # Set health checks to wait until postgres has started + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + # Maps tcp port 5432 on service container to the host + ports: + - 5432:5432 + env: + SENTRY_PYTHON_TEST_POSTGRES_HOST: {% raw %}${{ matrix.python-version == '3.6' && 'postgres' || 'localhost' }}{% endraw %} + SENTRY_PYTHON_TEST_POSTGRES_USER: postgres + SENTRY_PYTHON_TEST_POSTGRES_PASSWORD: sentry + + {% endif %} + # Use Docker container only for Python 3.6 + {% raw %}container: ${{ matrix.python-version == '3.6' && 'python:3.6' || null }}{% endraw %} + steps: + - uses: actions/checkout@v6.0.1 + - uses: actions/setup-python@v6 + {% raw %}if: ${{ matrix.python-version != '3.6' }}{% endraw %} + with: + python-version: {% raw %}${{ matrix.python-version }}{% endraw %} + allow-prereleases: true + + {% if needs_clickhouse %} + - name: "Setup ClickHouse Server" + uses: getsentry/action-clickhouse-in-ci@v1.6 + {% endif %} + + {% if needs_redis %} + - name: Start Redis + uses: supercharge/redis-github-action@v2 + {% endif %} + + {% if needs_java %} + - name: Install Java + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '21' + {% endif %} + + - name: Setup Test Env + run: | + pip install "coverage[toml]" tox + - name: Erase coverage + run: | + coverage erase + + {% for framework in frameworks %} + - name: Test {{ framework }} + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "{% raw %}py${{ matrix.python-version }}{% endraw %}-{{ framework }}" + {% endfor %} + + - name: Generate coverage XML (Python 3.6) + if: {% raw %}${{ !cancelled() && matrix.python-version == '3.6' }}{% endraw %} + run: | + export COVERAGE_RCFILE=.coveragerc36 + coverage combine .coverage-sentry-* + coverage xml --ignore-errors + + - name: Generate coverage XML + if: {% raw %}${{ !cancelled() && matrix.python-version != '3.6' }}{% endraw %} + run: | + coverage combine .coverage-sentry-* + coverage xml + + - name: Upload coverage to Codecov + if: {% raw %}${{ !cancelled() }}{% endraw %} + uses: codecov/codecov-action@v5.5.2 + with: + token: {% raw %}${{ secrets.CODECOV_TOKEN }}{% endraw %} + files: coverage.xml + # make sure no plugins alter our coverage reports + plugins: noop + verbose: true + + - name: Upload test results to Codecov + if: {% raw %}${{ !cancelled() }}{% endraw %} + uses: codecov/test-results-action@v1 + with: + token: {% raw %}${{ secrets.CODECOV_TOKEN }}{% endraw %} + files: .junitxml + verbose: true diff --git a/scripts/test-lambda-locally/.gitignore b/scripts/test-lambda-locally/.gitignore new file mode 100644 index 0000000000..f9b7f4de58 --- /dev/null +++ b/scripts/test-lambda-locally/.gitignore @@ -0,0 +1,4 @@ +.envrc +.venv/ +package/ +lambda_deployment_package.zip diff --git a/scripts/test-lambda-locally/README.md b/scripts/test-lambda-locally/README.md new file mode 100644 index 0000000000..2c02d6301f --- /dev/null +++ b/scripts/test-lambda-locally/README.md @@ -0,0 +1,28 @@ +# Test AWS Lambda functions locally + +An easy way to run an AWS Lambda function with the Sentry SDK locally. + +This is a small helper to create a AWS Lambda function that includes the +currently checked out Sentry SDK and runs it in a local AWS Lambda environment. + +Currently only embedding the Sentry SDK into the Lambda function package +is supported. Adding the SDK as Lambda Layer is not possible at the moment. + +## Prerequisites + +- Set `SENTRY_DSN` environment variable. The Lambda function will use this DSN. +- You need to have Docker installed and running. + +## Run Lambda function + +- Update `lambda_function.py` to include your test code. +- Run `./deploy-lambda-locally.sh`. This will: + - Install [AWS SAM](https://aws.amazon.com/serverless/sam/) in a virtual Python environment + - Create a lambda function package in `package/` that includes + - The currently checked out Sentry SDK + - All dependencies of the Sentry SDK (certifi and urllib3) + - The actual function defined in `lamdba_function.py`. + - Zip everything together into lambda_deployment_package.zip + - Run a local Lambda environment that serves that Lambda function. +- Point your browser to `http://127.0.0.1:3000` to access your Lambda function. + - Currently GET and POST requests are possible. This is defined in `template.yaml`. diff --git a/scripts/test-lambda-locally/deploy-lambda-locally.sh b/scripts/test-lambda-locally/deploy-lambda-locally.sh new file mode 100755 index 0000000000..5da4fee6ba --- /dev/null +++ b/scripts/test-lambda-locally/deploy-lambda-locally.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash + +# exit on first error +set -xeuo pipefail + +# Setup local AWS Lambda environment + +# Install uv if it's not installed +if ! command -v uv &> /dev/null; then + curl -LsSf https://astral.sh/uv/install.sh | sh +fi + +uv sync + +# Create a deployment package of the lambda function in `lambda_function.py`. +rm -rf package && mkdir -p package +pip install ../../../sentry-python -t package/ --upgrade +cp lambda_function.py package/ +cd package && zip -r ../lambda_deployment_package.zip . && cd .. + +# Start the local Lambda server with the new function (defined in template.yaml) +uv run sam local start-api \ + --skip-pull-image \ + --force-image-build \ + --parameter-overrides SentryDsn=$SENTRY_DSN diff --git a/scripts/test-lambda-locally/lambda_function.py b/scripts/test-lambda-locally/lambda_function.py new file mode 100644 index 0000000000..fdaa2160eb --- /dev/null +++ b/scripts/test-lambda-locally/lambda_function.py @@ -0,0 +1,26 @@ +import logging +import os +import sentry_sdk + +from sentry_sdk.integrations.aws_lambda import AwsLambdaIntegration +from sentry_sdk.integrations.logging import LoggingIntegration + + +def lambda_handler(event, context): + sentry_sdk.init( + dsn=os.environ.get("SENTRY_DSN"), + attach_stacktrace=True, + integrations=[ + LoggingIntegration(level=logging.INFO, event_level=logging.ERROR), + AwsLambdaIntegration(timeout_warning=True), + ], + traces_sample_rate=1.0, + debug=True, + ) + + try: + my_dict = {"a": "test"} + _ = my_dict["b"] # This should raise exception + except: + logging.exception("Key Does not Exists") + raise diff --git a/scripts/test-lambda-locally/pyproject.toml b/scripts/test-lambda-locally/pyproject.toml new file mode 100644 index 0000000000..522e9620e8 --- /dev/null +++ b/scripts/test-lambda-locally/pyproject.toml @@ -0,0 +1,8 @@ +[project] +name = "test-lambda-locally" +version = "0" +requires-python = ">=3.12" + +dependencies = [ + "aws-sam-cli>=1.135.0", +] diff --git a/scripts/test-lambda-locally/template.yaml b/scripts/test-lambda-locally/template.yaml new file mode 100644 index 0000000000..67b8f6e7da --- /dev/null +++ b/scripts/test-lambda-locally/template.yaml @@ -0,0 +1,29 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Resources: + SentryLambdaFunction: + Type: AWS::Serverless::Function + Properties: + CodeUri: lambda_deployment_package.zip + Handler: lambda_function.lambda_handler + Runtime: python3.12 + Timeout: 30 + Environment: + Variables: + SENTRY_DSN: !Ref SentryDsn + Events: + ApiEventGet: + Type: Api + Properties: + Path: / + Method: get + ApiEventPost: + Type: Api + Properties: + Path: / + Method: post + +Parameters: + SentryDsn: + Type: String + Default: '' diff --git a/scripts/test-lambda-locally/uv.lock b/scripts/test-lambda-locally/uv.lock new file mode 100644 index 0000000000..889ca8e62f --- /dev/null +++ b/scripts/test-lambda-locally/uv.lock @@ -0,0 +1,1239 @@ +version = 1 +revision = 1 +requires-python = ">=3.12" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, +] + +[[package]] +name = "arrow" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "types-python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/00/0f6e8fcdb23ea632c866620cc872729ff43ed91d284c866b515c6342b173/arrow-1.3.0.tar.gz", hash = "sha256:d4540617648cb5f895730f1ad8c82a65f2dad0166f57b75f3ca54759c4d67a85", size = 131960 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/ed/e97229a566617f2ae958a6b13e7cc0f585470eac730a73e9e82c32a3cdd2/arrow-1.3.0-py3-none-any.whl", hash = "sha256:c728b120ebc00eb84e01882a6f5e7927a53960aa990ce7dd2b10f39005a67f80", size = 66419 }, +] + +[[package]] +name = "attrs" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/7c/fdf464bcc51d23881d110abd74b512a42b3d5d376a55a831b44c603ae17f/attrs-25.1.0.tar.gz", hash = "sha256:1c97078a80c814273a76b2a298a932eb681c87415c11dee0a6921de7f1b02c3e", size = 810562 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/30/d4986a882011f9df997a55e6becd864812ccfcd821d64aac8570ee39f719/attrs-25.1.0-py3-none-any.whl", hash = "sha256:c75a69e28a550a7e93789579c22aa26b0f5b83b75dc4e08fe092980051e1090a", size = 63152 }, +] + +[[package]] +name = "aws-lambda-builders" +version = "1.53.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "setuptools" }, + { name = "wheel" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/0a/09a966ac588a3eb3333348a5e13892889fe9531a491359b35bc5b7b13818/aws_lambda_builders-1.53.0.tar.gz", hash = "sha256:d08bfa947fff590f1bedd16c2f4ec7722cbb8869aae80764d99215a41ff284a1", size = 95491 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/8c/9cf80784437059db1999655a943eb950a0587793c3fddb56aee3c0f60ae3/aws_lambda_builders-1.53.0-py3-none-any.whl", hash = "sha256:ca9ddd99214aef8a113a3fcd7d7fe3951ef0e078478484f03c398a3bdee04ccb", size = 131138 }, +] + +[[package]] +name = "aws-sam-cli" +version = "1.135.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aws-lambda-builders" }, + { name = "aws-sam-translator" }, + { name = "boto3" }, + { name = "boto3-stubs", extra = ["apigateway", "cloudformation", "ecr", "iam", "kinesis", "lambda", "s3", "schemas", "secretsmanager", "signer", "sqs", "stepfunctions", "sts", "xray"] }, + { name = "cfn-lint" }, + { name = "chevron" }, + { name = "click" }, + { name = "cookiecutter" }, + { name = "dateparser" }, + { name = "docker" }, + { name = "flask" }, + { name = "jmespath" }, + { name = "jsonschema" }, + { name = "pyopenssl" }, + { name = "pyyaml" }, + { name = "regex" }, + { name = "requests" }, + { name = "rich" }, + { name = "ruamel-yaml" }, + { name = "tomlkit" }, + { name = "typing-extensions" }, + { name = "tzlocal" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cc/ff/92159d25b8c563de8605cb67b18c6d4ec68880d2dfd7eac689f0f4b80f57/aws_sam_cli-1.135.0.tar.gz", hash = "sha256:c630b351feeb4854ad5ecea6768920c61e7d331b3d040a677fa8744380f48808", size = 5792676 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/0f/f299f9ac27d946d7bf5fb11b3d01e7d1f5affd2ec9220449636949ccc39a/aws_sam_cli-1.135.0-py3-none-any.whl", hash = "sha256:473d30202b89a9624201e46b3ecb9ad5bcd05332c3d308a888464f002c29432b", size = 6077290 }, +] + +[[package]] +name = "aws-sam-translator" +version = "1.95.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "boto3" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/8c/4ea1c5fafdec02f2b3a91d60889219a42c18f5c3dd93ec13ef985e4249f6/aws_sam_translator-1.95.0.tar.gz", hash = "sha256:fd2b891fc4cbdde1e06130eaf2710de5cc74442a656b7859b3840691144494cf", size = 327484 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/5a/2edbe63d0b1c1e3c685a9b8464626f59c48bfbcc4e20142acae5ddea504c/aws_sam_translator-1.95.0-py3-none-any.whl", hash = "sha256:c9e0f22cbe83c768f7d20a3afb7e654bd6bfc087b387528bd48e98366b82ae40", size = 385846 }, +] + +[[package]] +name = "binaryornot" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "chardet" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/fe/7ebfec74d49f97fc55cd38240c7a7d08134002b1e14be8c3897c0dd5e49b/binaryornot-0.4.4.tar.gz", hash = "sha256:359501dfc9d40632edc9fac890e19542db1a287bbcfa58175b66658392018061", size = 371054 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/7e/f7b6f453e6481d1e233540262ccbfcf89adcd43606f44a028d7f5fae5eb2/binaryornot-0.4.4-py2.py3-none-any.whl", hash = "sha256:b8b71173c917bddcd2c16070412e369c3ed7f0528926f70cac18a6c97fd563e4", size = 9006 }, +] + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458 }, +] + +[[package]] +name = "boto3" +version = "1.37.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/12/948ab48f2e2d4eda72f907352e67379334ded1a2a6d1ebbaac11e77dfca9/boto3-1.37.11.tar.gz", hash = "sha256:8eec08363ef5db05c2fbf58e89f0c0de6276cda2fdce01e76b3b5f423cd5c0f4", size = 111323 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/55/0afe0471e391f4aaa99e5216b5c9ce6493756c0b7a7d8f8ffe85ba83b7a0/boto3-1.37.11-py3-none-any.whl", hash = "sha256:da6c22fc8a7e9bca5d7fc465a877ac3d45b6b086d776bd1a6c55bdde60523741", size = 139553 }, +] + +[[package]] +name = "boto3-stubs" +version = "1.35.71" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore-stubs" }, + { name = "types-s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/85/86243ad2792f8506b567c645d97ece548258203c55bcc165fd5801f4372f/boto3_stubs-1.35.71.tar.gz", hash = "sha256:50e20fa74248c96b3e3498b2d81388585583e38b9f0609d2fa58257e49c986a5", size = 93776 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/d1/aedf5f4a92e1e74ee29a4d43084780f2d77aeef3d734e550aa2ab304e1fb/boto3_stubs-1.35.71-py3-none-any.whl", hash = "sha256:4abf357250bdb16d1a56489a59bfc385d132a43677956bd984f6578638d599c0", size = 62964 }, +] + +[package.optional-dependencies] +apigateway = [ + { name = "mypy-boto3-apigateway" }, +] +cloudformation = [ + { name = "mypy-boto3-cloudformation" }, +] +ecr = [ + { name = "mypy-boto3-ecr" }, +] +iam = [ + { name = "mypy-boto3-iam" }, +] +kinesis = [ + { name = "mypy-boto3-kinesis" }, +] +lambda = [ + { name = "mypy-boto3-lambda" }, +] +s3 = [ + { name = "mypy-boto3-s3" }, +] +schemas = [ + { name = "mypy-boto3-schemas" }, +] +secretsmanager = [ + { name = "mypy-boto3-secretsmanager" }, +] +signer = [ + { name = "mypy-boto3-signer" }, +] +sqs = [ + { name = "mypy-boto3-sqs" }, +] +stepfunctions = [ + { name = "mypy-boto3-stepfunctions" }, +] +sts = [ + { name = "mypy-boto3-sts" }, +] +xray = [ + { name = "mypy-boto3-xray" }, +] + +[[package]] +name = "botocore" +version = "1.37.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/24/ce/b11d4405b8be900bfea15d9460376ff6f07dd0e1b1f8a47e2671bf6e5ca8/botocore-1.37.11.tar.gz", hash = "sha256:72eb3a9a58b064be26ba154e5e56373633b58f951941c340ace0d379590d98b5", size = 13640593 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/0d/b07e9b6cd8823e520f1782742730f2e68b68ad7444825ed8dd8fcdb98fcb/botocore-1.37.11-py3-none-any.whl", hash = "sha256:02505309b1235f9f15a6da79103ca224b3f3dc5f6a62f8630fbb2c6ed05e2da8", size = 13407367 }, +] + +[[package]] +name = "botocore-stubs" +version = "1.37.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "types-awscrt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/6f/710664aac77cf91a663dcb291c2bbdcfe796909115aa5bb03382521359b1/botocore_stubs-1.37.11.tar.gz", hash = "sha256:9b89ba9a98eb9f088a5f82c52488013858092777c17b56265574bbf2d21da422", size = 42119 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/89/c8a6497055f9ecd0af5c16434c277635a4b365793d54f2d8f2b28aeeb58e/botocore_stubs-1.37.11-py3-none-any.whl", hash = "sha256:bec458a0d054892cdf82466b4d075f30a36fa03ce34f9becbcace5f36ec674bf", size = 65384 }, +] + +[[package]] +name = "certifi" +version = "2025.1.31" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 }, +] + +[[package]] +name = "cffi" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178 }, + { url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840 }, + { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803 }, + { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850 }, + { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729 }, + { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256 }, + { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424 }, + { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568 }, + { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736 }, + { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448 }, + { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976 }, + { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989 }, + { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802 }, + { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792 }, + { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893 }, + { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810 }, + { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200 }, + { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447 }, + { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358 }, + { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469 }, + { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475 }, + { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009 }, +] + +[[package]] +name = "cfn-lint" +version = "1.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aws-sam-translator" }, + { name = "jsonpatch" }, + { name = "networkx" }, + { name = "pyyaml" }, + { name = "regex" }, + { name = "sympy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4d/c0/a36a1bdc6ba1fd4a7e5f48cd23a1802ccaf745ffb5c79e3fdf800eb5ae90/cfn_lint-1.25.1.tar.gz", hash = "sha256:717012566c6034ffa7e60fcf1b350804d093ee37589a1e91a1fd867f33a930b7", size = 2837233 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/1c/b03940f2213f308f19318aaa8847adfe789b834e497f8839b2c9a876618b/cfn_lint-1.25.1-py3-none-any.whl", hash = "sha256:bbf6c2d95689da466dc427217ab7ed8f3a2a4a134df70876cc63e41aaad9385a", size = 4907033 }, +] + +[[package]] +name = "chardet" +version = "5.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/f7b6ab21ec75897ed80c17d79b15951a719226b9fababf1e40ea74d69079/chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7", size = 2069618 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/6f/f5fbc992a329ee4e0f288c1fe0e2ad9485ed064cac731ed2fe47dcc38cbf/chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970", size = 199385 }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a2115599cc9dad142fee4b747c357/charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", size = 123188 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/9a/dd1e1cdceb841925b7798369a09279bd1cf183cef0f9ddf15a3a6502ee45/charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545", size = 196105 }, + { url = "https://files.pythonhosted.org/packages/d3/8c/90bfabf8c4809ecb648f39794cf2a84ff2e7d2a6cf159fe68d9a26160467/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7", size = 140404 }, + { url = "https://files.pythonhosted.org/packages/ad/8f/e410d57c721945ea3b4f1a04b74f70ce8fa800d393d72899f0a40526401f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757", size = 150423 }, + { url = "https://files.pythonhosted.org/packages/f0/b8/e6825e25deb691ff98cf5c9072ee0605dc2acfca98af70c2d1b1bc75190d/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa", size = 143184 }, + { url = "https://files.pythonhosted.org/packages/3e/a2/513f6cbe752421f16d969e32f3583762bfd583848b763913ddab8d9bfd4f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d", size = 145268 }, + { url = "https://files.pythonhosted.org/packages/74/94/8a5277664f27c3c438546f3eb53b33f5b19568eb7424736bdc440a88a31f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616", size = 147601 }, + { url = "https://files.pythonhosted.org/packages/7c/5f/6d352c51ee763623a98e31194823518e09bfa48be2a7e8383cf691bbb3d0/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b", size = 141098 }, + { url = "https://files.pythonhosted.org/packages/78/d4/f5704cb629ba5ab16d1d3d741396aec6dc3ca2b67757c45b0599bb010478/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d", size = 149520 }, + { url = "https://files.pythonhosted.org/packages/c5/96/64120b1d02b81785f222b976c0fb79a35875457fa9bb40827678e54d1bc8/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a", size = 152852 }, + { url = "https://files.pythonhosted.org/packages/84/c9/98e3732278a99f47d487fd3468bc60b882920cef29d1fa6ca460a1fdf4e6/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9", size = 150488 }, + { url = "https://files.pythonhosted.org/packages/13/0e/9c8d4cb99c98c1007cc11eda969ebfe837bbbd0acdb4736d228ccaabcd22/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1", size = 146192 }, + { url = "https://files.pythonhosted.org/packages/b2/21/2b6b5b860781a0b49427309cb8670785aa543fb2178de875b87b9cc97746/charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35", size = 95550 }, + { url = "https://files.pythonhosted.org/packages/21/5b/1b390b03b1d16c7e382b561c5329f83cc06623916aab983e8ab9239c7d5c/charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f", size = 102785 }, + { url = "https://files.pythonhosted.org/packages/38/94/ce8e6f63d18049672c76d07d119304e1e2d7c6098f0841b51c666e9f44a0/charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", size = 195698 }, + { url = "https://files.pythonhosted.org/packages/24/2e/dfdd9770664aae179a96561cc6952ff08f9a8cd09a908f259a9dfa063568/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", size = 140162 }, + { url = "https://files.pythonhosted.org/packages/24/4e/f646b9093cff8fc86f2d60af2de4dc17c759de9d554f130b140ea4738ca6/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", size = 150263 }, + { url = "https://files.pythonhosted.org/packages/5e/67/2937f8d548c3ef6e2f9aab0f6e21001056f692d43282b165e7c56023e6dd/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", size = 142966 }, + { url = "https://files.pythonhosted.org/packages/52/ed/b7f4f07de100bdb95c1756d3a4d17b90c1a3c53715c1a476f8738058e0fa/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", size = 144992 }, + { url = "https://files.pythonhosted.org/packages/96/2c/d49710a6dbcd3776265f4c923bb73ebe83933dfbaa841c5da850fe0fd20b/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", size = 147162 }, + { url = "https://files.pythonhosted.org/packages/b4/41/35ff1f9a6bd380303dea55e44c4933b4cc3c4850988927d4082ada230273/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", size = 140972 }, + { url = "https://files.pythonhosted.org/packages/fb/43/c6a0b685fe6910d08ba971f62cd9c3e862a85770395ba5d9cad4fede33ab/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", size = 149095 }, + { url = "https://files.pythonhosted.org/packages/4c/ff/a9a504662452e2d2878512115638966e75633519ec11f25fca3d2049a94a/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", size = 152668 }, + { url = "https://files.pythonhosted.org/packages/6c/71/189996b6d9a4b932564701628af5cee6716733e9165af1d5e1b285c530ed/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", size = 150073 }, + { url = "https://files.pythonhosted.org/packages/e4/93/946a86ce20790e11312c87c75ba68d5f6ad2208cfb52b2d6a2c32840d922/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", size = 145732 }, + { url = "https://files.pythonhosted.org/packages/cd/e5/131d2fb1b0dddafc37be4f3a2fa79aa4c037368be9423061dccadfd90091/charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", size = 95391 }, + { url = "https://files.pythonhosted.org/packages/27/f2/4f9a69cc7712b9b5ad8fdb87039fd89abba997ad5cbe690d1835d40405b0/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", size = 102702 }, + { url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767 }, +] + +[[package]] +name = "chevron" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/1f/ca74b65b19798895d63a6e92874162f44233467c9e7c1ed8afd19016ebe9/chevron-0.14.0.tar.gz", hash = "sha256:87613aafdf6d77b6a90ff073165a61ae5086e21ad49057aa0e53681601800ebf", size = 11440 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/93/342cc62a70ab727e093ed98e02a725d85b746345f05d2b5e5034649f4ec8/chevron-0.14.0-py3-none-any.whl", hash = "sha256:fbf996a709f8da2e745ef763f482ce2d311aa817d287593a5b990d6d6e4f0443", size = 11595 }, +] + +[[package]] +name = "click" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "cookiecutter" +version = "2.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "arrow" }, + { name = "binaryornot" }, + { name = "click" }, + { name = "jinja2" }, + { name = "python-slugify" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/17/9f2cd228eb949a91915acd38d3eecdc9d8893dde353b603f0db7e9f6be55/cookiecutter-2.6.0.tar.gz", hash = "sha256:db21f8169ea4f4fdc2408d48ca44859349de2647fbe494a9d6c3edfc0542c21c", size = 158767 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/d9/0137658a353168ffa9d0fc14b812d3834772040858ddd1cb6eeaf09f7a44/cookiecutter-2.6.0-py3-none-any.whl", hash = "sha256:a54a8e37995e4ed963b3e82831072d1ad4b005af736bb17b99c2cbd9d41b6e2d", size = 39177 }, +] + +[[package]] +name = "cryptography" +version = "44.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/25/4ce80c78963834b8a9fd1cc1266be5ed8d1840785c0f2e1b73b8d128d505/cryptography-44.0.2.tar.gz", hash = "sha256:c63454aa261a0cf0c5b4718349629793e9e634993538db841165b3df74f37ec0", size = 710807 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/ef/83e632cfa801b221570c5f58c0369db6fa6cef7d9ff859feab1aae1a8a0f/cryptography-44.0.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:efcfe97d1b3c79e486554efddeb8f6f53a4cdd4cf6086642784fa31fc384e1d7", size = 6676361 }, + { url = "https://files.pythonhosted.org/packages/30/ec/7ea7c1e4c8fc8329506b46c6c4a52e2f20318425d48e0fe597977c71dbce/cryptography-44.0.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29ecec49f3ba3f3849362854b7253a9f59799e3763b0c9d0826259a88efa02f1", size = 3952350 }, + { url = "https://files.pythonhosted.org/packages/27/61/72e3afdb3c5ac510330feba4fc1faa0fe62e070592d6ad00c40bb69165e5/cryptography-44.0.2-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc821e161ae88bfe8088d11bb39caf2916562e0a2dc7b6d56714a48b784ef0bb", size = 4166572 }, + { url = "https://files.pythonhosted.org/packages/26/e4/ba680f0b35ed4a07d87f9e98f3ebccb05091f3bf6b5a478b943253b3bbd5/cryptography-44.0.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3c00b6b757b32ce0f62c574b78b939afab9eecaf597c4d624caca4f9e71e7843", size = 3958124 }, + { url = "https://files.pythonhosted.org/packages/9c/e8/44ae3e68c8b6d1cbc59040288056df2ad7f7f03bbcaca6b503c737ab8e73/cryptography-44.0.2-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7bdcd82189759aba3816d1f729ce42ffded1ac304c151d0a8e89b9996ab863d5", size = 3678122 }, + { url = "https://files.pythonhosted.org/packages/27/7b/664ea5e0d1eab511a10e480baf1c5d3e681c7d91718f60e149cec09edf01/cryptography-44.0.2-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4973da6ca3db4405c54cd0b26d328be54c7747e89e284fcff166132eb7bccc9c", size = 4191831 }, + { url = "https://files.pythonhosted.org/packages/2a/07/79554a9c40eb11345e1861f46f845fa71c9e25bf66d132e123d9feb8e7f9/cryptography-44.0.2-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:4e389622b6927d8133f314949a9812972711a111d577a5d1f4bee5e58736b80a", size = 3960583 }, + { url = "https://files.pythonhosted.org/packages/bb/6d/858e356a49a4f0b591bd6789d821427de18432212e137290b6d8a817e9bf/cryptography-44.0.2-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f514ef4cd14bb6fb484b4a60203e912cfcb64f2ab139e88c2274511514bf7308", size = 4191753 }, + { url = "https://files.pythonhosted.org/packages/b2/80/62df41ba4916067fa6b125aa8c14d7e9181773f0d5d0bd4dcef580d8b7c6/cryptography-44.0.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1bc312dfb7a6e5d66082c87c34c8a62176e684b6fe3d90fcfe1568de675e6688", size = 4079550 }, + { url = "https://files.pythonhosted.org/packages/f3/cd/2558cc08f7b1bb40683f99ff4327f8dcfc7de3affc669e9065e14824511b/cryptography-44.0.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b721b8b4d948b218c88cb8c45a01793483821e709afe5f622861fc6182b20a7", size = 4298367 }, + { url = "https://files.pythonhosted.org/packages/71/59/94ccc74788945bc3bd4cf355d19867e8057ff5fdbcac781b1ff95b700fb1/cryptography-44.0.2-cp37-abi3-win32.whl", hash = "sha256:51e4de3af4ec3899d6d178a8c005226491c27c4ba84101bfb59c901e10ca9f79", size = 2772843 }, + { url = "https://files.pythonhosted.org/packages/ca/2c/0d0bbaf61ba05acb32f0841853cfa33ebb7a9ab3d9ed8bb004bd39f2da6a/cryptography-44.0.2-cp37-abi3-win_amd64.whl", hash = "sha256:c505d61b6176aaf982c5717ce04e87da5abc9a36a5b39ac03905c4aafe8de7aa", size = 3209057 }, + { url = "https://files.pythonhosted.org/packages/9e/be/7a26142e6d0f7683d8a382dd963745e65db895a79a280a30525ec92be890/cryptography-44.0.2-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:8e0ddd63e6bf1161800592c71ac794d3fb8001f2caebe0966e77c5234fa9efc3", size = 6677789 }, + { url = "https://files.pythonhosted.org/packages/06/88/638865be7198a84a7713950b1db7343391c6066a20e614f8fa286eb178ed/cryptography-44.0.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81276f0ea79a208d961c433a947029e1a15948966658cf6710bbabb60fcc2639", size = 3951919 }, + { url = "https://files.pythonhosted.org/packages/d7/fc/99fe639bcdf58561dfad1faa8a7369d1dc13f20acd78371bb97a01613585/cryptography-44.0.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a1e657c0f4ea2a23304ee3f964db058c9e9e635cc7019c4aa21c330755ef6fd", size = 4167812 }, + { url = "https://files.pythonhosted.org/packages/53/7b/aafe60210ec93d5d7f552592a28192e51d3c6b6be449e7fd0a91399b5d07/cryptography-44.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6210c05941994290f3f7f175a4a57dbbb2afd9273657614c506d5976db061181", size = 3958571 }, + { url = "https://files.pythonhosted.org/packages/16/32/051f7ce79ad5a6ef5e26a92b37f172ee2d6e1cce09931646eef8de1e9827/cryptography-44.0.2-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1c3572526997b36f245a96a2b1713bf79ce99b271bbcf084beb6b9b075f29ea", size = 3679832 }, + { url = "https://files.pythonhosted.org/packages/78/2b/999b2a1e1ba2206f2d3bca267d68f350beb2b048a41ea827e08ce7260098/cryptography-44.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b042d2a275c8cee83a4b7ae30c45a15e6a4baa65a179a0ec2d78ebb90e4f6699", size = 4193719 }, + { url = "https://files.pythonhosted.org/packages/72/97/430e56e39a1356e8e8f10f723211a0e256e11895ef1a135f30d7d40f2540/cryptography-44.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:d03806036b4f89e3b13b6218fefea8d5312e450935b1a2d55f0524e2ed7c59d9", size = 3960852 }, + { url = "https://files.pythonhosted.org/packages/89/33/c1cf182c152e1d262cac56850939530c05ca6c8d149aa0dcee490b417e99/cryptography-44.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c7362add18b416b69d58c910caa217f980c5ef39b23a38a0880dfd87bdf8cd23", size = 4193906 }, + { url = "https://files.pythonhosted.org/packages/e1/99/87cf26d4f125380dc674233971069bc28d19b07f7755b29861570e513650/cryptography-44.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:8cadc6e3b5a1f144a039ea08a0bdb03a2a92e19c46be3285123d32029f40a922", size = 4081572 }, + { url = "https://files.pythonhosted.org/packages/b3/9f/6a3e0391957cc0c5f84aef9fbdd763035f2b52e998a53f99345e3ac69312/cryptography-44.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6f101b1f780f7fc613d040ca4bdf835c6ef3b00e9bd7125a4255ec574c7916e4", size = 4298631 }, + { url = "https://files.pythonhosted.org/packages/e2/a5/5bc097adb4b6d22a24dea53c51f37e480aaec3465285c253098642696423/cryptography-44.0.2-cp39-abi3-win32.whl", hash = "sha256:3dc62975e31617badc19a906481deacdeb80b4bb454394b4098e3f2525a488c5", size = 2773792 }, + { url = "https://files.pythonhosted.org/packages/33/cf/1f7649b8b9a3543e042d3f348e398a061923ac05b507f3f4d95f11938aa9/cryptography-44.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:5f6f90b72d8ccadb9c6e311c775c8305381db88374c65fa1a68250aa8a9cb3a6", size = 3210957 }, +] + +[[package]] +name = "dateparser" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "regex" }, + { name = "tzlocal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/3f/d3207a05f5b6a78c66d86631e60bfba5af163738a599a5b9aa2c2737a09e/dateparser-1.2.1.tar.gz", hash = "sha256:7e4919aeb48481dbfc01ac9683c8e20bfe95bb715a38c1e9f6af889f4f30ccc3", size = 309924 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/0a/981c438c4cd84147c781e4e96c1d72df03775deb1bc76c5a6ee8afa89c62/dateparser-1.2.1-py3-none-any.whl", hash = "sha256:bdcac262a467e6260030040748ad7c10d6bacd4f3b9cdb4cfd2251939174508c", size = 295658 }, +] + +[[package]] +name = "docker" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/9b/4a2ea29aeba62471211598dac5d96825bb49348fa07e906ea930394a83ce/docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c", size = 117834 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774 }, +] + +[[package]] +name = "flask" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/89/50/dff6380f1c7f84135484e176e0cac8690af72fa90e932ad2a0a60e28c69b/flask-3.1.0.tar.gz", hash = "sha256:5f873c5184c897c8d9d1b05df1e3d01b14910ce69607a117bd3277098a5836ac", size = 680824 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/47/93213ee66ef8fae3b93b3e29206f6b251e65c97bd91d8e1c5596ef15af0a/flask-3.1.0-py3-none-any.whl", hash = "sha256:d667207822eb83f1c4b50949b1623c8fc8d51f2341d65f72e1a1815397551136", size = 102979 }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234 }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 }, +] + +[[package]] +name = "jmespath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/2a/e867e8531cf3e36b41201936b7fa7ba7b5702dbef42922193f05c8976cd6/jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe", size = 25843 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/b4/b9b800c45527aadd64d5b442f9b932b00648617eb5d63d2c7a6587b7cafc/jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980", size = 20256 }, +] + +[[package]] +name = "jsonpatch" +version = "1.33" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonpointer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/78/18813351fe5d63acad16aec57f94ec2b70a09e53ca98145589e185423873/jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c", size = 21699 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/07/02e16ed01e04a374e644b575638ec7987ae846d25ad97bcc9945a3ee4b0e/jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade", size = 12898 }, +] + +[[package]] +name = "jsonpointer" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/0a/eebeb1fa92507ea94016a2a790b93c2ae41a7e18778f85471dc54475ed25/jsonpointer-3.0.0.tar.gz", hash = "sha256:2b2d729f2091522d61c3b31f82e11870f60b68f43fbc705cb76bf4b832af59ef", size = 9114 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/92/5e77f98553e9e75130c78900d000368476aed74276eb8ae8796f65f00918/jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942", size = 7595 }, +] + +[[package]] +name = "jsonschema" +version = "4.23.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/2e/03362ee4034a4c917f697890ccd4aec0800ccf9ded7f511971c75451deec/jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4", size = 325778 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/4a/4f9dbeb84e8850557c02365a0eee0649abe5eb1d84af92a25731c6c0f922/jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566", size = 88462 }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2024.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/db/58f950c996c793472e336ff3655b13fbcf1e3b359dcf52dcf3ed3b52c352/jsonschema_specifications-2024.10.1.tar.gz", hash = "sha256:0f38b83639958ce1152d02a7f062902c41c8fd20d558b0c34344292d417ae272", size = 15561 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/0f/8910b19ac0670a0f80ce1008e5e751c4a57e14d2c4c13a482aa6079fa9d6/jsonschema_specifications-2024.10.1-py3-none-any.whl", hash = "sha256:a09a0680616357d9a0ecf05c12ad234479f549239d0f5b55f3deea67475da9bf", size = 18459 }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, +] + +[[package]] +name = "markupsafe" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274 }, + { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348 }, + { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149 }, + { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118 }, + { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993 }, + { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178 }, + { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319 }, + { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352 }, + { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097 }, + { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601 }, + { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274 }, + { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352 }, + { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122 }, + { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085 }, + { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978 }, + { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208 }, + { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357 }, + { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344 }, + { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101 }, + { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603 }, + { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510 }, + { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486 }, + { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480 }, + { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914 }, + { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796 }, + { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473 }, + { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114 }, + { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098 }, + { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208 }, + { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739 }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, +] + +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198 }, +] + +[[package]] +name = "mypy-boto3-apigateway" +version = "1.35.93" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/3d/c5dc7a750d9fdba2bf704d3d963be9ad4ed617fe5bb98e5c88374a3d8d69/mypy_boto3_apigateway-1.35.93.tar.gz", hash = "sha256:df90957c5f2c219663f825b905cb53b9f53fd7982e01bb21da65f5757c3d5d41", size = 44837 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/7d/89f26a626ab30283143222430bd39ec46cf8a2ae002e5b5c590e01ff3ad0/mypy_boto3_apigateway-1.35.93-py3-none-any.whl", hash = "sha256:a5649e9899209470c35249651f7f2faa7d6919aab6b4fcac7bd4a54c11e872bc", size = 50874 }, +] + +[[package]] +name = "mypy-boto3-cloudformation" +version = "1.35.93" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/26/e59425e30fb1783aa718f1a8ac93cdc415e279e175c953ee0a72310f7490/mypy_boto3_cloudformation-1.35.93.tar.gz", hash = "sha256:57dc112ff3e2ddc1e9e621e428490b904c0da8c1532d30e9fa2a19aefde9f719", size = 54529 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/52/6e73adba190fc65c5cf89ed9394cc8a1acb073989f4eda87f80f451c9b15/mypy_boto3_cloudformation-1.35.93-py3-none-any.whl", hash = "sha256:4111913cb2c9fd9099ecd616212923312fde0c126ee41f5821759ae9df4272b9", size = 66124 }, +] + +[[package]] +name = "mypy-boto3-ecr" +version = "1.35.93" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/ae/1598bf3dc7069f0e48a60a482dffa71885e1558aa076243375820de2792f/mypy_boto3_ecr-1.35.93.tar.gz", hash = "sha256:57295a72a9473b8542578ab15eb0a4909cad6f2cee1da41ce6a8a40ab7051438", size = 33904 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/3b/4130e22423812da282bd9ebbf08a0f14ed2e314409847bc336b841c8177b/mypy_boto3_ecr-1.35.93-py3-none-any.whl", hash = "sha256:49d98ac7376e919c0061da44aeae9577b63343eee2c1d537fd636d8886db9ad2", size = 39733 }, +] + +[[package]] +name = "mypy-boto3-iam" +version = "1.35.93" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/7cb0b26c3af8207496880155441cfd7f5d8c5404d4669e39385eb307672d/mypy_boto3_iam-1.35.93.tar.gz", hash = "sha256:2595c8dac406e4e771d3b7d7835faacb936d20449b9cdd17a53f076219cc7712", size = 85815 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/5a/2694c8c692fad6908c3a52f629eb87b04c242dc8bb0091e56ff3780cdb45/mypy_boto3_iam-1.35.93-py3-none-any.whl", hash = "sha256:e2955040062bf9cb587a1874e1b2f2cca33cbf167187fd3a56b6c5412cc13dc9", size = 91125 }, +] + +[[package]] +name = "mypy-boto3-kinesis" +version = "1.35.93" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/c3/eb9f1aeaf42ea55c473b0281fe5813aafe3283733ad84fbd27c370416753/mypy_boto3_kinesis-1.35.93.tar.gz", hash = "sha256:f0718f5b54b955761790b4b33bdcab8d0c779bd50cc671c6862a8e0554515bda", size = 22476 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/bd/e44b999f516116dcb034262a1ed04d8ed3b830e84970b1224823ce866031/mypy_boto3_kinesis-1.35.93-py3-none-any.whl", hash = "sha256:fb11df380319e3cf5c26f43536107593836e36c6b9f3b415a7016aeaed2af1de", size = 32164 }, +] + +[[package]] +name = "mypy-boto3-lambda" +version = "1.35.93" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/ef/b90e51be87b5c226005c765a7109a26b5ce39cf349f2603336bd5c365863/mypy_boto3_lambda-1.35.93.tar.gz", hash = "sha256:c11b047743c7635ea8385abffaf97788a108b71479612e9b5e7d0bb19029d7a4", size = 41120 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/f0/3c03cc63c157046106f59768e915c21377a372be6bc9f079601dd646cf4d/mypy_boto3_lambda-1.35.93-py3-none-any.whl", hash = "sha256:6bcd623c827724cde0b21b30c328515811b178763b75f0701a641cc7aa3aa414", size = 47708 }, +] + +[[package]] +name = "mypy-boto3-s3" +version = "1.35.93" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/53/99667aad21b236612ecb50eee09fdc4de6fbe39c3a75a6bad387d108ed1f/mypy_boto3_s3-1.35.93.tar.gz", hash = "sha256:b4529e57a8d5f21d4c61fe650fa6764fee2ba7ab524a455a34ba2698ef6d27a8", size = 72871 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/52/9d45db5690eb2b3160c43259d70dd6890d9bc24633848bcb8ef835d44d6c/mypy_boto3_s3-1.35.93-py3-none-any.whl", hash = "sha256:4cd3f1718fa0d8a54212c495cdff493bdcc6a8ae419d95428c60fb6bc7db7980", size = 79501 }, +] + +[[package]] +name = "mypy-boto3-schemas" +version = "1.35.93" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/f7/63c5b0db122b99265a14f179f41ab01566610c78abe14e63a4df3ebca7fa/mypy_boto3_schemas-1.35.93.tar.gz", hash = "sha256:7f2255ddd6d531101ec67fbd1afca8be02568f4e5787d1631199aa25b58a480f", size = 20680 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/37/cf848ce4ec07bbd7d64c91efe8d31f5aa86bf5d6d2a9f7123ca3ce3fed44/mypy_boto3_schemas-1.35.93-py3-none-any.whl", hash = "sha256:9e82b7d6e059a531359cc0304b5d4c979406d06e9d19482c7a22ccb61b40c7ff", size = 28746 }, +] + +[[package]] +name = "mypy-boto3-secretsmanager" +version = "1.35.93" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/c6/1c69c3ac9fadeb6cc01da5a90edd5f36cbf09a4fa66e8cef638917eba4d1/mypy_boto3_secretsmanager-1.35.93.tar.gz", hash = "sha256:b6c4bc88a5fe4143124272728d41342e01c778b406db9d647a20dad0de7d6f47", size = 19624 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/ff/758f8869d10b10bf6bec7908bd9d532fdd26b6f04c2af4de3751d2c92b93/mypy_boto3_secretsmanager-1.35.93-py3-none-any.whl", hash = "sha256:521075d42b6d05f0d7302d1837520e9111a84d6613152d32dc8cbb3cd6fceeec", size = 26581 }, +] + +[[package]] +name = "mypy-boto3-signer" +version = "1.35.93" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d1/00/954104765b3414b0221cf18efebcee656f7b8be603866682a0dcf9e00ecf/mypy_boto3_signer-1.35.93.tar.gz", hash = "sha256:f12c7c7025cc25804146431f639f3eb9db664a4695bf28d2a87f58111fc7f888", size = 20496 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/a0/142a49f1bd98b9a393896e0912cc8dd7a1ac91c2fff224f2c4efb166e180/mypy_boto3_signer-1.35.93-py3-none-any.whl", hash = "sha256:e1ac026096be6a52b6de45771226efbd3909a1861a638441572d926650d7fd8c", size = 28770 }, +] + +[[package]] +name = "mypy-boto3-sqs" +version = "1.35.93" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/29/5b/040ba82c53d5edf578ad0aafcac501b91a259b40f296ef6662db975b6595/mypy_boto3_sqs-1.35.93.tar.gz", hash = "sha256:8ea7f63e0878544705c31996ae4c064095fbb4f780f8323a84f7a75281d643fe", size = 23344 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/eb/d8c10da3f905921f70f008f3bca092711e316ced49287e42f45309860aca/mypy_boto3_sqs-1.35.93-py3-none-any.whl", hash = "sha256:341974f77e66851b9a4190d0014481e6baabae82d32f9ee559faa823b693609b", size = 33491 }, +] + +[[package]] +name = "mypy-boto3-stepfunctions" +version = "1.35.93" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/f9/44a59a6c84edfd94477e5427befcbecdb4f92ae34d897536671dc4994e23/mypy_boto3_stepfunctions-1.35.93.tar.gz", hash = "sha256:20230615c42e7aabbd43b62657ca3534e96767245705d12d42672ac87cd1b59c", size = 30894 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/39/0964782eff12ec9c22a5dd78bc19f755df313fb6aa1215293444899dc40e/mypy_boto3_stepfunctions-1.35.93-py3-none-any.whl", hash = "sha256:7994450153298b87382119680d7fae4d8b5a6e6250cef364148ad8d0b84bd237", size = 35602 }, +] + +[[package]] +name = "mypy-boto3-sts" +version = "1.35.97" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/fc/652992367bad0bae7d1c8d8bd5fa455570de77337f8d0c2021263dc4e695/mypy_boto3_sts-1.35.97.tar.gz", hash = "sha256:6df698f6a400a82ebcc2f10adb43557f66278467200e0f75588e7de3e4a1622d", size = 16487 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/7c/092999366962bbe0bab5af8e18e0c8f70943ca34a42c214e3862df2fa80b/mypy_boto3_sts-1.35.97-py3-none-any.whl", hash = "sha256:50c32613aa9e8d33e5df922392e32daed6fcd0e4d4cc8d43f5948c69be1c9e1e", size = 19991 }, +] + +[[package]] +name = "mypy-boto3-xray" +version = "1.35.93" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/98/1ffe456cf073fe6ee1826f053943793d4082fe02412a109c72c0f414a66c/mypy_boto3_xray-1.35.93.tar.gz", hash = "sha256:7e0af9474f06da1923aa37c8639b051042cc3a56d1a36b0141124d9de7be6709", size = 31639 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/b4/826f269d883bd76df41b44fba4a49b2cd9b2a2a34a5561bc251bdb6778f2/mypy_boto3_xray-1.35.93-py3-none-any.whl", hash = "sha256:e80c2be40c5cb4851dc08c145101b4e52a6f471dab0fc5f488975f6e14f7cb93", size = 36455 }, +] + +[[package]] +name = "networkx" +version = "3.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/1d/06475e1cd5264c0b870ea2cc6fdb3e37177c1e565c43f56ff17a10e3937f/networkx-3.4.2.tar.gz", hash = "sha256:307c3669428c5362aab27c8a1260aa8f47c4e91d3891f48be0141738d8d053e1", size = 2151368 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/54/dd730b32ea14ea797530a4479b2ed46a6fb250f682a9cfb997e968bf0261/networkx-3.4.2-py3-none-any.whl", hash = "sha256:df5d4365b724cf81b8c6a7312509d0c22386097011ad1abe274afd5e9d3bbc5f", size = 1723263 }, +] + +[[package]] +name = "pycparser" +version = "2.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 }, +] + +[[package]] +name = "pydantic" +version = "2.10.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/ae/d5220c5c52b158b1de7ca89fc5edb72f304a70a4c540c84c8844bf4008de/pydantic-2.10.6.tar.gz", hash = "sha256:ca5daa827cce33de7a42be142548b0096bf05a7e7b365aebfa5f8eeec7128236", size = 761681 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/3c/8cc1cc84deffa6e25d2d0c688ebb80635dfdbf1dbea3e30c541c8cf4d860/pydantic-2.10.6-py3-none-any.whl", hash = "sha256:427d664bf0b8a2b34ff5dd0f5a18df00591adcee7198fbd71981054cef37b584", size = 431696 }, +] + +[[package]] +name = "pydantic-core" +version = "2.27.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/01/f3e5ac5e7c25833db5eb555f7b7ab24cd6f8c322d3a3ad2d67a952dc0abc/pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39", size = 413443 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/74/51c8a5482ca447871c93e142d9d4a92ead74de6c8dc5e66733e22c9bba89/pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0", size = 1893127 }, + { url = "https://files.pythonhosted.org/packages/d3/f3/c97e80721735868313c58b89d2de85fa80fe8dfeeed84dc51598b92a135e/pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef", size = 1811340 }, + { url = "https://files.pythonhosted.org/packages/9e/91/840ec1375e686dbae1bd80a9e46c26a1e0083e1186abc610efa3d9a36180/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7", size = 1822900 }, + { url = "https://files.pythonhosted.org/packages/f6/31/4240bc96025035500c18adc149aa6ffdf1a0062a4b525c932065ceb4d868/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934", size = 1869177 }, + { url = "https://files.pythonhosted.org/packages/fa/20/02fbaadb7808be578317015c462655c317a77a7c8f0ef274bc016a784c54/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6", size = 2038046 }, + { url = "https://files.pythonhosted.org/packages/06/86/7f306b904e6c9eccf0668248b3f272090e49c275bc488a7b88b0823444a4/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c", size = 2685386 }, + { url = "https://files.pythonhosted.org/packages/8d/f0/49129b27c43396581a635d8710dae54a791b17dfc50c70164866bbf865e3/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2", size = 1997060 }, + { url = "https://files.pythonhosted.org/packages/0d/0f/943b4af7cd416c477fd40b187036c4f89b416a33d3cc0ab7b82708a667aa/pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4", size = 2004870 }, + { url = "https://files.pythonhosted.org/packages/35/40/aea70b5b1a63911c53a4c8117c0a828d6790483f858041f47bab0b779f44/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3", size = 1999822 }, + { url = "https://files.pythonhosted.org/packages/f2/b3/807b94fd337d58effc5498fd1a7a4d9d59af4133e83e32ae39a96fddec9d/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4", size = 2130364 }, + { url = "https://files.pythonhosted.org/packages/fc/df/791c827cd4ee6efd59248dca9369fb35e80a9484462c33c6649a8d02b565/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57", size = 2158303 }, + { url = "https://files.pythonhosted.org/packages/9b/67/4e197c300976af185b7cef4c02203e175fb127e414125916bf1128b639a9/pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc", size = 1834064 }, + { url = "https://files.pythonhosted.org/packages/1f/ea/cd7209a889163b8dcca139fe32b9687dd05249161a3edda62860430457a5/pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9", size = 1989046 }, + { url = "https://files.pythonhosted.org/packages/bc/49/c54baab2f4658c26ac633d798dab66b4c3a9bbf47cff5284e9c182f4137a/pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b", size = 1885092 }, + { url = "https://files.pythonhosted.org/packages/41/b1/9bc383f48f8002f99104e3acff6cba1231b29ef76cfa45d1506a5cad1f84/pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b", size = 1892709 }, + { url = "https://files.pythonhosted.org/packages/10/6c/e62b8657b834f3eb2961b49ec8e301eb99946245e70bf42c8817350cbefc/pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154", size = 1811273 }, + { url = "https://files.pythonhosted.org/packages/ba/15/52cfe49c8c986e081b863b102d6b859d9defc63446b642ccbbb3742bf371/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9", size = 1823027 }, + { url = "https://files.pythonhosted.org/packages/b1/1c/b6f402cfc18ec0024120602bdbcebc7bdd5b856528c013bd4d13865ca473/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9", size = 1868888 }, + { url = "https://files.pythonhosted.org/packages/bd/7b/8cb75b66ac37bc2975a3b7de99f3c6f355fcc4d89820b61dffa8f1e81677/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1", size = 2037738 }, + { url = "https://files.pythonhosted.org/packages/c8/f1/786d8fe78970a06f61df22cba58e365ce304bf9b9f46cc71c8c424e0c334/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a", size = 2685138 }, + { url = "https://files.pythonhosted.org/packages/a6/74/d12b2cd841d8724dc8ffb13fc5cef86566a53ed358103150209ecd5d1999/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e", size = 1997025 }, + { url = "https://files.pythonhosted.org/packages/a0/6e/940bcd631bc4d9a06c9539b51f070b66e8f370ed0933f392db6ff350d873/pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4", size = 2004633 }, + { url = "https://files.pythonhosted.org/packages/50/cc/a46b34f1708d82498c227d5d80ce615b2dd502ddcfd8376fc14a36655af1/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27", size = 1999404 }, + { url = "https://files.pythonhosted.org/packages/ca/2d/c365cfa930ed23bc58c41463bae347d1005537dc8db79e998af8ba28d35e/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee", size = 2130130 }, + { url = "https://files.pythonhosted.org/packages/f4/d7/eb64d015c350b7cdb371145b54d96c919d4db516817f31cd1c650cae3b21/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1", size = 2157946 }, + { url = "https://files.pythonhosted.org/packages/a4/99/bddde3ddde76c03b65dfd5a66ab436c4e58ffc42927d4ff1198ffbf96f5f/pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130", size = 1834387 }, + { url = "https://files.pythonhosted.org/packages/71/47/82b5e846e01b26ac6f1893d3c5f9f3a2eb6ba79be26eef0b759b4fe72946/pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee", size = 1990453 }, + { url = "https://files.pythonhosted.org/packages/51/b2/b2b50d5ecf21acf870190ae5d093602d95f66c9c31f9d5de6062eb329ad1/pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b", size = 1885186 }, +] + +[[package]] +name = "pygments" +version = "2.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, +] + +[[package]] +name = "pyopenssl" +version = "24.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/d4/1067b82c4fc674d6f6e9e8d26b3dff978da46d351ca3bac171544693e085/pyopenssl-24.3.0.tar.gz", hash = "sha256:49f7a019577d834746bc55c5fce6ecbcec0f2b4ec5ce1cf43a9a173b8138bb36", size = 178944 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/22/40f9162e943f86f0fc927ebc648078be87def360d9d8db346619fb97df2b/pyOpenSSL-24.3.0-py3-none-any.whl", hash = "sha256:e474f5a473cd7f92221cc04976e48f4d11502804657a08a989fb3be5514c904a", size = 56111 }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, +] + +[[package]] +name = "python-slugify" +version = "8.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "text-unidecode" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/87/c7/5e1547c44e31da50a460df93af11a535ace568ef89d7a811069ead340c4a/python-slugify-8.0.4.tar.gz", hash = "sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856", size = 10921 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/62/02da182e544a51a5c3ccf4b03ab79df279f9c60c5e82d5e8bec7ca26ac11/python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8", size = 10051 }, +] + +[[package]] +name = "pytz" +version = "2025.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/57/df1c9157c8d5a05117e455d66fd7cf6dbc46974f832b1058ed4856785d8a/pytz-2025.1.tar.gz", hash = "sha256:c2db42be2a2518b28e65f9207c4d05e6ff547d1efa4086469ef855e4ab70178e", size = 319617 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/38/ac33370d784287baa1c3d538978b5e2ea064d4c1b93ffbd12826c190dd10/pytz-2025.1-py2.py3-none-any.whl", hash = "sha256:89dd22dca55b46eac6eda23b2d72721bf1bdfef212645d81513ef5d03038de57", size = 507930 }, +] + +[[package]] +name = "pywin32" +version = "309" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/2c/b0240b14ff3dba7a8a7122dc9bbf7fbd21ed0e8b57c109633675b5d1761f/pywin32-309-cp312-cp312-win32.whl", hash = "sha256:de9acacced5fa82f557298b1fed5fef7bd49beee04190f68e1e4783fbdc19926", size = 8790648 }, + { url = "https://files.pythonhosted.org/packages/dd/11/c36884c732e2b3397deee808b5dac1abbb170ec37f94c6606fcb04d1e9d7/pywin32-309-cp312-cp312-win_amd64.whl", hash = "sha256:6ff9eebb77ffc3d59812c68db33c0a7817e1337e3537859499bd27586330fc9e", size = 9497399 }, + { url = "https://files.pythonhosted.org/packages/18/9f/79703972958f8ba3fd38bc9bf1165810bd75124982419b0cc433a2894d46/pywin32-309-cp312-cp312-win_arm64.whl", hash = "sha256:619f3e0a327b5418d833f44dc87859523635cf339f86071cc65a13c07be3110f", size = 8454122 }, + { url = "https://files.pythonhosted.org/packages/6c/c3/51aca6887cc5e410aa4cdc55662cf8438212440c67335c3f141b02eb8d52/pywin32-309-cp313-cp313-win32.whl", hash = "sha256:008bffd4afd6de8ca46c6486085414cc898263a21a63c7f860d54c9d02b45c8d", size = 8789700 }, + { url = "https://files.pythonhosted.org/packages/dd/66/330f265140fa814b4ed1bf16aea701f9d005f8f4ab57a54feb17f53afe7e/pywin32-309-cp313-cp313-win_amd64.whl", hash = "sha256:bd0724f58492db4cbfbeb1fcd606495205aa119370c0ddc4f70e5771a3ab768d", size = 9496714 }, + { url = "https://files.pythonhosted.org/packages/2c/84/9a51e6949a03f25cd329ece54dbf0846d57fadd2e79046c3b8d140aaa132/pywin32-309-cp313-cp313-win_arm64.whl", hash = "sha256:8fd9669cfd41863b688a1bc9b1d4d2d76fd4ba2128be50a70b0ea66b8d37953b", size = 8453052 }, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873 }, + { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302 }, + { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154 }, + { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223 }, + { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542 }, + { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164 }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611 }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591 }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338 }, + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309 }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679 }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428 }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361 }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523 }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660 }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597 }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527 }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 }, +] + +[[package]] +name = "referencing" +version = "0.36.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/b1/3baf80dc6d2b7bc27a95a67752d0208e410351e3feb4eb78de5f77454d8d/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0", size = 26775 }, +] + +[[package]] +name = "regex" +version = "2024.11.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/5f/bd69653fbfb76cf8604468d3b4ec4c403197144c7bfe0e6a5fc9e02a07cb/regex-2024.11.6.tar.gz", hash = "sha256:7ab159b063c52a0333c884e4679f8d7a85112ee3078fe3d9004b2dd875585519", size = 399494 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/30/9a87ce8336b172cc232a0db89a3af97929d06c11ceaa19d97d84fa90a8f8/regex-2024.11.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:52fb28f528778f184f870b7cf8f225f5eef0a8f6e3778529bdd40c7b3920796a", size = 483781 }, + { url = "https://files.pythonhosted.org/packages/01/e8/00008ad4ff4be8b1844786ba6636035f7ef926db5686e4c0f98093612add/regex-2024.11.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdd6028445d2460f33136c55eeb1f601ab06d74cb3347132e1c24250187500d9", size = 288455 }, + { url = "https://files.pythonhosted.org/packages/60/85/cebcc0aff603ea0a201667b203f13ba75d9fc8668fab917ac5b2de3967bc/regex-2024.11.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:805e6b60c54bf766b251e94526ebad60b7de0c70f70a4e6210ee2891acb70bf2", size = 284759 }, + { url = "https://files.pythonhosted.org/packages/94/2b/701a4b0585cb05472a4da28ee28fdfe155f3638f5e1ec92306d924e5faf0/regex-2024.11.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b85c2530be953a890eaffde05485238f07029600e8f098cdf1848d414a8b45e4", size = 794976 }, + { url = "https://files.pythonhosted.org/packages/4b/bf/fa87e563bf5fee75db8915f7352e1887b1249126a1be4813837f5dbec965/regex-2024.11.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bb26437975da7dc36b7efad18aa9dd4ea569d2357ae6b783bf1118dabd9ea577", size = 833077 }, + { url = "https://files.pythonhosted.org/packages/a1/56/7295e6bad94b047f4d0834e4779491b81216583c00c288252ef625c01d23/regex-2024.11.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:abfa5080c374a76a251ba60683242bc17eeb2c9818d0d30117b4486be10c59d3", size = 823160 }, + { url = "https://files.pythonhosted.org/packages/fb/13/e3b075031a738c9598c51cfbc4c7879e26729c53aa9cca59211c44235314/regex-2024.11.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b7fa6606c2881c1db9479b0eaa11ed5dfa11c8d60a474ff0e095099f39d98e", size = 796896 }, + { url = "https://files.pythonhosted.org/packages/24/56/0b3f1b66d592be6efec23a795b37732682520b47c53da5a32c33ed7d84e3/regex-2024.11.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0c32f75920cf99fe6b6c539c399a4a128452eaf1af27f39bce8909c9a3fd8cbe", size = 783997 }, + { url = "https://files.pythonhosted.org/packages/f9/a1/eb378dada8b91c0e4c5f08ffb56f25fcae47bf52ad18f9b2f33b83e6d498/regex-2024.11.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:982e6d21414e78e1f51cf595d7f321dcd14de1f2881c5dc6a6e23bbbbd68435e", size = 781725 }, + { url = "https://files.pythonhosted.org/packages/83/f2/033e7dec0cfd6dda93390089864732a3409246ffe8b042e9554afa9bff4e/regex-2024.11.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a7c2155f790e2fb448faed6dd241386719802296ec588a8b9051c1f5c481bc29", size = 789481 }, + { url = "https://files.pythonhosted.org/packages/83/23/15d4552ea28990a74e7696780c438aadd73a20318c47e527b47a4a5a596d/regex-2024.11.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:149f5008d286636e48cd0b1dd65018548944e495b0265b45e1bffecce1ef7f39", size = 852896 }, + { url = "https://files.pythonhosted.org/packages/e3/39/ed4416bc90deedbfdada2568b2cb0bc1fdb98efe11f5378d9892b2a88f8f/regex-2024.11.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e5364a4502efca094731680e80009632ad6624084aff9a23ce8c8c6820de3e51", size = 860138 }, + { url = "https://files.pythonhosted.org/packages/93/2d/dd56bb76bd8e95bbce684326302f287455b56242a4f9c61f1bc76e28360e/regex-2024.11.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0a86e7eeca091c09e021db8eb72d54751e527fa47b8d5787caf96d9831bd02ad", size = 787692 }, + { url = "https://files.pythonhosted.org/packages/0b/55/31877a249ab7a5156758246b9c59539abbeba22461b7d8adc9e8475ff73e/regex-2024.11.6-cp312-cp312-win32.whl", hash = "sha256:32f9a4c643baad4efa81d549c2aadefaeba12249b2adc5af541759237eee1c54", size = 262135 }, + { url = "https://files.pythonhosted.org/packages/38/ec/ad2d7de49a600cdb8dd78434a1aeffe28b9d6fc42eb36afab4a27ad23384/regex-2024.11.6-cp312-cp312-win_amd64.whl", hash = "sha256:a93c194e2df18f7d264092dc8539b8ffb86b45b899ab976aa15d48214138e81b", size = 273567 }, + { url = "https://files.pythonhosted.org/packages/90/73/bcb0e36614601016552fa9344544a3a2ae1809dc1401b100eab02e772e1f/regex-2024.11.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a6ba92c0bcdf96cbf43a12c717eae4bc98325ca3730f6b130ffa2e3c3c723d84", size = 483525 }, + { url = "https://files.pythonhosted.org/packages/0f/3f/f1a082a46b31e25291d830b369b6b0c5576a6f7fb89d3053a354c24b8a83/regex-2024.11.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:525eab0b789891ac3be914d36893bdf972d483fe66551f79d3e27146191a37d4", size = 288324 }, + { url = "https://files.pythonhosted.org/packages/09/c9/4e68181a4a652fb3ef5099e077faf4fd2a694ea6e0f806a7737aff9e758a/regex-2024.11.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:086a27a0b4ca227941700e0b31425e7a28ef1ae8e5e05a33826e17e47fbfdba0", size = 284617 }, + { url = "https://files.pythonhosted.org/packages/fc/fd/37868b75eaf63843165f1d2122ca6cb94bfc0271e4428cf58c0616786dce/regex-2024.11.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bde01f35767c4a7899b7eb6e823b125a64de314a8ee9791367c9a34d56af18d0", size = 795023 }, + { url = "https://files.pythonhosted.org/packages/c4/7c/d4cd9c528502a3dedb5c13c146e7a7a539a3853dc20209c8e75d9ba9d1b2/regex-2024.11.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b583904576650166b3d920d2bcce13971f6f9e9a396c673187f49811b2769dc7", size = 833072 }, + { url = "https://files.pythonhosted.org/packages/4f/db/46f563a08f969159c5a0f0e722260568425363bea43bb7ae370becb66a67/regex-2024.11.6-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c4de13f06a0d54fa0d5ab1b7138bfa0d883220965a29616e3ea61b35d5f5fc7", size = 823130 }, + { url = "https://files.pythonhosted.org/packages/db/60/1eeca2074f5b87df394fccaa432ae3fc06c9c9bfa97c5051aed70e6e00c2/regex-2024.11.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3cde6e9f2580eb1665965ce9bf17ff4952f34f5b126beb509fee8f4e994f143c", size = 796857 }, + { url = "https://files.pythonhosted.org/packages/10/db/ac718a08fcee981554d2f7bb8402f1faa7e868c1345c16ab1ebec54b0d7b/regex-2024.11.6-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0d7f453dca13f40a02b79636a339c5b62b670141e63efd511d3f8f73fba162b3", size = 784006 }, + { url = "https://files.pythonhosted.org/packages/c2/41/7da3fe70216cea93144bf12da2b87367590bcf07db97604edeea55dac9ad/regex-2024.11.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:59dfe1ed21aea057a65c6b586afd2a945de04fc7db3de0a6e3ed5397ad491b07", size = 781650 }, + { url = "https://files.pythonhosted.org/packages/a7/d5/880921ee4eec393a4752e6ab9f0fe28009435417c3102fc413f3fe81c4e5/regex-2024.11.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b97c1e0bd37c5cd7902e65f410779d39eeda155800b65fc4d04cc432efa9bc6e", size = 789545 }, + { url = "https://files.pythonhosted.org/packages/dc/96/53770115e507081122beca8899ab7f5ae28ae790bfcc82b5e38976df6a77/regex-2024.11.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f9d1e379028e0fc2ae3654bac3cbbef81bf3fd571272a42d56c24007979bafb6", size = 853045 }, + { url = "https://files.pythonhosted.org/packages/31/d3/1372add5251cc2d44b451bd94f43b2ec78e15a6e82bff6a290ef9fd8f00a/regex-2024.11.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:13291b39131e2d002a7940fb176e120bec5145f3aeb7621be6534e46251912c4", size = 860182 }, + { url = "https://files.pythonhosted.org/packages/ed/e3/c446a64984ea9f69982ba1a69d4658d5014bc7a0ea468a07e1a1265db6e2/regex-2024.11.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f51f88c126370dcec4908576c5a627220da6c09d0bff31cfa89f2523843316d", size = 787733 }, + { url = "https://files.pythonhosted.org/packages/2b/f1/e40c8373e3480e4f29f2692bd21b3e05f296d3afebc7e5dcf21b9756ca1c/regex-2024.11.6-cp313-cp313-win32.whl", hash = "sha256:63b13cfd72e9601125027202cad74995ab26921d8cd935c25f09c630436348ff", size = 262122 }, + { url = "https://files.pythonhosted.org/packages/45/94/bc295babb3062a731f52621cdc992d123111282e291abaf23faa413443ea/regex-2024.11.6-cp313-cp313-win_amd64.whl", hash = "sha256:2b3361af3198667e99927da8b84c1b010752fa4b1115ee30beaa332cabc3ef1a", size = 273545 }, +] + +[[package]] +name = "requests" +version = "2.32.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, +] + +[[package]] +name = "rich" +version = "13.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 }, +] + +[[package]] +name = "rpds-py" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/79/2ce611b18c4fd83d9e3aecb5cba93e1917c050f556db39842889fa69b79f/rpds_py-0.23.1.tar.gz", hash = "sha256:7f3240dcfa14d198dba24b8b9cb3b108c06b68d45b7babd9eefc1038fdf7e707", size = 26806 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/8c/d17efccb9f5b9137ddea706664aebae694384ae1d5997c0202093e37185a/rpds_py-0.23.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3902df19540e9af4cc0c3ae75974c65d2c156b9257e91f5101a51f99136d834c", size = 364369 }, + { url = "https://files.pythonhosted.org/packages/6e/c0/ab030f696b5c573107115a88d8d73d80f03309e60952b64c584c70c659af/rpds_py-0.23.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:66f8d2a17e5838dd6fb9be6baaba8e75ae2f5fa6b6b755d597184bfcd3cb0eba", size = 349965 }, + { url = "https://files.pythonhosted.org/packages/b3/55/b40170f5a079c4fb0b6a82b299689e66e744edca3c3375a8b160fb797660/rpds_py-0.23.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:112b8774b0b4ee22368fec42749b94366bd9b536f8f74c3d4175d4395f5cbd31", size = 389064 }, + { url = "https://files.pythonhosted.org/packages/ab/1c/b03a912c59ec7c1e16b26e587b9dfa8ddff3b07851e781e8c46e908a365a/rpds_py-0.23.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e0df046f2266e8586cf09d00588302a32923eb6386ced0ca5c9deade6af9a149", size = 397741 }, + { url = "https://files.pythonhosted.org/packages/52/6f/151b90792b62fb6f87099bcc9044c626881fdd54e31bf98541f830b15cea/rpds_py-0.23.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0f3288930b947cbebe767f84cf618d2cbe0b13be476e749da0e6a009f986248c", size = 448784 }, + { url = "https://files.pythonhosted.org/packages/71/2a/6de67c0c97ec7857e0e9e5cd7c52405af931b303eb1e5b9eff6c50fd9a2e/rpds_py-0.23.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce473a2351c018b06dd8d30d5da8ab5a0831056cc53b2006e2a8028172c37ce5", size = 440203 }, + { url = "https://files.pythonhosted.org/packages/db/5e/e759cd1c276d98a4b1f464b17a9bf66c65d29f8f85754e27e1467feaa7c3/rpds_py-0.23.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d550d7e9e7d8676b183b37d65b5cd8de13676a738973d330b59dc8312df9c5dc", size = 391611 }, + { url = "https://files.pythonhosted.org/packages/1c/1e/2900358efcc0d9408c7289769cba4c0974d9db314aa884028ed7f7364f61/rpds_py-0.23.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e14f86b871ea74c3fddc9a40e947d6a5d09def5adc2076ee61fb910a9014fb35", size = 423306 }, + { url = "https://files.pythonhosted.org/packages/23/07/6c177e6d059f5d39689352d6c69a926ee4805ffdb6f06203570234d3d8f7/rpds_py-0.23.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1bf5be5ba34e19be579ae873da515a2836a2166d8d7ee43be6ff909eda42b72b", size = 562323 }, + { url = "https://files.pythonhosted.org/packages/70/e4/f9097fd1c02b516fff9850792161eb9fc20a2fd54762f3c69eae0bdb67cb/rpds_py-0.23.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d7031d493c4465dbc8d40bd6cafefef4bd472b17db0ab94c53e7909ee781b9ef", size = 588351 }, + { url = "https://files.pythonhosted.org/packages/87/39/5db3c6f326bfbe4576ae2af6435bd7555867d20ae690c786ff33659f293b/rpds_py-0.23.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:55ff4151cfd4bc635e51cfb1c59ac9f7196b256b12e3a57deb9e5742e65941ad", size = 557252 }, + { url = "https://files.pythonhosted.org/packages/fd/14/2d5ad292f144fa79bafb78d2eb5b8a3a91c358b6065443cb9c49b5d1fedf/rpds_py-0.23.1-cp312-cp312-win32.whl", hash = "sha256:a9d3b728f5a5873d84cba997b9d617c6090ca5721caaa691f3b1a78c60adc057", size = 222181 }, + { url = "https://files.pythonhosted.org/packages/a3/4f/0fce63e0f5cdd658e71e21abd17ac1bc9312741ebb8b3f74eeed2ebdf771/rpds_py-0.23.1-cp312-cp312-win_amd64.whl", hash = "sha256:b03a8d50b137ee758e4c73638b10747b7c39988eb8e6cd11abb7084266455165", size = 237426 }, + { url = "https://files.pythonhosted.org/packages/13/9d/b8b2c0edffb0bed15be17b6d5ab06216f2f47f9ee49259c7e96a3ad4ca42/rpds_py-0.23.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:4caafd1a22e5eaa3732acb7672a497123354bef79a9d7ceed43387d25025e935", size = 363672 }, + { url = "https://files.pythonhosted.org/packages/bd/c2/5056fa29e6894144d7ba4c938b9b0445f75836b87d2dd00ed4999dc45a8c/rpds_py-0.23.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:178f8a60fc24511c0eb756af741c476b87b610dba83270fce1e5a430204566a4", size = 349602 }, + { url = "https://files.pythonhosted.org/packages/b0/bc/33779a1bb0ee32d8d706b173825aab75c628521d23ce72a7c1e6a6852f86/rpds_py-0.23.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c632419c3870507ca20a37c8f8f5352317aca097639e524ad129f58c125c61c6", size = 388746 }, + { url = "https://files.pythonhosted.org/packages/62/0b/71db3e36b7780a619698ec82a9c87ab44ad7ca7f5480913e8a59ff76f050/rpds_py-0.23.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:698a79d295626ee292d1730bc2ef6e70a3ab135b1d79ada8fde3ed0047b65a10", size = 397076 }, + { url = "https://files.pythonhosted.org/packages/bb/2e/494398f613edf77ba10a916b1ddea2acce42ab0e3b62e2c70ffc0757ce00/rpds_py-0.23.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:271fa2184cf28bdded86bb6217c8e08d3a169fe0bbe9be5e8d96e8476b707122", size = 448399 }, + { url = "https://files.pythonhosted.org/packages/dd/53/4bd7f5779b1f463243ee5fdc83da04dd58a08f86e639dbffa7a35f969a84/rpds_py-0.23.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b91cceb5add79ee563bd1f70b30896bd63bc5f78a11c1f00a1e931729ca4f1f4", size = 439764 }, + { url = "https://files.pythonhosted.org/packages/f6/55/b3c18c04a460d951bf8e91f2abf46ce5b6426fb69784166a6a25827cb90a/rpds_py-0.23.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3a6cb95074777f1ecda2ca4fa7717caa9ee6e534f42b7575a8f0d4cb0c24013", size = 390662 }, + { url = "https://files.pythonhosted.org/packages/2a/65/cc463044a3cbd616029b2aa87a651cdee8288d2fdd7780b2244845e934c1/rpds_py-0.23.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:50fb62f8d8364978478b12d5f03bf028c6bc2af04082479299139dc26edf4c64", size = 422680 }, + { url = "https://files.pythonhosted.org/packages/fa/8e/1fa52990c7836d72e8d70cd7753f2362c72fbb0a49c1462e8c60e7176d0b/rpds_py-0.23.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c8f7e90b948dc9dcfff8003f1ea3af08b29c062f681c05fd798e36daa3f7e3e8", size = 561792 }, + { url = "https://files.pythonhosted.org/packages/57/b8/fe3b612979b1a29d0c77f8585903d8b3a292604b26d4b300e228b8ac6360/rpds_py-0.23.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5b98b6c953e5c2bda51ab4d5b4f172617d462eebc7f4bfdc7c7e6b423f6da957", size = 588127 }, + { url = "https://files.pythonhosted.org/packages/44/2d/fde474de516bbc4b9b230f43c98e7f8acc5da7fc50ceed8e7af27553d346/rpds_py-0.23.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2893d778d4671ee627bac4037a075168b2673c57186fb1a57e993465dbd79a93", size = 556981 }, + { url = "https://files.pythonhosted.org/packages/18/57/767deeb27b81370bbab8f74ef6e68d26c4ea99018f3c71a570e506fede85/rpds_py-0.23.1-cp313-cp313-win32.whl", hash = "sha256:2cfa07c346a7ad07019c33fb9a63cf3acb1f5363c33bc73014e20d9fe8b01cdd", size = 221936 }, + { url = "https://files.pythonhosted.org/packages/7d/6c/3474cfdd3cafe243f97ab8474ea8949236eb2a1a341ca55e75ce00cd03da/rpds_py-0.23.1-cp313-cp313-win_amd64.whl", hash = "sha256:3aaf141d39f45322e44fc2c742e4b8b4098ead5317e5f884770c8df0c332da70", size = 237145 }, + { url = "https://files.pythonhosted.org/packages/ec/77/e985064c624230f61efa0423759bb066da56ebe40c654f8b5ba225bd5d63/rpds_py-0.23.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:759462b2d0aa5a04be5b3e37fb8183615f47014ae6b116e17036b131985cb731", size = 359623 }, + { url = "https://files.pythonhosted.org/packages/62/d9/a33dcbf62b29e40559e012d525bae7d516757cf042cc9234bd34ca4b6aeb/rpds_py-0.23.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3e9212f52074fc9d72cf242a84063787ab8e21e0950d4d6709886fb62bcb91d5", size = 345900 }, + { url = "https://files.pythonhosted.org/packages/92/eb/f81a4be6397861adb2cb868bb6a28a33292c2dcac567d1dc575226055e55/rpds_py-0.23.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e9f3a3ac919406bc0414bbbd76c6af99253c507150191ea79fab42fdb35982a", size = 386426 }, + { url = "https://files.pythonhosted.org/packages/09/47/1f810c9b5e83be005341201b5389f1d240dfa440346ea7189f9b3fd6961d/rpds_py-0.23.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c04ca91dda8a61584165825907f5c967ca09e9c65fe8966ee753a3f2b019fe1e", size = 392314 }, + { url = "https://files.pythonhosted.org/packages/83/bd/bc95831432fd6c46ed8001f01af26de0763a059d6d7e6d69e3c5bf02917a/rpds_py-0.23.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4ab923167cfd945abb9b51a407407cf19f5bee35001221f2911dc85ffd35ff4f", size = 447706 }, + { url = "https://files.pythonhosted.org/packages/19/3e/567c04c226b1802dc6dc82cad3d53e1fa0a773258571c74ac5d8fbde97ed/rpds_py-0.23.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed6f011bedca8585787e5082cce081bac3d30f54520097b2411351b3574e1219", size = 437060 }, + { url = "https://files.pythonhosted.org/packages/fe/77/a77d2c6afe27ae7d0d55fc32f6841502648070dc8d549fcc1e6d47ff8975/rpds_py-0.23.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6959bb9928c5c999aba4a3f5a6799d571ddc2c59ff49917ecf55be2bbb4e3722", size = 389347 }, + { url = "https://files.pythonhosted.org/packages/3f/47/6b256ff20a74cfebeac790ab05586e0ac91f88e331125d4740a6c86fc26f/rpds_py-0.23.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1ed7de3c86721b4e83ac440751329ec6a1102229aa18163f84c75b06b525ad7e", size = 415554 }, + { url = "https://files.pythonhosted.org/packages/fc/29/d4572469a245bc9fc81e35166dca19fc5298d5c43e1a6dd64bf145045193/rpds_py-0.23.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5fb89edee2fa237584e532fbf78f0ddd1e49a47c7c8cfa153ab4849dc72a35e6", size = 557418 }, + { url = "https://files.pythonhosted.org/packages/9c/0a/68cf7228895b1a3f6f39f51b15830e62456795e61193d2c8b87fd48c60db/rpds_py-0.23.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7e5413d2e2d86025e73f05510ad23dad5950ab8417b7fc6beaad99be8077138b", size = 583033 }, + { url = "https://files.pythonhosted.org/packages/14/18/017ab41dcd6649ad5db7d00155b4c212b31ab05bd857d5ba73a1617984eb/rpds_py-0.23.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d31ed4987d72aabdf521eddfb6a72988703c091cfc0064330b9e5f8d6a042ff5", size = 554880 }, + { url = "https://files.pythonhosted.org/packages/2e/dd/17de89431268da8819d8d51ce67beac28d9b22fccf437bc5d6d2bcd1acdb/rpds_py-0.23.1-cp313-cp313t-win32.whl", hash = "sha256:f3429fb8e15b20961efca8c8b21432623d85db2228cc73fe22756c6637aa39e7", size = 219743 }, + { url = "https://files.pythonhosted.org/packages/68/15/6d22d07e063ce5e9bfbd96db9ec2fbb4693591b4503e3a76996639474d02/rpds_py-0.23.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d6f6512a90bd5cd9030a6237f5346f046c6f0e40af98657568fa45695d4de59d", size = 235415 }, +] + +[[package]] +name = "ruamel-yaml" +version = "0.18.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ruamel-yaml-clib", marker = "python_full_version < '3.13' and platform_python_implementation == 'CPython'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ea/46/f44d8be06b85bc7c4d8c95d658be2b68f27711f279bf9dd0612a5e4794f5/ruamel.yaml-0.18.10.tar.gz", hash = "sha256:20c86ab29ac2153f80a428e1254a8adf686d3383df04490514ca3b79a362db58", size = 143447 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/36/dfc1ebc0081e6d39924a2cc53654497f967a084a436bb64402dfce4254d9/ruamel.yaml-0.18.10-py3-none-any.whl", hash = "sha256:30f22513ab2301b3d2b577adc121c6471f28734d3d9728581245f1e76468b4f1", size = 117729 }, +] + +[[package]] +name = "ruamel-yaml-clib" +version = "0.2.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/84/80203abff8ea4993a87d823a5f632e4d92831ef75d404c9fc78d0176d2b5/ruamel.yaml.clib-0.2.12.tar.gz", hash = "sha256:6c8fbb13ec503f99a91901ab46e0b07ae7941cd527393187039aec586fdfd36f", size = 225315 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/41/e7a405afbdc26af961678474a55373e1b323605a4f5e2ddd4a80ea80f628/ruamel.yaml.clib-0.2.12-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:20b0f8dc160ba83b6dcc0e256846e1a02d044e13f7ea74a3d1d56ede4e48c632", size = 133433 }, + { url = "https://files.pythonhosted.org/packages/ec/b0/b850385604334c2ce90e3ee1013bd911aedf058a934905863a6ea95e9eb4/ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:943f32bc9dedb3abff9879edc134901df92cfce2c3d5c9348f172f62eb2d771d", size = 647362 }, + { url = "https://files.pythonhosted.org/packages/44/d0/3f68a86e006448fb6c005aee66565b9eb89014a70c491d70c08de597f8e4/ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95c3829bb364fdb8e0332c9931ecf57d9be3519241323c5274bd82f709cebc0c", size = 754118 }, + { url = "https://files.pythonhosted.org/packages/52/a9/d39f3c5ada0a3bb2870d7db41901125dbe2434fa4f12ca8c5b83a42d7c53/ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:749c16fcc4a2b09f28843cda5a193e0283e47454b63ec4b81eaa2242f50e4ccd", size = 706497 }, + { url = "https://files.pythonhosted.org/packages/b0/fa/097e38135dadd9ac25aecf2a54be17ddf6e4c23e43d538492a90ab3d71c6/ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bf165fef1f223beae7333275156ab2022cffe255dcc51c27f066b4370da81e31", size = 698042 }, + { url = "https://files.pythonhosted.org/packages/ec/d5/a659ca6f503b9379b930f13bc6b130c9f176469b73b9834296822a83a132/ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:32621c177bbf782ca5a18ba4d7af0f1082a3f6e517ac2a18b3974d4edf349680", size = 745831 }, + { url = "https://files.pythonhosted.org/packages/db/5d/36619b61ffa2429eeaefaab4f3374666adf36ad8ac6330d855848d7d36fd/ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b82a7c94a498853aa0b272fd5bc67f29008da798d4f93a2f9f289feb8426a58d", size = 715692 }, + { url = "https://files.pythonhosted.org/packages/b1/82/85cb92f15a4231c89b95dfe08b09eb6adca929ef7df7e17ab59902b6f589/ruamel.yaml.clib-0.2.12-cp312-cp312-win32.whl", hash = "sha256:e8c4ebfcfd57177b572e2040777b8abc537cdef58a2120e830124946aa9b42c5", size = 98777 }, + { url = "https://files.pythonhosted.org/packages/d7/8f/c3654f6f1ddb75daf3922c3d8fc6005b1ab56671ad56ffb874d908bfa668/ruamel.yaml.clib-0.2.12-cp312-cp312-win_amd64.whl", hash = "sha256:0467c5965282c62203273b838ae77c0d29d7638c8a4e3a1c8bdd3602c10904e4", size = 115523 }, + { url = "https://files.pythonhosted.org/packages/29/00/4864119668d71a5fa45678f380b5923ff410701565821925c69780356ffa/ruamel.yaml.clib-0.2.12-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4c8c5d82f50bb53986a5e02d1b3092b03622c02c2eb78e29bec33fd9593bae1a", size = 132011 }, + { url = "https://files.pythonhosted.org/packages/7f/5e/212f473a93ae78c669ffa0cb051e3fee1139cb2d385d2ae1653d64281507/ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:e7e3736715fbf53e9be2a79eb4db68e4ed857017344d697e8b9749444ae57475", size = 642488 }, + { url = "https://files.pythonhosted.org/packages/1f/8f/ecfbe2123ade605c49ef769788f79c38ddb1c8fa81e01f4dbf5cf1a44b16/ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b7e75b4965e1d4690e93021adfcecccbca7d61c7bddd8e22406ef2ff20d74ef", size = 745066 }, + { url = "https://files.pythonhosted.org/packages/e2/a9/28f60726d29dfc01b8decdb385de4ced2ced9faeb37a847bd5cf26836815/ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96777d473c05ee3e5e3c3e999f5d23c6f4ec5b0c38c098b3a5229085f74236c6", size = 701785 }, + { url = "https://files.pythonhosted.org/packages/84/7e/8e7ec45920daa7f76046578e4f677a3215fe8f18ee30a9cb7627a19d9b4c/ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:3bc2a80e6420ca8b7d3590791e2dfc709c88ab9152c00eeb511c9875ce5778bf", size = 693017 }, + { url = "https://files.pythonhosted.org/packages/c5/b3/d650eaade4ca225f02a648321e1ab835b9d361c60d51150bac49063b83fa/ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e188d2699864c11c36cdfdada94d781fd5d6b0071cd9c427bceb08ad3d7c70e1", size = 741270 }, + { url = "https://files.pythonhosted.org/packages/87/b8/01c29b924dcbbed75cc45b30c30d565d763b9c4d540545a0eeecffb8f09c/ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f6f3eac23941b32afccc23081e1f50612bdbe4e982012ef4f5797986828cd01", size = 709059 }, + { url = "https://files.pythonhosted.org/packages/30/8c/ed73f047a73638257aa9377ad356bea4d96125b305c34a28766f4445cc0f/ruamel.yaml.clib-0.2.12-cp313-cp313-win32.whl", hash = "sha256:6442cb36270b3afb1b4951f060eccca1ce49f3d087ca1ca4563a6eb479cb3de6", size = 98583 }, + { url = "https://files.pythonhosted.org/packages/b0/85/e8e751d8791564dd333d5d9a4eab0a7a115f7e349595417fd50ecae3395c/ruamel.yaml.clib-0.2.12-cp313-cp313-win_amd64.whl", hash = "sha256:e5b8daf27af0b90da7bb903a876477a9e6d7270be6146906b276605997c7e9a3", size = 115190 }, +] + +[[package]] +name = "s3transfer" +version = "0.11.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/ec/aa1a215e5c126fe5decbee2e107468f51d9ce190b9763cb649f76bb45938/s3transfer-0.11.4.tar.gz", hash = "sha256:559f161658e1cf0a911f45940552c696735f5c74e64362e515f333ebed87d679", size = 148419 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/62/8d3fc3ec6640161a5649b2cddbbf2b9fa39c92541225b33f117c37c5a2eb/s3transfer-0.11.4-py3-none-any.whl", hash = "sha256:ac265fa68318763a03bf2dc4f39d5cbd6a9e178d81cc9483ad27da33637e320d", size = 84412 }, +] + +[[package]] +name = "setuptools" +version = "76.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/32/d2/7b171caf085ba0d40d8391f54e1c75a1cda9255f542becf84575cfd8a732/setuptools-76.0.0.tar.gz", hash = "sha256:43b4ee60e10b0d0ee98ad11918e114c70701bc6051662a9a675a0496c1a158f4", size = 1349387 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/66/d2d7e6ad554f3a7c7297c3f8ef6e22643ad3d35ef5c63bf488bc89f32f31/setuptools-76.0.0-py3-none-any.whl", hash = "sha256:199466a166ff664970d0ee145839f5582cb9bca7a0a3a2e795b6a9cb2308e9c6", size = 1236106 }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, +] + +[[package]] +name = "sympy" +version = "1.13.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/11/8a/5a7fd6284fa8caac23a26c9ddf9c30485a48169344b4bd3b0f02fef1890f/sympy-1.13.3.tar.gz", hash = "sha256:b27fd2c6530e0ab39e275fc9b683895367e51d5da91baa8d3d64db2565fec4d9", size = 7533196 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/ff/c87e0622b1dadea79d2fb0b25ade9ed98954c9033722eb707053d310d4f3/sympy-1.13.3-py3-none-any.whl", hash = "sha256:54612cf55a62755ee71824ce692986f23c88ffa77207b30c1368eda4a7060f73", size = 6189483 }, +] + +[[package]] +name = "test-lambda-locally" +version = "0" +source = { virtual = "." } +dependencies = [ + { name = "aws-sam-cli" }, +] + +[package.metadata] +requires-dist = [{ name = "aws-sam-cli", specifier = ">=1.135.0" }] + +[[package]] +name = "text-unidecode" +version = "1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ab/e2/e9a00f0ccb71718418230718b3d900e71a5d16e701a3dae079a21e9cd8f8/text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93", size = 76885 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", size = 78154 }, +] + +[[package]] +name = "tomlkit" +version = "0.13.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/09/a439bec5888f00a54b8b9f05fa94d7f901d6735ef4e55dcec9bc37b5d8fa/tomlkit-0.13.2.tar.gz", hash = "sha256:fff5fe59a87295b278abd31bec92c15d9bc4a06885ab12bcea52c71119392e79", size = 192885 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/b6/a447b5e4ec71e13871be01ba81f5dfc9d0af7e473da256ff46bc0e24026f/tomlkit-0.13.2-py3-none-any.whl", hash = "sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde", size = 37955 }, +] + +[[package]] +name = "types-awscrt" +version = "0.24.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/88/6e/32779b967eee6ef627eaf10f3414163482b3980fc45ba21765fdd05359d4/types_awscrt-0.24.1.tar.gz", hash = "sha256:fc6eae56f8dc5a3f8cc93cc2c7c332fa82909f8284fbe25e014c575757af397d", size = 15450 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/1a/22e327d29fe231a10ed00e35ed2a100d2462cea253c3d24d41162769711a/types_awscrt-0.24.1-py3-none-any.whl", hash = "sha256:f3f2578ff74a254a79882b95961fb493ba217cebc350b3eb239d1cd948d4d7fa", size = 19414 }, +] + +[[package]] +name = "types-python-dateutil" +version = "2.9.0.20241206" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/60/47d92293d9bc521cd2301e423a358abfac0ad409b3a1606d8fbae1321961/types_python_dateutil-2.9.0.20241206.tar.gz", hash = "sha256:18f493414c26ffba692a72369fea7a154c502646301ebfe3d56a04b3767284cb", size = 13802 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/b3/ca41df24db5eb99b00d97f89d7674a90cb6b3134c52fb8121b6d8d30f15c/types_python_dateutil-2.9.0.20241206-py3-none-any.whl", hash = "sha256:e248a4bc70a486d3e3ec84d0dc30eec3a5f979d6e7ee4123ae043eedbb987f53", size = 14384 }, +] + +[[package]] +name = "types-s3transfer" +version = "0.11.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/93/a9/440d8ba72a81bcf2cc5a56ef63f23b58ce93e7b9b62409697553bdcdd181/types_s3transfer-0.11.4.tar.gz", hash = "sha256:05fde593c84270f19fd053f0b1e08f5a057d7c5f036b9884e68fb8cd3041ac30", size = 14074 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/69/0b5ae42c3c33d31a32f7dcb9f35a3e327365360a6e4a2a7b491904bd38aa/types_s3transfer-0.11.4-py3-none-any.whl", hash = "sha256:2a76d92c07d4a3cb469e5343b2e7560e0b8078b2e03696a65407b8c44c861b61", size = 19516 }, +] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, +] + +[[package]] +name = "tzdata" +version = "2025.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/0f/fa4723f22942480be4ca9527bbde8d43f6c3f2fe8412f00e7f5f6746bc8b/tzdata-2025.1.tar.gz", hash = "sha256:24894909e88cdb28bd1636c6887801df64cb485bd593f2fd83ef29075a81d694", size = 194950 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/dd/84f10e23edd882c6f968c21c2434fe67bd4a528967067515feca9e611e5e/tzdata-2025.1-py2.py3-none-any.whl", hash = "sha256:7e127113816800496f027041c570f50bcd464a020098a3b6b199517772303639", size = 346762 }, +] + +[[package]] +name = "tzlocal" +version = "5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/d3/c19d65ae67636fe63953b20c2e4a8ced4497ea232c43ff8d01db16de8dc0/tzlocal-5.2.tar.gz", hash = "sha256:8d399205578f1a9342816409cc1e46a93ebd5755e39ea2d85334bea911bf0e6e", size = 30201 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/3f/c4c51c55ff8487f2e6d0e618dba917e3c3ee2caae6cf0fbb59c9b1876f2e/tzlocal-5.2-py3-none-any.whl", hash = "sha256:49816ef2fe65ea8ac19d19aa7a1ae0551c834303d5014c6d5a62e4cbda8047b8", size = 17859 }, +] + +[[package]] +name = "urllib3" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997973f966045cb019c3f4b66/urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d", size = 307268 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369 }, +] + +[[package]] +name = "watchdog" +version = "4.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/38/764baaa25eb5e35c9a043d4c4588f9836edfe52a708950f4b6d5f714fd42/watchdog-4.0.2.tar.gz", hash = "sha256:b4dfbb6c49221be4535623ea4474a4d6ee0a9cef4a80b20c28db4d858b64e270", size = 126587 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/f5/ea22b095340545faea37ad9a42353b265ca751f543da3fb43f5d00cdcd21/watchdog-4.0.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:1cdcfd8142f604630deef34722d695fb455d04ab7cfe9963055df1fc69e6727a", size = 100342 }, + { url = "https://files.pythonhosted.org/packages/cb/d2/8ce97dff5e465db1222951434e3115189ae54a9863aef99c6987890cc9ef/watchdog-4.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d7ab624ff2f663f98cd03c8b7eedc09375a911794dfea6bf2a359fcc266bff29", size = 92306 }, + { url = "https://files.pythonhosted.org/packages/49/c4/1aeba2c31b25f79b03b15918155bc8c0b08101054fc727900f1a577d0d54/watchdog-4.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:132937547a716027bd5714383dfc40dc66c26769f1ce8a72a859d6a48f371f3a", size = 92915 }, + { url = "https://files.pythonhosted.org/packages/79/63/eb8994a182672c042d85a33507475c50c2ee930577524dd97aea05251527/watchdog-4.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:cd67c7df93eb58f360c43802acc945fa8da70c675b6fa37a241e17ca698ca49b", size = 100343 }, + { url = "https://files.pythonhosted.org/packages/ce/82/027c0c65c2245769580605bcd20a1dc7dfd6c6683c8c4e2ef43920e38d27/watchdog-4.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:bcfd02377be80ef3b6bc4ce481ef3959640458d6feaae0bd43dd90a43da90a7d", size = 92313 }, + { url = "https://files.pythonhosted.org/packages/2a/89/ad4715cbbd3440cb0d336b78970aba243a33a24b1a79d66f8d16b4590d6a/watchdog-4.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:980b71510f59c884d684b3663d46e7a14b457c9611c481e5cef08f4dd022eed7", size = 92919 }, + { url = "https://files.pythonhosted.org/packages/8a/b1/25acf6767af6f7e44e0086309825bd8c098e301eed5868dc5350642124b9/watchdog-4.0.2-py3-none-manylinux2014_aarch64.whl", hash = "sha256:936acba76d636f70db8f3c66e76aa6cb5136a936fc2a5088b9ce1c7a3508fc83", size = 82947 }, + { url = "https://files.pythonhosted.org/packages/e8/90/aebac95d6f954bd4901f5d46dcd83d68e682bfd21798fd125a95ae1c9dbf/watchdog-4.0.2-py3-none-manylinux2014_armv7l.whl", hash = "sha256:e252f8ca942a870f38cf785aef420285431311652d871409a64e2a0a52a2174c", size = 82942 }, + { url = "https://files.pythonhosted.org/packages/15/3a/a4bd8f3b9381824995787488b9282aff1ed4667e1110f31a87b871ea851c/watchdog-4.0.2-py3-none-manylinux2014_i686.whl", hash = "sha256:0e83619a2d5d436a7e58a1aea957a3c1ccbf9782c43c0b4fed80580e5e4acd1a", size = 82947 }, + { url = "https://files.pythonhosted.org/packages/09/cc/238998fc08e292a4a18a852ed8274159019ee7a66be14441325bcd811dfd/watchdog-4.0.2-py3-none-manylinux2014_ppc64.whl", hash = "sha256:88456d65f207b39f1981bf772e473799fcdc10801062c36fd5ad9f9d1d463a73", size = 82946 }, + { url = "https://files.pythonhosted.org/packages/80/f1/d4b915160c9d677174aa5fae4537ae1f5acb23b3745ab0873071ef671f0a/watchdog-4.0.2-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:32be97f3b75693a93c683787a87a0dc8db98bb84701539954eef991fb35f5fbc", size = 82947 }, + { url = "https://files.pythonhosted.org/packages/db/02/56ebe2cf33b352fe3309588eb03f020d4d1c061563d9858a9216ba004259/watchdog-4.0.2-py3-none-manylinux2014_s390x.whl", hash = "sha256:c82253cfc9be68e3e49282831afad2c1f6593af80c0daf1287f6a92657986757", size = 82944 }, + { url = "https://files.pythonhosted.org/packages/01/d2/c8931ff840a7e5bd5dcb93f2bb2a1fd18faf8312e9f7f53ff1cf76ecc8ed/watchdog-4.0.2-py3-none-manylinux2014_x86_64.whl", hash = "sha256:c0b14488bd336c5b1845cee83d3e631a1f8b4e9c5091ec539406e4a324f882d8", size = 82947 }, + { url = "https://files.pythonhosted.org/packages/d0/d8/cdb0c21a4a988669d7c210c75c6a2c9a0e16a3b08d9f7e633df0d9a16ad8/watchdog-4.0.2-py3-none-win32.whl", hash = "sha256:0d8a7e523ef03757a5aa29f591437d64d0d894635f8a50f370fe37f913ce4e19", size = 82935 }, + { url = "https://files.pythonhosted.org/packages/99/2e/b69dfaae7a83ea64ce36538cc103a3065e12c447963797793d5c0a1d5130/watchdog-4.0.2-py3-none-win_amd64.whl", hash = "sha256:c344453ef3bf875a535b0488e3ad28e341adbd5a9ffb0f7d62cefacc8824ef2b", size = 82934 }, + { url = "https://files.pythonhosted.org/packages/b0/0b/43b96a9ecdd65ff5545b1b13b687ca486da5c6249475b1a45f24d63a1858/watchdog-4.0.2-py3-none-win_ia64.whl", hash = "sha256:baececaa8edff42cd16558a639a9b0ddf425f93d892e8392a56bf904f5eff22c", size = 82933 }, +] + +[[package]] +name = "werkzeug" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/69/83029f1f6300c5fb2471d621ab06f6ec6b3324685a2ce0f9777fd4a8b71e/werkzeug-3.1.3.tar.gz", hash = "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746", size = 806925 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/24/ab44c871b0f07f491e5d2ad12c9bd7358e527510618cb1b803a88e986db1/werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e", size = 224498 }, +] + +[[package]] +name = "wheel" +version = "0.45.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/98/2d9906746cdc6a6ef809ae6338005b3f21bb568bea3165cfc6a243fdc25c/wheel-0.45.1.tar.gz", hash = "sha256:661e1abd9198507b1409a20c02106d9670b2576e916d58f520316666abca6729", size = 107545 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/2c/87f3254fd8ffd29e4c02732eee68a83a1d3c346ae39bc6822dcbcb697f2b/wheel-0.45.1-py3-none-any.whl", hash = "sha256:708e7481cc80179af0e556bbf0cc00b8444c7321e2700b8d8580231d13017248", size = 72494 }, +] diff --git a/scripts/update_integration_support.py b/scripts/update_integration_support.py new file mode 100644 index 0000000000..7e686b2729 --- /dev/null +++ b/scripts/update_integration_support.py @@ -0,0 +1,55 @@ +""" +Small utility to determine the actual minimum supported version of each framework/library. +""" + +import os +import sys +from textwrap import dedent + +populate_tox_dir = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "populate_tox" +) +sys.path.append(populate_tox_dir) + +from populate_tox import main + + +def update(): + print("Running populate_tox.py...") + packages = main() + + print("Figuring out the lowest supported version of integrations...") + min_versions = [] + + for _, integrations in packages.items(): + for integration in integrations: + min_versions.append( + (integration["integration_name"], str(integration["releases"][0])) + ) + + min_versions = sorted( + set( + [ + (integration, tuple([int(v) for v in min_version.split(".")])) + for integration, min_version in min_versions + ] + ) + ) + + print() + print("Effective minimal versions:") + print( + dedent(""" + - The format is the same as _MIN_VERSIONS in sentry_sdk/integrations/__init__.py for easy replacing. + - When updating these, make sure to also update: + - The docs page for the integration + - The lower bounds in extras_require in setup.py + """) + ) + print() + for integration, min_version in min_versions: + print(f'"{integration}": {min_version},') + + +if __name__ == "__main__": + update() diff --git a/sentry_sdk/__init__.py b/sentry_sdk/__init__.py index 562da90739..e149418c38 100644 --- a/sentry_sdk/__init__.py +++ b/sentry_sdk/__init__.py @@ -1,14 +1,11 @@ -from sentry_sdk.hub import Hub, init +from sentry_sdk import profiler +from sentry_sdk import metrics from sentry_sdk.scope import Scope from sentry_sdk.transport import Transport, HttpTransport from sentry_sdk.client import Client from sentry_sdk.api import * # noqa - -from sentry_sdk.consts import VERSION # noqa - -from sentry_sdk.crons import monitor # noqa -from sentry_sdk.tracing import trace # noqa +from sentry_sdk.consts import VERSION __all__ = [ # noqa "Hub", @@ -16,30 +13,48 @@ "Client", "Transport", "HttpTransport", - "init", + "VERSION", "integrations", # From sentry_sdk.api + "init", + "add_attachment", + "add_breadcrumb", "capture_event", - "capture_message", "capture_exception", - "add_breadcrumb", + "capture_message", "configure_scope", - "push_scope", + "continue_trace", "flush", + "get_baggage", + "get_client", + "get_global_scope", + "get_isolation_scope", + "get_current_scope", + "get_current_span", + "get_traceparent", + "is_initialized", + "isolation_scope", "last_event_id", - "start_span", - "start_transaction", - "set_tag", + "new_scope", + "push_scope", "set_context", "set_extra", - "set_user", "set_level", "set_measurement", - "get_current_span", - "get_traceparent", - "get_baggage", - "continue_trace", + "set_tag", + "set_tags", + "set_user", + "start_span", + "start_transaction", "trace", + "monitor", + "logger", + "metrics", + "profiler", + "start_session", + "end_session", + "set_transaction_name", + "update_current_span", ] # Initialize the debug support after everything is loaded @@ -47,3 +62,6 @@ init_debug_support() del init_debug_support + +# circular imports +from sentry_sdk.hub import Hub diff --git a/sentry_sdk/_compat.py b/sentry_sdk/_compat.py index b88c648b01..dcb590fcfa 100644 --- a/sentry_sdk/_compat.py +++ b/sentry_sdk/_compat.py @@ -1,156 +1,94 @@ import sys -import contextlib -from datetime import datetime -from functools import wraps -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: - from typing import Optional - from typing import Tuple from typing import Any - from typing import Type from typing import TypeVar - from typing import Callable T = TypeVar("T") -PY2 = sys.version_info[0] == 2 -PY33 = sys.version_info[0] == 3 and sys.version_info[1] >= 3 PY37 = sys.version_info[0] == 3 and sys.version_info[1] >= 7 +PY38 = sys.version_info[0] == 3 and sys.version_info[1] >= 8 PY310 = sys.version_info[0] == 3 and sys.version_info[1] >= 10 PY311 = sys.version_info[0] == 3 and sys.version_info[1] >= 11 -if PY2: - import urlparse - - text_type = unicode # noqa - - string_types = (str, text_type) - number_types = (int, long, float) # noqa - int_types = (int, long) # noqa - iteritems = lambda x: x.iteritems() # noqa: B301 - binary_sequence_types = (bytearray, memoryview) - - def datetime_utcnow(): - return datetime.utcnow() - - def utc_from_timestamp(timestamp): - return datetime.utcfromtimestamp(timestamp) - - def implements_str(cls): - # type: (T) -> T - cls.__unicode__ = cls.__str__ - cls.__str__ = lambda x: unicode(x).encode("utf-8") # noqa - return cls - - # The line below is written as an "exec" because it triggers a syntax error in Python 3 - exec("def reraise(tp, value, tb=None):\n raise tp, value, tb") - - def contextmanager(func): - # type: (Callable) -> Callable - """ - Decorator which creates a contextmanager that can also be used as a - decorator, similar to how the built-in contextlib.contextmanager - function works in Python 3.2+. - """ - contextmanager_func = contextlib.contextmanager(func) - - @wraps(func) - class DecoratorContextManager: - def __init__(self, *args, **kwargs): - # type: (...) -> None - self.the_contextmanager = contextmanager_func(*args, **kwargs) - - def __enter__(self): - # type: () -> None - self.the_contextmanager.__enter__() - - def __exit__(self, *args, **kwargs): - # type: (...) -> None - self.the_contextmanager.__exit__(*args, **kwargs) - - def __call__(self, decorated_func): - # type: (Callable) -> Callable[...] - @wraps(decorated_func) - def when_called(*args, **kwargs): - # type: (...) -> Any - with self.the_contextmanager: - return_val = decorated_func(*args, **kwargs) - return return_val - - return when_called - - return DecoratorContextManager - -else: - from datetime import timezone - import urllib.parse as urlparse # noqa - - text_type = str - string_types = (text_type,) # type: Tuple[type] - number_types = (int, float) # type: Tuple[type, type] - int_types = (int,) - iteritems = lambda x: x.items() - binary_sequence_types = (bytes, bytearray, memoryview) - - def datetime_utcnow(): - # type: () -> datetime - return datetime.now(timezone.utc) - - def utc_from_timestamp(timestamp): - # type: (float) -> datetime - return datetime.fromtimestamp(timestamp, timezone.utc) - - def implements_str(x): - # type: (T) -> T - return x - - def reraise(tp, value, tb=None): - # type: (Optional[Type[BaseException]], Optional[BaseException], Optional[Any]) -> None - assert value is not None - if value.__traceback__ is not tb: - raise value.with_traceback(tb) - raise value - - # contextlib.contextmanager already can be used as decorator in Python 3.2+ - contextmanager = contextlib.contextmanager - - -def with_metaclass(meta, *bases): - # type: (Any, *Any) -> Any + +def with_metaclass(meta: "Any", *bases: "Any") -> "Any": class MetaClass(type): - def __new__(metacls, name, this_bases, d): - # type: (Any, Any, Any, Any) -> Any + def __new__(metacls: "Any", name: "Any", this_bases: "Any", d: "Any") -> "Any": return meta(name, bases, d) return type.__new__(MetaClass, "temporary_class", (), {}) -def check_thread_support(): - # type: () -> None +def check_uwsgi_thread_support() -> bool: + # We check two things here: + # + # 1. uWSGI doesn't run in threaded mode by default -- issue a warning if + # that's the case. + # + # 2. Additionally, if uWSGI is running in preforking mode (default), it needs + # the --py-call-uwsgi-fork-hooks option for the SDK to work properly. This + # is because any background threads spawned before the main process is + # forked are NOT CLEANED UP IN THE CHILDREN BY DEFAULT even if + # --enable-threads is on. One has to explicitly provide + # --py-call-uwsgi-fork-hooks to force uWSGI to run regular cpython + # after-fork hooks that take care of cleaning up stale thread data. try: from uwsgi import opt # type: ignore except ImportError: - return + return True + + from sentry_sdk.consts import FALSE_VALUES + + def enabled(option: str) -> bool: + value = opt.get(option, False) + if isinstance(value, bool): + return value + + if isinstance(value, bytes): + try: + value = value.decode() + except Exception: + pass + + return value and str(value).lower() not in FALSE_VALUES # When `threads` is passed in as a uwsgi option, # `enable-threads` is implied on. - if "threads" in opt: - return + threads_enabled = "threads" in opt or enabled("enable-threads") + fork_hooks_on = enabled("py-call-uwsgi-fork-hooks") + lazy_mode = enabled("lazy-apps") or enabled("lazy") - # put here because of circular import - from sentry_sdk.consts import FALSE_VALUES + if lazy_mode and not threads_enabled: + from warnings import warn - if str(opt.get("enable-threads", "0")).lower() in FALSE_VALUES: + warn( + Warning( + "IMPORTANT: " + "We detected the use of uWSGI without thread support. " + "This might lead to unexpected issues. " + 'Please run uWSGI with "--enable-threads" for full support.' + ) + ) + + return False + + elif not lazy_mode and (not threads_enabled or not fork_hooks_on): from warnings import warn warn( Warning( - "We detected the use of uwsgi with disabled threads. " - "This will cause issues with the transport you are " - "trying to use. Please enable threading for uwsgi. " - '(Add the "enable-threads" flag).' + "IMPORTANT: " + "We detected the use of uWSGI in preforking mode without " + "thread support. This might lead to crashing workers. " + 'Please run uWSGI with both "--enable-threads" and ' + '"--py-call-uwsgi-fork-hooks" for full support.' ) ) + + return False + + return True diff --git a/sentry_sdk/_functools.py b/sentry_sdk/_functools.py deleted file mode 100644 index 6bcc85f3b4..0000000000 --- a/sentry_sdk/_functools.py +++ /dev/null @@ -1,121 +0,0 @@ -""" -A backport of Python 3 functools to Python 2/3. The only important change -we rely upon is that `update_wrapper` handles AttributeError gracefully. - -Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010, -2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020 Python Software Foundation; - -All Rights Reserved - - -PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2 --------------------------------------------- - -1. This LICENSE AGREEMENT is between the Python Software Foundation -("PSF"), and the Individual or Organization ("Licensee") accessing and -otherwise using this software ("Python") in source or binary form and -its associated documentation. - -2. Subject to the terms and conditions of this License Agreement, PSF hereby -grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce, -analyze, test, perform and/or display publicly, prepare derivative works, -distribute, and otherwise use Python alone or in any derivative version, -provided, however, that PSF's License Agreement and PSF's notice of copyright, -i.e., "Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010, -2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020 Python Software Foundation; -All Rights Reserved" are retained in Python alone or in any derivative version -prepared by Licensee. - -3. In the event Licensee prepares a derivative work that is based on -or incorporates Python or any part thereof, and wants to make -the derivative work available to others as provided herein, then -Licensee hereby agrees to include in any such work a brief summary of -the changes made to Python. - -4. PSF is making Python available to Licensee on an "AS IS" -basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR -IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND -DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS -FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT -INFRINGE ANY THIRD PARTY RIGHTS. - -5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON -FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS -A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON, -OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. - -6. This License Agreement will automatically terminate upon a material -breach of its terms and conditions. - -7. Nothing in this License Agreement shall be deemed to create any -relationship of agency, partnership, or joint venture between PSF and -Licensee. This License Agreement does not grant permission to use PSF -trademarks or trade name in a trademark sense to endorse or promote -products or services of Licensee, or any third party. - -8. By copying, installing or otherwise using Python, Licensee -agrees to be bound by the terms and conditions of this License -Agreement. -""" - -from functools import partial - -from sentry_sdk._types import TYPE_CHECKING - -if TYPE_CHECKING: - from typing import Any - from typing import Callable - - -WRAPPER_ASSIGNMENTS = ( - "__module__", - "__name__", - "__qualname__", - "__doc__", - "__annotations__", -) -WRAPPER_UPDATES = ("__dict__",) - - -def update_wrapper( - wrapper, wrapped, assigned=WRAPPER_ASSIGNMENTS, updated=WRAPPER_UPDATES -): - # type: (Any, Any, Any, Any) -> Any - """Update a wrapper function to look like the wrapped function - - wrapper is the function to be updated - wrapped is the original function - assigned is a tuple naming the attributes assigned directly - from the wrapped function to the wrapper function (defaults to - functools.WRAPPER_ASSIGNMENTS) - updated is a tuple naming the attributes of the wrapper that - are updated with the corresponding attribute from the wrapped - function (defaults to functools.WRAPPER_UPDATES) - """ - for attr in assigned: - try: - value = getattr(wrapped, attr) - except AttributeError: - pass - else: - setattr(wrapper, attr, value) - for attr in updated: - getattr(wrapper, attr).update(getattr(wrapped, attr, {})) - # Issue #17482: set __wrapped__ last so we don't inadvertently copy it - # from the wrapped function when updating __dict__ - wrapper.__wrapped__ = wrapped - # Return the wrapper so this can be used as a decorator via partial() - return wrapper - - -def wraps(wrapped, assigned=WRAPPER_ASSIGNMENTS, updated=WRAPPER_UPDATES): - # type: (Callable[..., Any], Any, Any) -> Callable[[Callable[..., Any]], Callable[..., Any]] - """Decorator factory to apply update_wrapper() to a wrapper function - - Returns a decorator that invokes update_wrapper() with the decorated - function as the wrapper argument and the arguments to wraps() as the - remaining arguments. Default arguments are as for update_wrapper(). - This is a convenience function to simplify applying partial() to - update_wrapper(). - """ - return partial(update_wrapper, wrapped=wrapped, assigned=assigned, updated=updated) diff --git a/sentry_sdk/_init_implementation.py b/sentry_sdk/_init_implementation.py new file mode 100644 index 0000000000..c2d77809c7 --- /dev/null +++ b/sentry_sdk/_init_implementation.py @@ -0,0 +1,79 @@ +import warnings + +from typing import TYPE_CHECKING + +import sentry_sdk + +if TYPE_CHECKING: + from typing import Any, ContextManager, Optional + + import sentry_sdk.consts + + +class _InitGuard: + _CONTEXT_MANAGER_DEPRECATION_WARNING_MESSAGE = ( + "Using the return value of sentry_sdk.init as a context manager " + "and manually calling the __enter__ and __exit__ methods on the " + "return value are deprecated. We are no longer maintaining this " + "functionality, and we will remove it in the next major release." + ) + + def __init__(self, client: "sentry_sdk.Client") -> None: + self._client = client + + def __enter__(self) -> "_InitGuard": + warnings.warn( + self._CONTEXT_MANAGER_DEPRECATION_WARNING_MESSAGE, + stacklevel=2, + category=DeprecationWarning, + ) + + return self + + def __exit__(self, exc_type: "Any", exc_value: "Any", tb: "Any") -> None: + warnings.warn( + self._CONTEXT_MANAGER_DEPRECATION_WARNING_MESSAGE, + stacklevel=2, + category=DeprecationWarning, + ) + + c = self._client + if c is not None: + c.close() + + +def _check_python_deprecations() -> None: + # Since we're likely to deprecate Python versions in the future, I'm keeping + # this handy function around. Use this to detect the Python version used and + # to output logger.warning()s if it's deprecated. + pass + + +def _init(*args: "Optional[str]", **kwargs: "Any") -> "ContextManager[Any]": + """Initializes the SDK and optionally integrations. + + This takes the same arguments as the client constructor. + """ + client = sentry_sdk.Client(*args, **kwargs) + sentry_sdk.get_global_scope().set_client(client) + _check_python_deprecations() + rv = _InitGuard(client) + return rv + + +if TYPE_CHECKING: + # Make mypy, PyCharm and other static analyzers think `init` is a type to + # have nicer autocompletion for params. + # + # Use `ClientConstructor` to define the argument types of `init` and + # `ContextManager[Any]` to tell static analyzers about the return type. + + class init(sentry_sdk.consts.ClientConstructor, _InitGuard): # noqa: N801 + pass + +else: + # Alias `init` for actual usage. Go through the lambda indirection to throw + # PyCharm off of the weakly typed signature (it would otherwise discover + # both the weakly typed signature of `_init` and our faked `init` type). + + init = (lambda: _init)() diff --git a/sentry_sdk/_log_batcher.py b/sentry_sdk/_log_batcher.py new file mode 100644 index 0000000000..51886f48f9 --- /dev/null +++ b/sentry_sdk/_log_batcher.py @@ -0,0 +1,164 @@ +import os +import random +import threading +from datetime import datetime, timezone +from typing import Optional, List, Callable, TYPE_CHECKING, Any + +from sentry_sdk.utils import format_timestamp, safe_repr, serialize_attribute +from sentry_sdk.envelope import Envelope, Item, PayloadRef + +if TYPE_CHECKING: + from sentry_sdk._types import Log + + +class LogBatcher: + MAX_LOGS_BEFORE_FLUSH = 100 + MAX_LOGS_BEFORE_DROP = 1_000 + FLUSH_WAIT_TIME = 5.0 + + def __init__( + self, + capture_func: "Callable[[Envelope], None]", + record_lost_func: "Callable[..., None]", + ) -> None: + self._log_buffer: "List[Log]" = [] + self._capture_func = capture_func + self._record_lost_func = record_lost_func + self._running = True + self._lock = threading.Lock() + + self._flush_event: "threading.Event" = threading.Event() + + self._flusher: "Optional[threading.Thread]" = None + self._flusher_pid: "Optional[int]" = None + + def _ensure_thread(self) -> bool: + """For forking processes we might need to restart this thread. + This ensures that our process actually has that thread running. + """ + if not self._running: + return False + + pid = os.getpid() + if self._flusher_pid == pid: + return True + + with self._lock: + # Recheck to make sure another thread didn't get here and start the + # the flusher in the meantime + if self._flusher_pid == pid: + return True + + self._flusher_pid = pid + + self._flusher = threading.Thread(target=self._flush_loop) + self._flusher.daemon = True + + try: + self._flusher.start() + except RuntimeError: + # Unfortunately at this point the interpreter is in a state that no + # longer allows us to spawn a thread and we have to bail. + self._running = False + return False + + return True + + def _flush_loop(self) -> None: + while self._running: + self._flush_event.wait(self.FLUSH_WAIT_TIME + random.random()) + self._flush_event.clear() + self._flush() + + def add( + self, + log: "Log", + ) -> None: + if not self._ensure_thread() or self._flusher is None: + return None + + with self._lock: + if len(self._log_buffer) >= self.MAX_LOGS_BEFORE_DROP: + # Construct log envelope item without sending it to report lost bytes + log_item = Item( + type="log", + content_type="application/vnd.sentry.items.log+json", + headers={ + "item_count": 1, + }, + payload=PayloadRef( + json={"items": [LogBatcher._log_to_transport_format(log)]} + ), + ) + self._record_lost_func( + reason="queue_overflow", + data_category="log_item", + item=log_item, + quantity=1, + ) + return None + + self._log_buffer.append(log) + if len(self._log_buffer) >= self.MAX_LOGS_BEFORE_FLUSH: + self._flush_event.set() + + def kill(self) -> None: + if self._flusher is None: + return + + self._running = False + self._flush_event.set() + self._flusher = None + + def flush(self) -> None: + self._flush() + + @staticmethod + def _log_to_transport_format(log: "Log") -> "Any": + if "sentry.severity_number" not in log["attributes"]: + log["attributes"]["sentry.severity_number"] = log["severity_number"] + if "sentry.severity_text" not in log["attributes"]: + log["attributes"]["sentry.severity_text"] = log["severity_text"] + + res = { + "timestamp": int(log["time_unix_nano"]) / 1.0e9, + "trace_id": log.get("trace_id", "00000000-0000-0000-0000-000000000000"), + "span_id": log.get("span_id"), + "level": str(log["severity_text"]), + "body": str(log["body"]), + "attributes": { + k: serialize_attribute(v) for (k, v) in log["attributes"].items() + }, + } + + return res + + def _flush(self) -> "Optional[Envelope]": + envelope = Envelope( + headers={"sent_at": format_timestamp(datetime.now(timezone.utc))} + ) + with self._lock: + if len(self._log_buffer) == 0: + return None + + envelope.add_item( + Item( + type="log", + content_type="application/vnd.sentry.items.log+json", + headers={ + "item_count": len(self._log_buffer), + }, + payload=PayloadRef( + json={ + "items": [ + self._log_to_transport_format(log) + for log in self._log_buffer + ] + } + ), + ) + ) + self._log_buffer.clear() + + self._capture_func(envelope) + return envelope diff --git a/sentry_sdk/_lru_cache.py b/sentry_sdk/_lru_cache.py index 91cf55d09a..16c238bcab 100644 --- a/sentry_sdk/_lru_cache.py +++ b/sentry_sdk/_lru_cache.py @@ -1,156 +1,43 @@ -""" -A fork of Python 3.6's stdlib lru_cache (found in Python's 'cpython/Lib/functools.py') -adapted into a data structure for single threaded uses. +from typing import TYPE_CHECKING -https://github.com/python/cpython/blob/v3.6.12/Lib/functools.py +if TYPE_CHECKING: + from typing import Any -Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010, -2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020 Python Software Foundation; +_SENTINEL = object() -All Rights Reserved - - -PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2 --------------------------------------------- - -1. This LICENSE AGREEMENT is between the Python Software Foundation -("PSF"), and the Individual or Organization ("Licensee") accessing and -otherwise using this software ("Python") in source or binary form and -its associated documentation. - -2. Subject to the terms and conditions of this License Agreement, PSF hereby -grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce, -analyze, test, perform and/or display publicly, prepare derivative works, -distribute, and otherwise use Python alone or in any derivative version, -provided, however, that PSF's License Agreement and PSF's notice of copyright, -i.e., "Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010, -2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020 Python Software Foundation; -All Rights Reserved" are retained in Python alone or in any derivative version -prepared by Licensee. - -3. In the event Licensee prepares a derivative work that is based on -or incorporates Python or any part thereof, and wants to make -the derivative work available to others as provided herein, then -Licensee hereby agrees to include in any such work a brief summary of -the changes made to Python. - -4. PSF is making Python available to Licensee on an "AS IS" -basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR -IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND -DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS -FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT -INFRINGE ANY THIRD PARTY RIGHTS. - -5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON -FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS -A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON, -OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. - -6. This License Agreement will automatically terminate upon a material -breach of its terms and conditions. - -7. Nothing in this License Agreement shall be deemed to create any -relationship of agency, partnership, or joint venture between PSF and -Licensee. This License Agreement does not grant permission to use PSF -trademarks or trade name in a trademark sense to endorse or promote -products or services of Licensee, or any third party. - -8. By copying, installing or otherwise using Python, Licensee -agrees to be bound by the terms and conditions of this License -Agreement. - -""" - -SENTINEL = object() - - -# aliases to the entries in a node -PREV = 0 -NEXT = 1 -KEY = 2 -VALUE = 3 - - -class LRUCache(object): - def __init__(self, max_size): - assert max_size > 0 +class LRUCache: + def __init__(self, max_size: int) -> None: + if max_size <= 0: + raise AssertionError(f"invalid max_size: {max_size}") self.max_size = max_size - self.full = False - - self.cache = {} - - # root of the circularly linked list to keep track of - # the least recently used key - self.root = [] # type: ignore - # the node looks like [PREV, NEXT, KEY, VALUE] - self.root[:] = [self.root, self.root, None, None] - + self._data: "dict[Any, Any]" = {} self.hits = self.misses = 0 + self.full = False - def set(self, key, value): - link = self.cache.get(key, SENTINEL) - - if link is not SENTINEL: - # have to move the node to the front of the linked list - link_prev, link_next, _key, _value = link - - # first remove the node from the lsnked list - link_prev[NEXT] = link_next - link_next[PREV] = link_prev - - # insert the node between the root and the last - last = self.root[PREV] - last[NEXT] = self.root[PREV] = link - link[PREV] = last - link[NEXT] = self.root - - # update the value - link[VALUE] = value - + def set(self, key: "Any", value: "Any") -> None: + current = self._data.pop(key, _SENTINEL) + if current is not _SENTINEL: + self._data[key] = value elif self.full: - # reuse the root node, so update its key/value - old_root = self.root - old_root[KEY] = key - old_root[VALUE] = value - - self.root = old_root[NEXT] - old_key = self.root[KEY] - - self.root[KEY] = self.root[VALUE] = None - - del self.cache[old_key] - - self.cache[key] = old_root - + self._data.pop(next(iter(self._data))) + self._data[key] = value else: - # insert new node after last - last = self.root[PREV] - link = [last, self.root, key, value] - last[NEXT] = self.root[PREV] = self.cache[key] = link - self.full = len(self.cache) >= self.max_size - - def get(self, key, default=None): - link = self.cache.get(key, SENTINEL) + self._data[key] = value + self.full = len(self._data) >= self.max_size - if link is SENTINEL: + def get(self, key: "Any", default: "Any" = None) -> "Any": + try: + ret = self._data.pop(key) + except KeyError: self.misses += 1 - return default - - # have to move the node to the front of the linked list - link_prev, link_next, _key, _value = link - - # first remove the node from the lsnked list - link_prev[NEXT] = link_next - link_next[PREV] = link_prev - - # insert the node between the root and the last - last = self.root[PREV] - last[NEXT] = self.root[PREV] = link - link[PREV] = last - link[NEXT] = self.root + ret = default + else: + self.hits += 1 + self._data[key] = ret - self.hits += 1 + return ret - return link[VALUE] + def get_all(self) -> "list[tuple[Any, Any]]": + return list(self._data.items()) diff --git a/sentry_sdk/_metrics_batcher.py b/sentry_sdk/_metrics_batcher.py new file mode 100644 index 0000000000..6cbac0cbce --- /dev/null +++ b/sentry_sdk/_metrics_batcher.py @@ -0,0 +1,146 @@ +import os +import random +import threading +from datetime import datetime, timezone +from typing import Optional, List, Callable, TYPE_CHECKING, Any, Union + +from sentry_sdk.utils import format_timestamp, safe_repr, serialize_attribute +from sentry_sdk.envelope import Envelope, Item, PayloadRef + +if TYPE_CHECKING: + from sentry_sdk._types import Metric + + +class MetricsBatcher: + MAX_METRICS_BEFORE_FLUSH = 1000 + MAX_METRICS_BEFORE_DROP = 10_000 + FLUSH_WAIT_TIME = 5.0 + + def __init__( + self, + capture_func: "Callable[[Envelope], None]", + record_lost_func: "Callable[..., None]", + ) -> None: + self._metric_buffer: "List[Metric]" = [] + self._capture_func = capture_func + self._record_lost_func = record_lost_func + self._running = True + self._lock = threading.Lock() + + self._flush_event: "threading.Event" = threading.Event() + + self._flusher: "Optional[threading.Thread]" = None + self._flusher_pid: "Optional[int]" = None + + def _ensure_thread(self) -> bool: + if not self._running: + return False + + pid = os.getpid() + if self._flusher_pid == pid: + return True + + with self._lock: + if self._flusher_pid == pid: + return True + + self._flusher_pid = pid + + self._flusher = threading.Thread(target=self._flush_loop) + self._flusher.daemon = True + + try: + self._flusher.start() + except RuntimeError: + self._running = False + return False + + return True + + def _flush_loop(self) -> None: + while self._running: + self._flush_event.wait(self.FLUSH_WAIT_TIME + random.random()) + self._flush_event.clear() + self._flush() + + def add( + self, + metric: "Metric", + ) -> None: + if not self._ensure_thread() or self._flusher is None: + return None + + with self._lock: + if len(self._metric_buffer) >= self.MAX_METRICS_BEFORE_DROP: + self._record_lost_func( + reason="queue_overflow", + data_category="trace_metric", + quantity=1, + ) + return None + + self._metric_buffer.append(metric) + if len(self._metric_buffer) >= self.MAX_METRICS_BEFORE_FLUSH: + self._flush_event.set() + + def kill(self) -> None: + if self._flusher is None: + return + + self._running = False + self._flush_event.set() + self._flusher = None + + def flush(self) -> None: + self._flush() + + @staticmethod + def _metric_to_transport_format(metric: "Metric") -> "Any": + res = { + "timestamp": metric["timestamp"], + "trace_id": metric["trace_id"], + "name": metric["name"], + "type": metric["type"], + "value": metric["value"], + "attributes": { + k: serialize_attribute(v) for (k, v) in metric["attributes"].items() + }, + } + + if metric.get("span_id") is not None: + res["span_id"] = metric["span_id"] + + if metric.get("unit") is not None: + res["unit"] = metric["unit"] + + return res + + def _flush(self) -> "Optional[Envelope]": + envelope = Envelope( + headers={"sent_at": format_timestamp(datetime.now(timezone.utc))} + ) + with self._lock: + if len(self._metric_buffer) == 0: + return None + + envelope.add_item( + Item( + type="trace_metric", + content_type="application/vnd.sentry.items.trace-metric+json", + headers={ + "item_count": len(self._metric_buffer), + }, + payload=PayloadRef( + json={ + "items": [ + self._metric_to_transport_format(metric) + for metric in self._metric_buffer + ] + } + ), + ) + ) + self._metric_buffer.clear() + + self._capture_func(envelope) + return envelope diff --git a/sentry_sdk/_queue.py b/sentry_sdk/_queue.py index 129b6e58a6..9bdb76dddb 100644 --- a/sentry_sdk/_queue.py +++ b/sentry_sdk/_queue.py @@ -76,7 +76,7 @@ from collections import deque from time import time -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any @@ -86,15 +86,17 @@ class EmptyError(Exception): "Exception raised by Queue.get(block=0)/get_nowait()." + pass class FullError(Exception): "Exception raised by Queue.put(block=0)/put_nowait()." + pass -class Queue(object): +class Queue: """Create a queue object with a given maximum size. If maxsize is <= 0, the queue size is infinite. @@ -273,7 +275,7 @@ def get_nowait(self): # Initialize the queue representation def _init(self, maxsize): - self.queue = deque() # type: Any + self.queue: "Any" = deque() def _qsize(self): return len(self.queue) diff --git a/sentry_sdk/_types.py b/sentry_sdk/_types.py index e88d07b420..5a8cb936ac 100644 --- a/sentry_sdk/_types.py +++ b/sentry_sdk/_types.py @@ -1,62 +1,118 @@ -try: - from typing import TYPE_CHECKING as TYPE_CHECKING -except ImportError: - TYPE_CHECKING = False +from typing import TYPE_CHECKING, TypeVar, Union # Re-exported for compat, since code out there in the wild might use this variable. MYPY = TYPE_CHECKING +SENSITIVE_DATA_SUBSTITUTE = "[Filtered]" + + +class AnnotatedValue: + """ + Meta information for a data field in the event payload. + This is to tell Relay that we have tampered with the fields value. + See: + https://github.com/getsentry/relay/blob/be12cd49a0f06ea932ed9b9f93a655de5d6ad6d1/relay-general/src/types/meta.rs#L407-L423 + """ + + __slots__ = ("value", "metadata") + + def __init__(self, value: "Optional[Any]", metadata: "Dict[str, Any]") -> None: + self.value = value + self.metadata = metadata + + def __eq__(self, other: "Any") -> bool: + if not isinstance(other, AnnotatedValue): + return False + + return self.value == other.value and self.metadata == other.metadata + + def __str__(self: "AnnotatedValue") -> str: + return str({"value": str(self.value), "metadata": str(self.metadata)}) + + def __len__(self: "AnnotatedValue") -> int: + if self.value is not None: + return len(self.value) + else: + return 0 + + @classmethod + def removed_because_raw_data(cls) -> "AnnotatedValue": + """The value was removed because it could not be parsed. This is done for request body values that are not json nor a form.""" + return AnnotatedValue( + value="", + metadata={ + "rem": [ # Remark + [ + "!raw", # Unparsable raw data + "x", # The fields original value was removed + ] + ] + }, + ) + + @classmethod + def removed_because_over_size_limit(cls, value: "Any" = "") -> "AnnotatedValue": + """ + The actual value was removed because the size of the field exceeded the configured maximum size, + for example specified with the max_request_body_size sdk option. + """ + return AnnotatedValue( + value=value, + metadata={ + "rem": [ # Remark + [ + "!config", # Because of configured maximum size + "x", # The fields original value was removed + ] + ] + }, + ) + + @classmethod + def substituted_because_contains_sensitive_data(cls) -> "AnnotatedValue": + """The actual value was removed because it contained sensitive information.""" + return AnnotatedValue( + value=SENSITIVE_DATA_SUBSTITUTE, + metadata={ + "rem": [ # Remark + [ + "!config", # Because of SDK configuration (in this case the config is the hard coded removal of certain django cookies) + "s", # The fields original value was substituted + ] + ] + }, + ) + + +T = TypeVar("T") +Annotated = Union[AnnotatedValue, T] + + if TYPE_CHECKING: + from collections.abc import Container, MutableMapping, Sequence + + from datetime import datetime + from types import TracebackType from typing import Any from typing import Callable from typing import Dict - from typing import List from typing import Mapping + from typing import NotRequired from typing import Optional from typing import Tuple from typing import Type - from typing import Union - from typing_extensions import Literal + from typing_extensions import Literal, TypedDict - ExcInfo = Tuple[ - Optional[Type[BaseException]], Optional[BaseException], Optional[TracebackType] - ] - - Event = Dict[str, Any] - Hint = Dict[str, Any] - - Breadcrumb = Dict[str, Any] - BreadcrumbHint = Dict[str, Any] + class SDKInfo(TypedDict): + name: str + version: str + packages: "Sequence[Mapping[str, str]]" - SamplingContext = Dict[str, Any] - - EventProcessor = Callable[[Event, Hint], Optional[Event]] - ErrorProcessor = Callable[[Event, ExcInfo], Optional[Event]] - BreadcrumbProcessor = Callable[[Breadcrumb, BreadcrumbHint], Optional[Breadcrumb]] - TransactionProcessor = Callable[[Event, Hint], Optional[Event]] - - TracesSampler = Callable[[SamplingContext], Union[float, int, bool]] - - # https://github.com/python/mypy/issues/5710 - NotImplementedType = Any - - EventDataCategory = Literal[ - "default", - "error", - "crash", - "transaction", - "security", - "attachment", - "session", - "internal", - "profile", - "statsd", - ] - SessionStatus = Literal["ok", "exited", "crashed", "abnormal"] - EndpointType = Literal["store", "envelope"] + # "critical" is an alias of "fatal" recognized by Relay + LogLevelStr = Literal["fatal", "critical", "error", "warning", "info", "debug"] DurationUnit = Literal[ "nanosecond", @@ -89,30 +145,207 @@ FractionUnit = Literal["ratio", "percent"] MeasurementUnit = Union[DurationUnit, InformationUnit, FractionUnit, str] - ProfilerMode = Literal["sleep", "thread", "gevent", "unknown"] + MeasurementValue = TypedDict( + "MeasurementValue", + { + "value": float, + "unit": NotRequired[Optional[MeasurementUnit]], + }, + ) + + Event = TypedDict( + "Event", + { + "breadcrumbs": Annotated[ + dict[Literal["values"], list[dict[str, Any]]] + ], # TODO: We can expand on this type + "check_in_id": str, + "contexts": dict[str, dict[str, object]], + "dist": str, + "duration": Optional[float], + "environment": Optional[str], + "errors": list[dict[str, Any]], # TODO: We can expand on this type + "event_id": str, + "exception": dict[ + Literal["values"], list[dict[str, Any]] + ], # TODO: We can expand on this type + "extra": MutableMapping[str, object], + "fingerprint": list[str], + "level": LogLevelStr, + "logentry": Mapping[str, object], + "logger": str, + "measurements": dict[str, MeasurementValue], + "message": str, + "modules": dict[str, str], + "monitor_config": Mapping[str, object], + "monitor_slug": Optional[str], + "platform": Literal["python"], + "profile": object, # Should be sentry_sdk.profiler.Profile, but we can't import that here due to circular imports + "release": Optional[str], + "request": dict[str, object], + "sdk": Mapping[str, object], + "server_name": str, + "spans": Annotated[list[dict[str, object]]], + "stacktrace": dict[ + str, object + ], # We access this key in the code, but I am unsure whether we ever set it + "start_timestamp": datetime, + "status": Optional[str], + "tags": MutableMapping[ + str, str + ], # Tags must be less than 200 characters each + "threads": dict[ + Literal["values"], list[dict[str, Any]] + ], # TODO: We can expand on this type + "timestamp": Optional[datetime], # Must be set before sending the event + "transaction": str, + "transaction_info": Mapping[str, Any], # TODO: We can expand on this type + "type": Literal["check_in", "transaction"], + "user": dict[str, object], + "_dropped_spans": int, + }, + total=False, + ) + + ExcInfo = Union[ + tuple[Type[BaseException], BaseException, Optional[TracebackType]], + tuple[None, None, None], + ] + + # TODO: Make a proper type definition for this (PRs welcome!) + Hint = Dict[str, Any] + + AttributeValue = ( + str | bool | float | int + # TODO: relay support coming soon for + # | list[str] | list[bool] | list[float] | list[int] + ) + Attributes = dict[str, AttributeValue] + + SerializedAttributeValue = TypedDict( + # https://develop.sentry.dev/sdk/telemetry/attributes/#supported-types + "SerializedAttributeValue", + { + "type": Literal[ + "string", + "boolean", + "double", + "integer", + # TODO: relay support coming soon for: + # "string[]", + # "boolean[]", + # "double[]", + # "integer[]", + ], + "value": AttributeValue, + }, + ) - # Type of the metric. - MetricType = Literal["d", "s", "g", "c"] + Log = TypedDict( + "Log", + { + "severity_text": str, + "severity_number": int, + "body": str, + "attributes": Attributes, + "time_unix_nano": int, + "trace_id": Optional[str], + "span_id": Optional[str], + }, + ) - # Value of the metric. - MetricValue = Union[int, float, str] + MetricType = Literal["counter", "gauge", "distribution"] - # Internal representation of tags as a tuple of tuples (this is done in order to allow for the same key to exist - # multiple times). - MetricTagsInternal = Tuple[Tuple[str, str], ...] + Metric = TypedDict( + "Metric", + { + "timestamp": float, + "trace_id": Optional[str], + "span_id": Optional[str], + "name": str, + "type": MetricType, + "value": float, + "unit": Optional[str], + "attributes": Attributes, + }, + ) + + MetricProcessor = Callable[[Metric, Hint], Optional[Metric]] + + # TODO: Make a proper type definition for this (PRs welcome!) + Breadcrumb = Dict[str, Any] + + # TODO: Make a proper type definition for this (PRs welcome!) + BreadcrumbHint = Dict[str, Any] + + # TODO: Make a proper type definition for this (PRs welcome!) + SamplingContext = Dict[str, Any] + + EventProcessor = Callable[[Event, Hint], Optional[Event]] + ErrorProcessor = Callable[[Event, ExcInfo], Optional[Event]] + BreadcrumbProcessor = Callable[[Breadcrumb, BreadcrumbHint], Optional[Breadcrumb]] + TransactionProcessor = Callable[[Event, Hint], Optional[Event]] + LogProcessor = Callable[[Log, Hint], Optional[Log]] + + TracesSampler = Callable[[SamplingContext], Union[float, int, bool]] - # External representation of tags as a dictionary. - MetricTagValue = Union[ - str, - int, - float, - None, - List[Union[int, str, float, None]], - Tuple[Union[int, str, float, None], ...], + # https://github.com/python/mypy/issues/5710 + NotImplementedType = Any + + EventDataCategory = Literal[ + "default", + "error", + "crash", + "transaction", + "security", + "attachment", + "session", + "internal", + "profile", + "profile_chunk", + "monitor", + "span", + "log_item", + "log_byte", + "trace_metric", ] - MetricTags = Mapping[str, MetricTagValue] + SessionStatus = Literal["ok", "exited", "crashed", "abnormal"] + + ContinuousProfilerMode = Literal["thread", "gevent", "unknown"] + ProfilerMode = Union[ContinuousProfilerMode, Literal["sleep"]] + + MonitorConfigScheduleType = Literal["crontab", "interval"] + MonitorConfigScheduleUnit = Literal[ + "year", + "month", + "week", + "day", + "hour", + "minute", + "second", # not supported in Sentry and will result in a warning + ] + + MonitorConfigSchedule = TypedDict( + "MonitorConfigSchedule", + { + "type": MonitorConfigScheduleType, + "value": Union[int, str], + "unit": MonitorConfigScheduleUnit, + }, + total=False, + ) - # Value inside the generator for the metric value. - FlushedMetricValue = Union[int, float] + MonitorConfig = TypedDict( + "MonitorConfig", + { + "schedule": MonitorConfigSchedule, + "timezone": str, + "checkin_margin": int, + "max_runtime": int, + "failure_issue_threshold": int, + "recovery_threshold": int, + }, + total=False, + ) - BucketKey = Tuple[MetricType, str, MeasurementUnit, MetricTagsInternal] + HttpStatusCodeRange = Union[int, Container[int]] diff --git a/sentry_sdk/_werkzeug.py b/sentry_sdk/_werkzeug.py index 197c5c19b1..cdc3026c08 100644 --- a/sentry_sdk/_werkzeug.py +++ b/sentry_sdk/_werkzeug.py @@ -32,9 +32,7 @@ SUCH DAMAGE. """ -from sentry_sdk._compat import iteritems - -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Dict @@ -49,12 +47,11 @@ # We need this function because Django does not give us a "pure" http header # dict. So we might as well use it for all WSGI integrations. # -def _get_headers(environ): - # type: (Dict[str, str]) -> Iterator[Tuple[str, str]] +def _get_headers(environ: "Dict[str, str]") -> "Iterator[Tuple[str, str]]": """ Returns only proper HTTP headers. """ - for key, value in iteritems(environ): + for key, value in environ.items(): key = str(key) if key.startswith("HTTP_") and key not in ( "HTTP_CONTENT_TYPE", @@ -69,8 +66,7 @@ def _get_headers(environ): # `get_host` comes from `werkzeug.wsgi.get_host` # https://github.com/pallets/werkzeug/blob/1.0.1/src/werkzeug/wsgi.py#L145 # -def get_host(environ, use_x_forwarded_for=False): - # type: (Dict[str, str], bool) -> str +def get_host(environ: "Dict[str, str]", use_x_forwarded_for: bool = False) -> str: """ Return the host for the given WSGI environment. """ diff --git a/sentry_sdk/ai/__init__.py b/sentry_sdk/ai/__init__.py new file mode 100644 index 0000000000..fbcb9c061d --- /dev/null +++ b/sentry_sdk/ai/__init__.py @@ -0,0 +1,7 @@ +from .utils import ( + set_data_normalized, + GEN_AI_MESSAGE_ROLE_MAPPING, + GEN_AI_MESSAGE_ROLE_REVERSE_MAPPING, + normalize_message_role, + normalize_message_roles, +) # noqa: F401 diff --git a/sentry_sdk/ai/monitoring.py b/sentry_sdk/ai/monitoring.py new file mode 100644 index 0000000000..e7e00ad462 --- /dev/null +++ b/sentry_sdk/ai/monitoring.py @@ -0,0 +1,129 @@ +import inspect +from functools import wraps + +from sentry_sdk.consts import SPANDATA +import sentry_sdk.utils +from sentry_sdk import start_span +from sentry_sdk.tracing import Span +from sentry_sdk.utils import ContextVar + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Optional, Callable, Awaitable, Any, Union, TypeVar + + F = TypeVar("F", bound=Union[Callable[..., Any], Callable[..., Awaitable[Any]]]) + +_ai_pipeline_name = ContextVar("ai_pipeline_name", default=None) + + +def set_ai_pipeline_name(name: "Optional[str]") -> None: + _ai_pipeline_name.set(name) + + +def get_ai_pipeline_name() -> "Optional[str]": + return _ai_pipeline_name.get() + + +def ai_track(description: str, **span_kwargs: "Any") -> "Callable[[F], F]": + def decorator(f: "F") -> "F": + def sync_wrapped(*args: "Any", **kwargs: "Any") -> "Any": + curr_pipeline = _ai_pipeline_name.get() + op = span_kwargs.pop("op", "ai.run" if curr_pipeline else "ai.pipeline") + + with start_span(name=description, op=op, **span_kwargs) as span: + for k, v in kwargs.pop("sentry_tags", {}).items(): + span.set_tag(k, v) + for k, v in kwargs.pop("sentry_data", {}).items(): + span.set_data(k, v) + if curr_pipeline: + span.set_data(SPANDATA.GEN_AI_PIPELINE_NAME, curr_pipeline) + return f(*args, **kwargs) + else: + _ai_pipeline_name.set(description) + try: + res = f(*args, **kwargs) + except Exception as e: + event, hint = sentry_sdk.utils.event_from_exception( + e, + client_options=sentry_sdk.get_client().options, + mechanism={"type": "ai_monitoring", "handled": False}, + ) + sentry_sdk.capture_event(event, hint=hint) + raise e from None + finally: + _ai_pipeline_name.set(None) + return res + + async def async_wrapped(*args: "Any", **kwargs: "Any") -> "Any": + curr_pipeline = _ai_pipeline_name.get() + op = span_kwargs.pop("op", "ai.run" if curr_pipeline else "ai.pipeline") + + with start_span(name=description, op=op, **span_kwargs) as span: + for k, v in kwargs.pop("sentry_tags", {}).items(): + span.set_tag(k, v) + for k, v in kwargs.pop("sentry_data", {}).items(): + span.set_data(k, v) + if curr_pipeline: + span.set_data(SPANDATA.GEN_AI_PIPELINE_NAME, curr_pipeline) + return await f(*args, **kwargs) + else: + _ai_pipeline_name.set(description) + try: + res = await f(*args, **kwargs) + except Exception as e: + event, hint = sentry_sdk.utils.event_from_exception( + e, + client_options=sentry_sdk.get_client().options, + mechanism={"type": "ai_monitoring", "handled": False}, + ) + sentry_sdk.capture_event(event, hint=hint) + raise e from None + finally: + _ai_pipeline_name.set(None) + return res + + if inspect.iscoroutinefunction(f): + return wraps(f)(async_wrapped) # type: ignore + else: + return wraps(f)(sync_wrapped) # type: ignore + + return decorator + + +def record_token_usage( + span: "Span", + input_tokens: "Optional[int]" = None, + input_tokens_cached: "Optional[int]" = None, + output_tokens: "Optional[int]" = None, + output_tokens_reasoning: "Optional[int]" = None, + total_tokens: "Optional[int]" = None, +) -> None: + # TODO: move pipeline name elsewhere + ai_pipeline_name = get_ai_pipeline_name() + if ai_pipeline_name: + span.set_data(SPANDATA.GEN_AI_PIPELINE_NAME, ai_pipeline_name) + + if input_tokens is not None: + span.set_data(SPANDATA.GEN_AI_USAGE_INPUT_TOKENS, input_tokens) + + if input_tokens_cached is not None: + span.set_data( + SPANDATA.GEN_AI_USAGE_INPUT_TOKENS_CACHED, + input_tokens_cached, + ) + + if output_tokens is not None: + span.set_data(SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS, output_tokens) + + if output_tokens_reasoning is not None: + span.set_data( + SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS_REASONING, + output_tokens_reasoning, + ) + + if total_tokens is None and input_tokens is not None and output_tokens is not None: + total_tokens = input_tokens + output_tokens + + if total_tokens is not None: + span.set_data(SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS, total_tokens) diff --git a/sentry_sdk/ai/utils.py b/sentry_sdk/ai/utils.py new file mode 100644 index 0000000000..1d2b4483c9 --- /dev/null +++ b/sentry_sdk/ai/utils.py @@ -0,0 +1,193 @@ +import inspect +import json +from collections import deque +from copy import deepcopy +from sys import getsizeof +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Any, Callable, Dict, List, Optional, Tuple + + from sentry_sdk.tracing import Span + +import sentry_sdk +from sentry_sdk.utils import logger + +MAX_GEN_AI_MESSAGE_BYTES = 20_000 # 20KB +# Maximum characters when only a single message is left after bytes truncation +MAX_SINGLE_MESSAGE_CONTENT_CHARS = 10_000 + + +class GEN_AI_ALLOWED_MESSAGE_ROLES: + SYSTEM = "system" + USER = "user" + ASSISTANT = "assistant" + TOOL = "tool" + + +GEN_AI_MESSAGE_ROLE_REVERSE_MAPPING = { + GEN_AI_ALLOWED_MESSAGE_ROLES.SYSTEM: ["system"], + GEN_AI_ALLOWED_MESSAGE_ROLES.USER: ["user", "human"], + GEN_AI_ALLOWED_MESSAGE_ROLES.ASSISTANT: ["assistant", "ai"], + GEN_AI_ALLOWED_MESSAGE_ROLES.TOOL: ["tool", "tool_call"], +} + +GEN_AI_MESSAGE_ROLE_MAPPING = {} +for target_role, source_roles in GEN_AI_MESSAGE_ROLE_REVERSE_MAPPING.items(): + for source_role in source_roles: + GEN_AI_MESSAGE_ROLE_MAPPING[source_role] = target_role + + +def _normalize_data(data: "Any", unpack: bool = True) -> "Any": + # convert pydantic data (e.g. OpenAI v1+) to json compatible format + if hasattr(data, "model_dump"): + # Check if it's a class (type) rather than an instance + # Model classes can be passed as arguments (e.g., for schema definitions) + if inspect.isclass(data): + return f"" + + try: + return _normalize_data(data.model_dump(), unpack=unpack) + except Exception as e: + logger.warning("Could not convert pydantic data to JSON: %s", e) + return data if isinstance(data, (int, float, bool, str)) else str(data) + + if isinstance(data, list): + if unpack and len(data) == 1: + return _normalize_data(data[0], unpack=unpack) # remove empty dimensions + return list(_normalize_data(x, unpack=unpack) for x in data) + + if isinstance(data, dict): + return {k: _normalize_data(v, unpack=unpack) for (k, v) in data.items()} + + return data if isinstance(data, (int, float, bool, str)) else str(data) + + +def set_data_normalized( + span: "Span", key: str, value: "Any", unpack: bool = True +) -> None: + normalized = _normalize_data(value, unpack=unpack) + if isinstance(normalized, (int, float, bool, str)): + span.set_data(key, normalized) + else: + span.set_data(key, json.dumps(normalized)) + + +def normalize_message_role(role: str) -> str: + """ + Normalize a message role to one of the 4 allowed gen_ai role values. + Maps "ai" -> "assistant" and keeps other standard roles unchanged. + """ + return GEN_AI_MESSAGE_ROLE_MAPPING.get(role, role) + + +def normalize_message_roles(messages: "list[dict[str, Any]]") -> "list[dict[str, Any]]": + """ + Normalize roles in a list of messages to use standard gen_ai role values. + Creates a deep copy to avoid modifying the original messages. + """ + normalized_messages = [] + for message in messages: + if not isinstance(message, dict): + normalized_messages.append(message) + continue + normalized_message = message.copy() + if "role" in message: + normalized_message["role"] = normalize_message_role(message["role"]) + normalized_messages.append(normalized_message) + + return normalized_messages + + +def get_start_span_function() -> "Callable[..., Any]": + current_span = sentry_sdk.get_current_span() + transaction_exists = ( + current_span is not None and current_span.containing_transaction is not None + ) + return sentry_sdk.start_span if transaction_exists else sentry_sdk.start_transaction + + +def _truncate_single_message_content_if_present( + message: "Dict[str, Any]", max_chars: int +) -> "Dict[str, Any]": + """ + Truncate a message's content to at most `max_chars` characters and append an + ellipsis if truncation occurs. + """ + if not isinstance(message, dict) or "content" not in message: + return message + content = message["content"] + + if not isinstance(content, str) or len(content) <= max_chars: + return message + + message["content"] = content[:max_chars] + "..." + return message + + +def _find_truncation_index(messages: "List[Dict[str, Any]]", max_bytes: int) -> int: + """ + Find the index of the first message that would exceed the max bytes limit. + Compute the individual message sizes, and return the index of the first message from the back + of the list that would exceed the max bytes limit. + """ + running_sum = 0 + for idx in range(len(messages) - 1, -1, -1): + size = len(json.dumps(messages[idx], separators=(",", ":")).encode("utf-8")) + running_sum += size + if running_sum > max_bytes: + return idx + 1 + + return 0 + + +def truncate_messages_by_size( + messages: "List[Dict[str, Any]]", + max_bytes: int = MAX_GEN_AI_MESSAGE_BYTES, + max_single_message_chars: int = MAX_SINGLE_MESSAGE_CONTENT_CHARS, +) -> "Tuple[List[Dict[str, Any]], int]": + """ + Returns a truncated messages list, consisting of + - the last message, with its content truncated to `max_single_message_chars` characters, + if the last message's size exceeds `max_bytes` bytes; otherwise, + - the maximum number of messages, starting from the end of the `messages` list, whose total + serialized size does not exceed `max_bytes` bytes. + + In the single message case, the serialized message size may exceed `max_bytes`, because + truncation is based only on character count in that case. + """ + serialized_json = json.dumps(messages, separators=(",", ":")) + current_size = len(serialized_json.encode("utf-8")) + + if current_size <= max_bytes: + return messages, 0 + + truncation_index = _find_truncation_index(messages, max_bytes) + if truncation_index < len(messages): + truncated_messages = messages[truncation_index:] + else: + truncation_index = len(messages) - 1 + truncated_messages = messages[-1:] + + if len(truncated_messages) == 1: + truncated_messages[0] = _truncate_single_message_content_if_present( + deepcopy(truncated_messages[0]), max_chars=max_single_message_chars + ) + + return truncated_messages, truncation_index + + +def truncate_and_annotate_messages( + messages: "Optional[List[Dict[str, Any]]]", + span: "Any", + scope: "Any", + max_bytes: int = MAX_GEN_AI_MESSAGE_BYTES, +) -> "Optional[List[Dict[str, Any]]]": + if not messages: + return None + + truncated_messages, removed_count = truncate_messages_by_size(messages, max_bytes) + if removed_count > 0: + scope._gen_ai_original_message_count[span.span_id] = len(messages) + + return truncated_messages diff --git a/sentry_sdk/api.py b/sentry_sdk/api.py index f0c6a87432..c4e2229938 100644 --- a/sentry_sdk/api.py +++ b/sentry_sdk/api.py @@ -1,13 +1,22 @@ import inspect +import warnings +from contextlib import contextmanager -from sentry_sdk._types import TYPE_CHECKING -from sentry_sdk.hub import Hub -from sentry_sdk.scope import Scope -from sentry_sdk.tracing import NoOpSpan, Transaction +from sentry_sdk import tracing_utils, Client +from sentry_sdk._init_implementation import init +from sentry_sdk.consts import INSTRUMENTER +from sentry_sdk.scope import Scope, _ScopeManager, new_scope, isolation_scope +from sentry_sdk.tracing import NoOpSpan, Transaction, trace +from sentry_sdk.crons import monitor + +from typing import TYPE_CHECKING if TYPE_CHECKING: + from collections.abc import Mapping + from typing import Any from typing import Dict + from typing import Generator from typing import Optional from typing import overload from typing import Callable @@ -15,6 +24,9 @@ from typing import ContextManager from typing import Union + from typing_extensions import Unpack + + from sentry_sdk.client import BaseClient from sentry_sdk._types import ( Event, Hint, @@ -22,248 +34,492 @@ BreadcrumbHint, ExcInfo, MeasurementUnit, + LogLevelStr, + SamplingContext, ) - from sentry_sdk.tracing import Span + from sentry_sdk.tracing import Span, TransactionKwargs T = TypeVar("T") F = TypeVar("F", bound=Callable[..., Any]) else: - def overload(x): - # type: (T) -> T + def overload(x: "T") -> "T": return x # When changing this, update __all__ in __init__.py too __all__ = [ + "init", + "add_attachment", + "add_breadcrumb", "capture_event", - "capture_message", "capture_exception", - "add_breadcrumb", + "capture_message", "configure_scope", - "push_scope", + "continue_trace", "flush", + "get_baggage", + "get_client", + "get_global_scope", + "get_isolation_scope", + "get_current_scope", + "get_current_span", + "get_traceparent", + "is_initialized", + "isolation_scope", "last_event_id", - "start_span", - "start_transaction", - "set_tag", + "new_scope", + "push_scope", "set_context", "set_extra", - "set_user", "set_level", "set_measurement", - "get_current_span", - "get_traceparent", - "get_baggage", - "continue_trace", + "set_tag", + "set_tags", + "set_user", + "start_span", + "start_transaction", + "trace", + "monitor", + "start_session", + "end_session", + "set_transaction_name", + "update_current_span", ] -def hubmethod(f): - # type: (F) -> F +def scopemethod(f: "F") -> "F": f.__doc__ = "%s\n\n%s" % ( - "Alias for :py:meth:`sentry_sdk.Hub.%s`" % f.__name__, - inspect.getdoc(getattr(Hub, f.__name__)), + "Alias for :py:meth:`sentry_sdk.Scope.%s`" % f.__name__, + inspect.getdoc(getattr(Scope, f.__name__)), ) return f -def scopemethod(f): - # type: (F) -> F +def clientmethod(f: "F") -> "F": f.__doc__ = "%s\n\n%s" % ( - "Alias for :py:meth:`sentry_sdk.Scope.%s`" % f.__name__, - inspect.getdoc(getattr(Scope, f.__name__)), + "Alias for :py:meth:`sentry_sdk.Client.%s`" % f.__name__, + inspect.getdoc(getattr(Client, f.__name__)), ) return f -@hubmethod +@scopemethod +def get_client() -> "BaseClient": + return Scope.get_client() + + +def is_initialized() -> bool: + """ + .. versionadded:: 2.0.0 + + Returns whether Sentry has been initialized or not. + + If a client is available and the client is active + (meaning it is configured to send data) then + Sentry is initialized. + """ + return get_client().is_active() + + +@scopemethod +def get_global_scope() -> "Scope": + return Scope.get_global_scope() + + +@scopemethod +def get_isolation_scope() -> "Scope": + return Scope.get_isolation_scope() + + +@scopemethod +def get_current_scope() -> "Scope": + return Scope.get_current_scope() + + +@scopemethod +def last_event_id() -> "Optional[str]": + """ + See :py:meth:`sentry_sdk.Scope.last_event_id` documentation regarding + this method's limitations. + """ + return Scope.last_event_id() + + +@scopemethod def capture_event( - event, # type: Event - hint=None, # type: Optional[Hint] - scope=None, # type: Optional[Any] - **scope_args # type: Any -): - # type: (...) -> Optional[str] - return Hub.current.capture_event(event, hint, scope=scope, **scope_args) + event: "Event", + hint: "Optional[Hint]" = None, + scope: "Optional[Any]" = None, + **scope_kwargs: "Any", +) -> "Optional[str]": + return get_current_scope().capture_event(event, hint, scope=scope, **scope_kwargs) -@hubmethod +@scopemethod def capture_message( - message, # type: str - level=None, # type: Optional[str] - scope=None, # type: Optional[Any] - **scope_args # type: Any -): - # type: (...) -> Optional[str] - return Hub.current.capture_message(message, level, scope=scope, **scope_args) + message: str, + level: "Optional[LogLevelStr]" = None, + scope: "Optional[Any]" = None, + **scope_kwargs: "Any", +) -> "Optional[str]": + return get_current_scope().capture_message( + message, level, scope=scope, **scope_kwargs + ) -@hubmethod +@scopemethod def capture_exception( - error=None, # type: Optional[Union[BaseException, ExcInfo]] - scope=None, # type: Optional[Any] - **scope_args # type: Any -): - # type: (...) -> Optional[str] - return Hub.current.capture_exception(error, scope=scope, **scope_args) + error: "Optional[Union[BaseException, ExcInfo]]" = None, + scope: "Optional[Any]" = None, + **scope_kwargs: "Any", +) -> "Optional[str]": + return get_current_scope().capture_exception(error, scope=scope, **scope_kwargs) -@hubmethod +@scopemethod +def add_attachment( + bytes: "Union[None, bytes, Callable[[], bytes]]" = None, + filename: "Optional[str]" = None, + path: "Optional[str]" = None, + content_type: "Optional[str]" = None, + add_to_transactions: bool = False, +) -> None: + return get_isolation_scope().add_attachment( + bytes, filename, path, content_type, add_to_transactions + ) + + +@scopemethod def add_breadcrumb( - crumb=None, # type: Optional[Breadcrumb] - hint=None, # type: Optional[BreadcrumbHint] - **kwargs # type: Any -): - # type: (...) -> None - return Hub.current.add_breadcrumb(crumb, hint, **kwargs) + crumb: "Optional[Breadcrumb]" = None, + hint: "Optional[BreadcrumbHint]" = None, + **kwargs: "Any", +) -> None: + return get_isolation_scope().add_breadcrumb(crumb, hint, **kwargs) @overload -def configure_scope(): - # type: () -> ContextManager[Scope] +def configure_scope() -> "ContextManager[Scope]": pass @overload def configure_scope( # noqa: F811 - callback, # type: Callable[[Scope], None] -): - # type: (...) -> None + callback: "Callable[[Scope], None]", +) -> None: pass -@hubmethod def configure_scope( # noqa: F811 - callback=None, # type: Optional[Callable[[Scope], None]] -): - # type: (...) -> Optional[ContextManager[Scope]] - return Hub.current.configure_scope(callback) + callback: "Optional[Callable[[Scope], None]]" = None, +) -> "Optional[ContextManager[Scope]]": + """ + Reconfigures the scope. + + :param callback: If provided, call the callback with the current scope. + + :returns: If no callback is provided, returns a context manager that returns the scope. + """ + warnings.warn( + "sentry_sdk.configure_scope is deprecated and will be removed in the next major version. " + "Please consult our migration guide to learn how to migrate to the new API: " + "https://docs.sentry.io/platforms/python/migration/1.x-to-2.x#scope-configuring", + DeprecationWarning, + stacklevel=2, + ) + + scope = get_isolation_scope() + scope.generate_propagation_context() + + if callback is not None: + # TODO: used to return None when client is None. Check if this changes behavior. + callback(scope) + + return None + + @contextmanager + def inner() -> "Generator[Scope, None, None]": + yield scope + + return inner() @overload -def push_scope(): - # type: () -> ContextManager[Scope] +def push_scope() -> "ContextManager[Scope]": pass @overload def push_scope( # noqa: F811 - callback, # type: Callable[[Scope], None] -): - # type: (...) -> None + callback: "Callable[[Scope], None]", +) -> None: pass -@hubmethod def push_scope( # noqa: F811 - callback=None, # type: Optional[Callable[[Scope], None]] -): - # type: (...) -> Optional[ContextManager[Scope]] - return Hub.current.push_scope(callback) + callback: "Optional[Callable[[Scope], None]]" = None, +) -> "Optional[ContextManager[Scope]]": + """ + Pushes a new layer on the scope stack. + + :param callback: If provided, this method pushes a scope, calls + `callback`, and pops the scope again. + + :returns: If no `callback` is provided, a context manager that should + be used to pop the scope again. + """ + warnings.warn( + "sentry_sdk.push_scope is deprecated and will be removed in the next major version. " + "Please consult our migration guide to learn how to migrate to the new API: " + "https://docs.sentry.io/platforms/python/migration/1.x-to-2.x#scope-pushing", + DeprecationWarning, + stacklevel=2, + ) + + if callback is not None: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + with push_scope() as scope: + callback(scope) + return None + + return _ScopeManager() @scopemethod -def set_tag(key, value): - # type: (str, Any) -> None - return Hub.current.scope.set_tag(key, value) +def set_tag(key: str, value: "Any") -> None: + return get_isolation_scope().set_tag(key, value) @scopemethod -def set_context(key, value): - # type: (str, Dict[str, Any]) -> None - return Hub.current.scope.set_context(key, value) +def set_tags(tags: "Mapping[str, object]") -> None: + return get_isolation_scope().set_tags(tags) @scopemethod -def set_extra(key, value): - # type: (str, Any) -> None - return Hub.current.scope.set_extra(key, value) +def set_context(key: str, value: "Dict[str, Any]") -> None: + return get_isolation_scope().set_context(key, value) @scopemethod -def set_user(value): - # type: (Optional[Dict[str, Any]]) -> None - return Hub.current.scope.set_user(value) +def set_extra(key: str, value: "Any") -> None: + return get_isolation_scope().set_extra(key, value) @scopemethod -def set_level(value): - # type: (str) -> None - return Hub.current.scope.set_level(value) +def set_user(value: "Optional[Dict[str, Any]]") -> None: + return get_isolation_scope().set_user(value) -@hubmethod -def flush( - timeout=None, # type: Optional[float] - callback=None, # type: Optional[Callable[[int, float], None]] -): - # type: (...) -> None - return Hub.current.flush(timeout=timeout, callback=callback) +@scopemethod +def set_level(value: "LogLevelStr") -> None: + return get_isolation_scope().set_level(value) -@hubmethod -def last_event_id(): - # type: () -> Optional[str] - return Hub.current.last_event_id() +@clientmethod +def flush( + timeout: "Optional[float]" = None, + callback: "Optional[Callable[[int, float], None]]" = None, +) -> None: + return get_client().flush(timeout=timeout, callback=callback) -@hubmethod +@scopemethod def start_span( - span=None, # type: Optional[Span] - **kwargs # type: Any -): - # type: (...) -> Span - return Hub.current.start_span(span=span, **kwargs) + **kwargs: "Any", +) -> "Span": + return get_current_scope().start_span(**kwargs) -@hubmethod +@scopemethod def start_transaction( - transaction=None, # type: Optional[Transaction] - **kwargs # type: Any -): - # type: (...) -> Union[Transaction, NoOpSpan] - return Hub.current.start_transaction(transaction, **kwargs) + transaction: "Optional[Transaction]" = None, + instrumenter: str = INSTRUMENTER.SENTRY, + custom_sampling_context: "Optional[SamplingContext]" = None, + **kwargs: "Unpack[TransactionKwargs]", +) -> "Union[Transaction, NoOpSpan]": + """ + Start and return a transaction on the current scope. + + Start an existing transaction if given, otherwise create and start a new + transaction with kwargs. + + This is the entry point to manual tracing instrumentation. + + A tree structure can be built by adding child spans to the transaction, + and child spans to other spans. To start a new child span within the + transaction or any span, call the respective `.start_child()` method. + + Every child span must be finished before the transaction is finished, + otherwise the unfinished spans are discarded. + When used as context managers, spans and transactions are automatically + finished at the end of the `with` block. If not using context managers, + call the `.finish()` method. -def set_measurement(name, value, unit=""): - # type: (str, float, MeasurementUnit) -> None - transaction = Hub.current.scope.transaction + When the transaction is finished, it will be sent to Sentry with all its + finished child spans. + + :param transaction: The transaction to start. If omitted, we create and + start a new transaction. + :param instrumenter: This parameter is meant for internal use only. It + will be removed in the next major version. + :param custom_sampling_context: The transaction's custom sampling context. + :param kwargs: Optional keyword arguments to be passed to the Transaction + constructor. See :py:class:`sentry_sdk.tracing.Transaction` for + available arguments. + """ + return get_current_scope().start_transaction( + transaction, instrumenter, custom_sampling_context, **kwargs + ) + + +def set_measurement(name: str, value: float, unit: "MeasurementUnit" = "") -> None: + """ + .. deprecated:: 2.28.0 + This function is deprecated and will be removed in the next major release. + """ + transaction = get_current_scope().transaction if transaction is not None: transaction.set_measurement(name, value, unit) -def get_current_span(hub=None): - # type: (Optional[Hub]) -> Optional[Span] +def get_current_span(scope: "Optional[Scope]" = None) -> "Optional[Span]": """ Returns the currently active span if there is one running, otherwise `None` """ - if hub is None: - hub = Hub.current - - current_span = hub.scope.span - return current_span + return tracing_utils.get_current_span(scope) -def get_traceparent(): - # type: () -> Optional[str] +def get_traceparent() -> "Optional[str]": """ Returns the traceparent either from the active span or from the scope. """ - return Hub.current.get_traceparent() + return get_current_scope().get_traceparent() -def get_baggage(): - # type: () -> Optional[str] +def get_baggage() -> "Optional[str]": """ Returns Baggage either from the active span or from the scope. """ - return Hub.current.get_baggage() + baggage = get_current_scope().get_baggage() + if baggage is not None: + return baggage.serialize() + return None -def continue_trace(environ_or_headers, op=None, name=None, source=None): - # type: (Dict[str, Any], Optional[str], Optional[str], Optional[str]) -> Transaction + +def continue_trace( + environ_or_headers: "Dict[str, Any]", + op: "Optional[str]" = None, + name: "Optional[str]" = None, + source: "Optional[str]" = None, + origin: str = "manual", +) -> "Transaction": """ Sets the propagation context from environment or headers and returns a transaction. """ - return Hub.current.continue_trace(environ_or_headers, op, name, source) + return get_isolation_scope().continue_trace( + environ_or_headers, op, name, source, origin + ) + + +@scopemethod +def start_session( + session_mode: str = "application", +) -> None: + return get_isolation_scope().start_session(session_mode=session_mode) + + +@scopemethod +def end_session() -> None: + return get_isolation_scope().end_session() + + +@scopemethod +def set_transaction_name(name: str, source: "Optional[str]" = None) -> None: + return get_current_scope().set_transaction_name(name, source) + + +def update_current_span( + op: "Optional[str]" = None, + name: "Optional[str]" = None, + attributes: "Optional[dict[str, Union[str, int, float, bool]]]" = None, + data: "Optional[dict[str, Any]]" = None, +) -> None: + """ + Update the current active span with the provided parameters. + + This function allows you to modify properties of the currently active span. + If no span is currently active, this function will do nothing. + + :param op: The operation name for the span. This is a high-level description + of what the span represents (e.g., "http.client", "db.query"). + You can use predefined constants from :py:class:`sentry_sdk.consts.OP` + or provide your own string. If not provided, the span's operation will + remain unchanged. + :type op: str or None + + :param name: The human-readable name/description for the span. This provides + more specific details about what the span represents (e.g., "GET /api/users", + "SELECT * FROM users"). If not provided, the span's name will remain unchanged. + :type name: str or None + + :param data: A dictionary of key-value pairs to add as data to the span. This + data will be merged with any existing span data. If not provided, + no data will be added. + + .. deprecated:: 2.35.0 + Use ``attributes`` instead. The ``data`` parameter will be removed + in a future version. + :type data: dict[str, Union[str, int, float, bool]] or None + + :param attributes: A dictionary of key-value pairs to add as attributes to the span. + Attribute values must be strings, integers, floats, or booleans. These + attributes will be merged with any existing span data. If not provided, + no attributes will be added. + :type attributes: dict[str, Union[str, int, float, bool]] or None + + :returns: None + + .. versionadded:: 2.35.0 + + Example:: + + import sentry_sdk + from sentry_sdk.consts import OP + + sentry_sdk.update_current_span( + op=OP.FUNCTION, + name="process_user_data", + attributes={"user_id": 123, "batch_size": 50} + ) + """ + current_span = get_current_span() + + if current_span is None: + return + + if op is not None: + current_span.op = op + + if name is not None: + # internally it is still description + current_span.description = name + + if data is not None and attributes is not None: + raise ValueError( + "Cannot provide both `data` and `attributes`. Please use only `attributes`." + ) + + if data is not None: + warnings.warn( + "The `data` parameter is deprecated. Please use `attributes` instead.", + DeprecationWarning, + stacklevel=2, + ) + attributes = data + + if attributes is not None: + current_span.update_data(attributes) diff --git a/sentry_sdk/attachments.py b/sentry_sdk/attachments.py index c15afd447b..8ad85f4335 100644 --- a/sentry_sdk/attachments.py +++ b/sentry_sdk/attachments.py @@ -1,23 +1,42 @@ import os import mimetypes -from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.envelope import Item, PayloadRef +from typing import TYPE_CHECKING + if TYPE_CHECKING: from typing import Optional, Union, Callable -class Attachment(object): +class Attachment: + """Additional files/data to send along with an event. + + This class stores attachments that can be sent along with an event. Attachments are files or other data, e.g. + config or log files, that are relevant to an event. Attachments are set on the ``Scope``, and are sent along with + all non-transaction events (or all events including transactions if ``add_to_transactions`` is ``True``) that are + captured within the ``Scope``. + + To add an attachment to a ``Scope``, use :py:meth:`sentry_sdk.Scope.add_attachment`. The parameters for + ``add_attachment`` are the same as the parameters for this class's constructor. + + :param bytes: Raw bytes of the attachment, or a function that returns the raw bytes. Must be provided unless + ``path`` is provided. + :param filename: The filename of the attachment. Must be provided unless ``path`` is provided. + :param path: Path to a file to attach. Must be provided unless ``bytes`` is provided. + :param content_type: The content type of the attachment. If not provided, it will be guessed from the ``filename`` + parameter, if available, or the ``path`` parameter if ``filename`` is ``None``. + :param add_to_transactions: Whether to add this attachment to transactions. Defaults to ``False``. + """ + def __init__( self, - bytes=None, # type: Union[None, bytes, Callable[[], bytes]] - filename=None, # type: Optional[str] - path=None, # type: Optional[str] - content_type=None, # type: Optional[str] - add_to_transactions=False, # type: bool - ): - # type: (...) -> None + bytes: "Union[None, bytes, Callable[[], bytes]]" = None, + filename: "Optional[str]" = None, + path: "Optional[str]" = None, + content_type: "Optional[str]" = None, + add_to_transactions: bool = False, + ) -> None: if bytes is None and path is None: raise TypeError("path or raw bytes required for attachment") if filename is None and path is not None: @@ -32,10 +51,9 @@ def __init__( self.content_type = content_type self.add_to_transactions = add_to_transactions - def to_envelope_item(self): - # type: () -> Item + def to_envelope_item(self) -> "Item": """Returns an envelope item for this attachment.""" - payload = None # type: Union[None, PayloadRef, bytes] + payload: "Union[None, PayloadRef, bytes]" = None if self.bytes is not None: if callable(self.bytes): payload = self.bytes() @@ -50,6 +68,5 @@ def to_envelope_item(self): filename=self.filename, ) - def __repr__(self): - # type: () -> str + def __repr__(self) -> str: return "" % (self.filename,) diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index e8d7fd3bbc..259196d1c6 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -1,25 +1,39 @@ -from importlib import import_module import os import uuid import random import socket +from collections.abc import Mapping +from datetime import datetime, timezone +from importlib import import_module +from typing import TYPE_CHECKING, List, Dict, cast, overload +import warnings -from sentry_sdk._compat import datetime_utcnow, string_types, text_type, iteritems +import sentry_sdk +from sentry_sdk._compat import PY37, check_uwsgi_thread_support +from sentry_sdk._metrics_batcher import MetricsBatcher from sentry_sdk.utils import ( + AnnotatedValue, + ContextVar, capture_internal_exceptions, current_stacktrace, - disable_capture_event, + env_to_bool, format_timestamp, get_sdk_name, get_type_name, get_default_release, handle_in_app, + is_gevent, logger, + get_before_send_log, + get_before_send_metric, + has_logs_enabled, + has_metrics_enabled, ) from sentry_sdk.serializer import serialize -from sentry_sdk.tracing import trace, has_tracing_enabled -from sentry_sdk.transport import make_transport +from sentry_sdk.tracing import trace +from sentry_sdk.transport import BaseHttpTransport, make_transport from sentry_sdk.consts import ( + SPANDATA, DEFAULT_MAX_VALUE_LENGTH, DEFAULT_OPTIONS, INSTRUMENTER, @@ -27,41 +41,52 @@ ClientConstructor, ) from sentry_sdk.integrations import _DEFAULT_INTEGRATIONS, setup_integrations -from sentry_sdk.utils import ContextVar +from sentry_sdk.integrations.dedupe import DedupeIntegration from sentry_sdk.sessions import SessionFlusher from sentry_sdk.envelope import Envelope -from sentry_sdk.profiler import has_profiling_enabled, setup_profiler +from sentry_sdk.profiler.continuous_profiler import setup_continuous_profiler +from sentry_sdk.profiler.transaction_profiler import ( + has_profiling_enabled, + Profile, + setup_profiler, +) from sentry_sdk.scrubber import EventScrubber from sentry_sdk.monitor import Monitor -from sentry_sdk._types import TYPE_CHECKING - if TYPE_CHECKING: from typing import Any from typing import Callable - from typing import Dict from typing import Optional from typing import Sequence + from typing import Type + from typing import Union + from typing import TypeVar + from sentry_sdk._types import Event, Hint, SDKInfo, Log, Metric, EventDataCategory + from sentry_sdk.integrations import Integration from sentry_sdk.scope import Scope - from sentry_sdk._types import Event, Hint from sentry_sdk.session import Session + from sentry_sdk.spotlight import SpotlightClient + from sentry_sdk.transport import Transport, Item + from sentry_sdk._log_batcher import LogBatcher + from sentry_sdk._metrics_batcher import MetricsBatcher + from sentry_sdk.utils import Dsn + I = TypeVar("I", bound=Integration) # noqa: E741 _client_init_debug = ContextVar("client_init_debug") -SDK_INFO = { +SDK_INFO: "SDKInfo" = { "name": "sentry.python", # SDK name will be overridden after integrations have been loaded with sentry_sdk.integrations.setup_integrations() "version": VERSION, "packages": [{"name": "pypi:sentry-sdk", "version": VERSION}], } -def _get_options(*args, **kwargs): - # type: (*Optional[str], **Any) -> Dict[str, Any] - if args and (isinstance(args[0], (text_type, bytes, str)) or args[0] is None): - dsn = args[0] # type: Optional[str] +def _get_options(*args: "Optional[str]", **kwargs: "Any") -> "Dict[str, Any]": + if args and (isinstance(args[0], (bytes, str)) or args[0] is None): + dsn: "Optional[str]" = args[0] args = args[1:] else: dsn = None @@ -74,28 +99,8 @@ def _get_options(*args, **kwargs): if dsn is not None and options.get("dsn") is None: options["dsn"] = dsn - for key, value in iteritems(options): + for key, value in options.items(): if key not in rv: - # Option "with_locals" was renamed to "include_local_variables" - if key == "with_locals": - msg = ( - "Deprecated: The option 'with_locals' was renamed to 'include_local_variables'. " - "Please use 'include_local_variables'. The option 'with_locals' will be removed in the future." - ) - logger.warning(msg) - rv["include_local_variables"] = value - continue - - # Option "request_bodies" was renamed to "max_request_body_size" - if key == "request_bodies": - msg = ( - "Deprecated: The option 'request_bodies' was renamed to 'max_request_body_size'. " - "Please use 'max_request_body_size'. The option 'request_bodies' will be removed in the future." - ) - logger.warning(msg) - rv["max_request_body_size"] = value - continue - raise TypeError("Unknown option %r" % (key,)) rv[key] = value @@ -109,6 +114,9 @@ def _get_options(*args, **kwargs): if rv["environment"] is None: rv["environment"] = os.environ.get("SENTRY_ENVIRONMENT") or "production" + if rv["debug"] is None: + rv["debug"] = env_to_bool(os.environ.get("SENTRY_DEBUG"), strict=True) or False + if rv["server_name"] is None and hasattr(socket, "gethostname"): rv["server_name"] = socket.gethostname() @@ -127,7 +135,29 @@ def _get_options(*args, **kwargs): rv["traces_sample_rate"] = 1.0 if rv["event_scrubber"] is None: - rv["event_scrubber"] = EventScrubber() + rv["event_scrubber"] = EventScrubber( + send_default_pii=( + False if rv["send_default_pii"] is None else rv["send_default_pii"] + ) + ) + + if rv["socket_options"] and not isinstance(rv["socket_options"], list): + logger.warning( + "Ignoring socket_options because of unexpected format. See urllib3.HTTPConnection.socket_options for the expected format." + ) + rv["socket_options"] = None + + if rv["keep_alive"] is None: + rv["keep_alive"] = ( + env_to_bool(os.environ.get("SENTRY_KEEP_ALIVE"), strict=True) or False + ) + + if rv["enable_tracing"] is not None: + warnings.warn( + "The `enable_tracing` parameter is deprecated. Please use `traces_sample_rate` instead.", + DeprecationWarning, + stacklevel=2, + ) return rv @@ -140,30 +170,122 @@ def _get_options(*args, **kwargs): module_not_found_error = ImportError # type: ignore -class _Client(object): - """The client is internally responsible for capturing the events and +class BaseClient: + """ + .. versionadded:: 2.0.0 + + The basic definition of a client that is used for sending data to Sentry. + """ + + spotlight: "Optional[SpotlightClient]" = None + + def __init__(self, options: "Optional[Dict[str, Any]]" = None) -> None: + self.options: "Dict[str, Any]" = ( + options if options is not None else DEFAULT_OPTIONS + ) + + self.transport: "Optional[Transport]" = None + self.monitor: "Optional[Monitor]" = None + self.log_batcher: "Optional[LogBatcher]" = None + self.metrics_batcher: "Optional[MetricsBatcher]" = None + + def __getstate__(self, *args: "Any", **kwargs: "Any") -> "Any": + return {"options": {}} + + def __setstate__(self, *args: "Any", **kwargs: "Any") -> None: + pass + + @property + def dsn(self) -> "Optional[str]": + return None + + @property + def parsed_dsn(self) -> "Optional[Dsn]": + return None + + def should_send_default_pii(self) -> bool: + return False + + def is_active(self) -> bool: + """ + .. versionadded:: 2.0.0 + + Returns whether the client is active (able to send data to Sentry) + """ + return False + + def capture_event(self, *args: "Any", **kwargs: "Any") -> "Optional[str]": + return None + + def _capture_log(self, log: "Log", scope: "Scope") -> None: + pass + + def _capture_metric(self, metric: "Metric", scope: "Scope") -> None: + pass + + def capture_session(self, *args: "Any", **kwargs: "Any") -> None: + return None + + if TYPE_CHECKING: + + @overload + def get_integration(self, name_or_class: str) -> "Optional[Integration]": ... + + @overload + def get_integration(self, name_or_class: "type[I]") -> "Optional[I]": ... + + def get_integration( + self, name_or_class: "Union[str, type[Integration]]" + ) -> "Optional[Integration]": + return None + + def close(self, *args: "Any", **kwargs: "Any") -> None: + return None + + def flush(self, *args: "Any", **kwargs: "Any") -> None: + return None + + def __enter__(self) -> "BaseClient": + return self + + def __exit__(self, exc_type: "Any", exc_value: "Any", tb: "Any") -> None: + return None + + +class NonRecordingClient(BaseClient): + """ + .. versionadded:: 2.0.0 + + A client that does not send any events to Sentry. This is used as a fallback when the Sentry SDK is not yet initialized. + """ + + pass + + +class _Client(BaseClient): + """ + The client is internally responsible for capturing the events and forwarding them to sentry through the configured transport. It takes the client options as keyword arguments and optionally the DSN as first argument. - """ - def __init__(self, *args, **kwargs): - # type: (*Any, **Any) -> None - self.options = get_options(*args, **kwargs) # type: Dict[str, Any] + Alias of :py:class:`sentry_sdk.Client`. (Was created for better intelisense support) + """ + def __init__(self, *args: "Any", **kwargs: "Any") -> None: + super(_Client, self).__init__(options=get_options(*args, **kwargs)) self._init_impl() - def __getstate__(self): - # type: () -> Any + def __getstate__(self) -> "Any": return {"options": self.options} - def __setstate__(self, state): - # type: (Any) -> None + def __setstate__(self, state: "Any") -> None: self.options = state["options"] self._init_impl() - def _setup_instrumentation(self, functions_to_trace): - # type: (Sequence[Dict[str, str]]) -> None + def _setup_instrumentation( + self, functions_to_trace: "Sequence[Dict[str, str]]" + ) -> None: """ Instruments the functions given in the list `functions_to_trace` with the `@sentry_sdk.tracing.trace` decorator. """ @@ -180,7 +302,6 @@ def _setup_instrumentation(self, functions_to_trace): function_obj = getattr(module_obj, function_name) setattr(module_obj, function_name, trace(function_obj)) logger.debug("Enabled tracing for %s", function_qualname) - except module_not_found_error: try: # Try to import a class @@ -190,7 +311,13 @@ def _setup_instrumentation(self, functions_to_trace): module_obj = import_module(module_name) class_obj = getattr(module_obj, class_name) function_obj = getattr(class_obj, function_name) - setattr(class_obj, function_name, trace(function_obj)) + function_type = type(class_obj.__dict__[function_name]) + traced_function = trace(function_obj) + + if function_type in (staticmethod, classmethod): + traced_function = staticmethod(traced_function) + + setattr(class_obj, function_name, traced_function) setattr(module_obj, class_name, class_obj) logger.debug("Enabled tracing for %s", function_qualname) @@ -208,15 +335,29 @@ def _setup_instrumentation(self, functions_to_trace): e, ) - def _init_impl(self): - # type: () -> None + def _init_impl(self) -> None: old_debug = _client_init_debug.get(False) - def _capture_envelope(envelope): - # type: (Envelope) -> None + def _capture_envelope(envelope: "Envelope") -> None: + if self.spotlight is not None: + self.spotlight.capture_envelope(envelope) if self.transport is not None: self.transport.capture_envelope(envelope) + def _record_lost_event( + reason: str, + data_category: "EventDataCategory", + item: "Optional[Item]" = None, + quantity: int = 1, + ) -> None: + if self.transport is not None: + self.transport.record_lost_event( + reason=reason, + data_category=data_category, + item=item, + quantity=quantity, + ) + try: _client_init_debug.set(self.options["debug"]) self.transport = make_transport(self.options) @@ -226,14 +367,35 @@ def _capture_envelope(envelope): if self.options["enable_backpressure_handling"]: self.monitor = Monitor(self.transport) + # Setup Spotlight before creating batchers so _capture_envelope can use it. + # setup_spotlight handles all config/env var resolution per the SDK spec. + from sentry_sdk.spotlight import setup_spotlight + + self.spotlight = setup_spotlight(self.options) + if self.spotlight is not None and not self.options["dsn"]: + sample_all = lambda *_args, **_kwargs: 1.0 + self.options["send_default_pii"] = True + self.options["error_sampler"] = sample_all + self.options["traces_sampler"] = sample_all + self.options["profiles_sampler"] = sample_all + self.session_flusher = SessionFlusher(capture_func=_capture_envelope) - self.metrics_aggregator = None # type: Optional[MetricsAggregator] - if self.options.get("_experiments", {}).get("enable_metrics"): - from sentry_sdk.metrics import MetricsAggregator + self.log_batcher = None - self.metrics_aggregator = MetricsAggregator( - capture_func=_capture_envelope + if has_logs_enabled(self.options): + from sentry_sdk._log_batcher import LogBatcher + + self.log_batcher = LogBatcher( + capture_func=_capture_envelope, + record_lost_func=_record_lost_event, + ) + + self.metrics_batcher = None + if has_metrics_enabled(self.options): + self.metrics_batcher = MetricsBatcher( + capture_func=_capture_envelope, + record_lost_func=_record_lost_event, ) max_request_body_size = ("always", "never", "small", "medium") @@ -249,9 +411,13 @@ def _capture_envelope(envelope): "[OTel] Enabling experimental OTel-powered performance monitoring." ) self.options["instrumenter"] = INSTRUMENTER.OTEL - _DEFAULT_INTEGRATIONS.append( - "sentry_sdk.integrations.opentelemetry.integration.OpenTelemetryIntegration", - ) + if ( + "sentry_sdk.integrations.opentelemetry.integration.OpenTelemetryIntegration" + not in _DEFAULT_INTEGRATIONS + ): + _DEFAULT_INTEGRATIONS.append( + "sentry_sdk.integrations.opentelemetry.integration.OpenTelemetryIntegration", + ) self.integrations = setup_integrations( self.options["integrations"], @@ -259,6 +425,8 @@ def _capture_envelope(envelope): with_auto_enabling_integrations=self.options[ "auto_enabling_integrations" ], + disabled_integrations=self.options["disabled_integrations"], + options=self.options, ) sdk_name = get_sdk_name(list(self.integrations.keys())) @@ -270,31 +438,73 @@ def _capture_envelope(envelope): setup_profiler(self.options) except Exception as e: logger.debug("Can not set up profiler. (%s)", e) + else: + try: + setup_continuous_profiler( + self.options, + sdk_info=SDK_INFO, + capture_func=_capture_envelope, + ) + except Exception as e: + logger.debug("Can not set up continuous profiler. (%s)", e) finally: _client_init_debug.set(old_debug) self._setup_instrumentation(self.options.get("functions_to_trace", [])) + if ( + self.monitor + or self.log_batcher + or has_profiling_enabled(self.options) + 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. + check_uwsgi_thread_support() + + def is_active(self) -> bool: + """ + .. versionadded:: 2.0.0 + + Returns whether the client is active (able to send data to Sentry) + """ + return True + + def should_send_default_pii(self) -> bool: + """ + .. versionadded:: 2.0.0 + + Returns whether the client should send default PII (Personally Identifiable Information) data to Sentry. + """ + return self.options.get("send_default_pii") or False + @property - def dsn(self): - # type: () -> Optional[str] + def dsn(self) -> "Optional[str]": """Returns the configured DSN as string.""" return self.options["dsn"] + @property + def parsed_dsn(self) -> "Optional[Dsn]": + """Returns the configured parsed DSN object.""" + return self.transport.parsed_dsn if self.transport else None + def _prepare_event( self, - event, # type: Event - hint, # type: Hint - scope, # type: Optional[Scope] - ): - # type: (...) -> Optional[Event] + event: "Event", + hint: "Hint", + scope: "Optional[Scope]", + ) -> "Optional[Event]": + previous_total_spans: "Optional[int]" = None + previous_total_breadcrumbs: "Optional[int]" = None if event.get("timestamp") is None: - event["timestamp"] = datetime_utcnow() + event["timestamp"] = datetime.now(timezone.utc) + + is_transaction = event.get("type") == "transaction" if scope is not None: - is_transaction = event.get("type") == "transaction" + spans_before = len(cast(List[Dict[str, object]], event.get("spans", []))) event_ = scope.apply_to_event(event, hint, self.options) # one of the event/error processors returned None @@ -304,12 +514,40 @@ def _prepare_event( "event_processor", data_category=("transaction" if is_transaction else "error"), ) + if is_transaction: + self.transport.record_lost_event( + "event_processor", + data_category="span", + quantity=spans_before + 1, # +1 for the transaction itself + ) return None event = event_ + spans_delta = spans_before - len( + cast(List[Dict[str, object]], event.get("spans", [])) + ) + if is_transaction and spans_delta > 0 and self.transport is not None: + self.transport.record_lost_event( + "event_processor", data_category="span", quantity=spans_delta + ) + + dropped_spans: int = event.pop("_dropped_spans", 0) + spans_delta + if dropped_spans > 0: + previous_total_spans = spans_before + dropped_spans + if scope._n_breadcrumbs_truncated > 0: + breadcrumbs = event.get("breadcrumbs", {}) + values = ( + breadcrumbs.get("values", []) + if not isinstance(breadcrumbs, AnnotatedValue) + else [] + ) + previous_total_breadcrumbs = ( + len(values) + scope._n_breadcrumbs_truncated + ) if ( - self.options["attach_stacktrace"] + not is_transaction + and self.options["attach_stacktrace"] and "exception" not in event and "stacktrace" not in event and "threads" not in event @@ -334,7 +572,7 @@ def _prepare_event( for key in "release", "environment", "server_name", "dist": if event.get(key) is None and self.options[key] is not None: - event[key] = text_type(self.options[key]).strip() + event[key] = str(self.options[key]).strip() if event.get("sdk") is None: sdk_info = dict(SDK_INFO) sdk_info["integrations"] = sorted(self.integrations.keys()) @@ -352,16 +590,45 @@ def _prepare_event( if event is not None: event_scrubber = self.options["event_scrubber"] - if event_scrubber and not self.options["send_default_pii"]: + if event_scrubber: event_scrubber.scrub_event(event) + if scope is not None and scope._gen_ai_original_message_count: + spans: "List[Dict[str, Any]] | AnnotatedValue" = event.get("spans", []) + if isinstance(spans, list): + for span in spans: + span_id = span.get("span_id", None) + span_data = span.get("data", {}) + if ( + span_id + and span_id in scope._gen_ai_original_message_count + and SPANDATA.GEN_AI_REQUEST_MESSAGES in span_data + ): + span_data[SPANDATA.GEN_AI_REQUEST_MESSAGES] = AnnotatedValue( + span_data[SPANDATA.GEN_AI_REQUEST_MESSAGES], + {"len": scope._gen_ai_original_message_count[span_id]}, + ) + if previous_total_spans is not None: + event["spans"] = AnnotatedValue( + event.get("spans", []), {"len": previous_total_spans} + ) + if previous_total_breadcrumbs is not None: + event["breadcrumbs"] = AnnotatedValue( + event.get("breadcrumbs", {"values": []}), + {"len": previous_total_breadcrumbs}, + ) + # Postprocess the event here so that annotated types do # generally not surface in before_send if event is not None: - event = serialize( - event, - max_request_body_size=self.options.get("max_request_body_size"), - max_value_length=self.options.get("max_value_length"), + event = cast( + "Event", + serialize( + cast("Dict[str, Any]", event), + max_request_body_size=self.options.get("max_request_body_size"), + max_value_length=self.options.get("max_value_length"), + custom_repr=self.options.get("custom_repr"), + ), ) before_send = self.options["before_send"] @@ -379,7 +646,15 @@ def _prepare_event( self.transport.record_lost_event( "before_send", data_category="error" ) - event = new_event # type: ignore + + # If this is an exception, reset the DedupeIntegration. It still + # remembers the dropped exception as the last exception, meaning + # that if the same exception happens again and is not dropped + # in before_send, it'd get dropped by DedupeIntegration. + if event.get("exception"): + DedupeIntegration.reset_last_seen() + + event = new_event before_send_transaction = self.options["before_send_transaction"] if ( @@ -388,20 +663,32 @@ def _prepare_event( and event.get("type") == "transaction" ): new_event = None + spans_before = len(cast(List[Dict[str, object]], event.get("spans", []))) with capture_internal_exceptions(): new_event = before_send_transaction(event, hint or {}) if new_event is None: logger.info("before send transaction dropped event") if self.transport: self.transport.record_lost_event( - "before_send", data_category="transaction" + reason="before_send", data_category="transaction" + ) + self.transport.record_lost_event( + reason="before_send", + data_category="span", + quantity=spans_before + 1, # +1 for the transaction itself + ) + else: + spans_delta = spans_before - len(new_event.get("spans", [])) + if spans_delta > 0 and self.transport is not None: + self.transport.record_lost_event( + reason="before_send", data_category="span", quantity=spans_delta ) - event = new_event # type: ignore + + event = new_event return event - def _is_ignored_error(self, event, hint): - # type: (Event, Hint) -> bool + def _is_ignored_error(self, event: "Event", hint: "Hint") -> bool: exc_info = hint.get("exc_info") if exc_info is None: return False @@ -413,7 +700,7 @@ def _is_ignored_error(self, event, hint): for ignored_error in self.options["ignore_errors"]: # String types are matched against the type name in the # exception only - if isinstance(ignored_error, string_types): + if isinstance(ignored_error, str): if ignored_error == error_full_name or ignored_error == error_type_name: return True else: @@ -424,11 +711,10 @@ def _is_ignored_error(self, event, hint): def _should_capture( self, - event, # type: Event - hint, # type: Hint - scope=None, # type: Optional[Scope] - ): - # type: (...) -> bool + event: "Event", + hint: "Hint", + scope: "Optional[Scope]" = None, + ) -> bool: # Transactions are sampled independent of error events. is_transaction = event.get("type") == "transaction" if is_transaction: @@ -446,13 +732,42 @@ def _should_capture( def _should_sample_error( self, - event, # type: Event - ): - # type: (...) -> bool - not_in_sample_rate = ( - self.options["sample_rate"] < 1.0 - and random.random() >= self.options["sample_rate"] - ) + event: "Event", + hint: "Hint", + ) -> bool: + error_sampler = self.options.get("error_sampler", None) + + if callable(error_sampler): + with capture_internal_exceptions(): + sample_rate = error_sampler(event, hint) + else: + sample_rate = self.options["sample_rate"] + + try: + not_in_sample_rate = sample_rate < 1.0 and random.random() >= sample_rate + except NameError: + logger.warning( + "The provided error_sampler raised an error. Defaulting to sampling the event." + ) + + # If the error_sampler raised an error, we should sample the event, since the default behavior + # (when no sample_rate or error_sampler is provided) is to sample all events. + not_in_sample_rate = False + except TypeError: + parameter, verb = ( + ("error_sampler", "returned") + if callable(error_sampler) + else ("sample_rate", "contains") + ) + logger.warning( + "The provided %s %s an invalid value of %s. The value should be a float or a bool. Defaulting to sampling the event." + % (parameter, verb, repr(sample_rate)) + ) + + # If the sample_rate has an invalid value, we should sample the event, since the default behavior + # (when no sample_rate or error_sampler is provided) is to sample all events. + not_in_sample_rate = False + if not_in_sample_rate: # because we will not sample this event, record a "lost event". if self.transport: @@ -464,11 +779,9 @@ def _should_sample_error( def _update_session_from_event( self, - session, # type: Session - event, # type: Event - ): - # type: (...) -> None - + session: "Session", + event: "Event", + ) -> None: crashed = False errored = False user_agent = None @@ -477,8 +790,10 @@ def _update_session_from_event( if exceptions: errored = True for error in exceptions: + if isinstance(error, AnnotatedValue): + error = error.value or {} mechanism = error.get("mechanism") - if mechanism and mechanism.get("handled") is False: + if isinstance(mechanism, Mapping) and mechanism.get("handled") is False: crashed = True break @@ -486,7 +801,8 @@ def _update_session_from_event( if session.user_agent is None: headers = (event.get("request") or {}).get("headers") - for k, v in iteritems(headers or {}): + headers_dict = headers if isinstance(headers, dict) else {} + for k, v in headers_dict.items(): if k.lower() == "user-agent": user_agent = v break @@ -500,39 +816,30 @@ def _update_session_from_event( def capture_event( self, - event, # type: Event - hint=None, # type: Optional[Hint] - scope=None, # type: Optional[Scope] - ): - # type: (...) -> Optional[str] + event: "Event", + hint: "Optional[Hint]" = None, + scope: "Optional[Scope]" = None, + ) -> "Optional[str]": """Captures an event. :param event: A ready-made event that can be directly sent to Sentry. :param hint: Contains metadata about the event that can be read from `before_send`, such as the original exception object or a HTTP request object. - :param scope: An optional scope to use for determining whether this event - should be captured. + :param scope: An optional :py:class:`sentry_sdk.Scope` to apply to events. :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 disable_capture_event.get(False): - return None + hint: "Hint" = dict(hint or ()) - if self.transport is None: - return None - 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 @@ -549,67 +856,122 @@ def capture_event( if ( not is_transaction and not is_checkin - and not self._should_sample_error(event) + and not self._should_sample_error(event, hint) ): return None - tracing_enabled = has_tracing_enabled(self.options) attachments = hint.get("attachments") trace_context = event_opt.get("contexts", {}).get("trace") or {} dynamic_sampling_context = trace_context.pop("dynamic_sampling_context", {}) - # If tracing is enabled all events should go to /envelope endpoint. - # If no tracing is enabled only transactions, events with attachments, and checkins should go to the /envelope endpoint. - should_use_envelope_endpoint = ( - tracing_enabled or is_transaction or is_checkin or bool(attachments) - ) - if should_use_envelope_endpoint: - headers = { - "event_id": event_opt["event_id"], - "sent_at": format_timestamp(datetime_utcnow()), - } - - if dynamic_sampling_context: - headers["trace"] = dynamic_sampling_context - - envelope = Envelope(headers=headers) - - if is_transaction: - if profile is not None: - envelope.add_profile(profile.to_json(event_opt, self.options)) - envelope.add_transaction(event_opt) - elif is_checkin: - envelope.add_checkin(event_opt) - else: - envelope.add_event(event_opt) + headers: "dict[str, object]" = { + "event_id": event_opt["event_id"], + "sent_at": format_timestamp(datetime.now(timezone.utc)), + } - for attachment in attachments or (): - envelope.add_item(attachment.to_envelope_item()) + if dynamic_sampling_context: + headers["trace"] = dynamic_sampling_context - self.transport.capture_envelope(envelope) + envelope = Envelope(headers=headers) + if is_transaction: + if isinstance(profile, Profile): + envelope.add_profile(profile.to_json(event_opt, self.options)) + envelope.add_transaction(event_opt) + elif is_checkin: + envelope.add_checkin(event_opt) else: - # All other events go to the legacy /store/ endpoint (will be removed in the future). - self.transport.capture_event(event_opt) + envelope.add_event(event_opt) + + 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 not None: + self.transport.capture_envelope(envelope) + return_value = event_id + + return return_value + + def _capture_telemetry( + self, telemetry: "Optional[Union[Log, Metric]]", ty: str, scope: "Scope" + ) -> None: + # Capture attributes-based telemetry (logs, metrics, spansV2) + if telemetry is None: + return + + scope.apply_to_telemetry(telemetry) + + before_send = None + if ty == "log": + before_send = get_before_send_log(self.options) + elif ty == "metric": + before_send = get_before_send_metric(self.options) # type: ignore + + if before_send is not None: + telemetry = before_send(telemetry, {}) # type: ignore + + if telemetry is None: + return + + batcher = None + if ty == "log": + batcher = self.log_batcher + elif ty == "metric": + batcher = self.metrics_batcher # type: ignore + + if batcher is not None: + batcher.add(telemetry) # type: ignore - return event_id + def _capture_log(self, log: "Optional[Log]", scope: "Scope") -> None: + self._capture_telemetry(log, "log", scope) + + def _capture_metric(self, metric: "Optional[Metric]", scope: "Scope") -> None: + self._capture_telemetry(metric, "metric", scope) def capture_session( - self, session # type: Session - ): - # type: (...) -> None + self, + session: "Session", + ) -> None: if not session.release: logger.info("Discarded session update because of missing release") else: self.session_flusher.add_session(session) + if TYPE_CHECKING: + + @overload + def get_integration(self, name_or_class: str) -> "Optional[Integration]": ... + + @overload + def get_integration(self, name_or_class: "type[I]") -> "Optional[I]": ... + + def get_integration( + self, + name_or_class: "Union[str, Type[Integration]]", + ) -> "Optional[Integration]": + """Returns the integration for this client by name or class. + If the client does not have that integration then `None` is returned. + """ + if isinstance(name_or_class, str): + integration_name = name_or_class + elif name_or_class.identifier is not None: + integration_name = name_or_class.identifier + else: + raise ValueError("Integration has no name") + + return self.integrations.get(integration_name) + def close( self, - timeout=None, # type: Optional[float] - callback=None, # type: Optional[Callable[[int, float], None]] - ): - # type: (...) -> None + timeout: "Optional[float]" = None, + callback: "Optional[Callable[[int, float], None]]" = None, + ) -> None: """ Close the client and shut down the transport. Arguments have the same semantics as :py:meth:`Client.flush`. @@ -617,8 +979,10 @@ def close( if self.transport is not None: self.flush(timeout=timeout, callback=callback) self.session_flusher.kill() - if self.metrics_aggregator is not None: - self.metrics_aggregator.kill() + if self.log_batcher is not None: + self.log_batcher.kill() + if self.metrics_batcher is not None: + self.metrics_batcher.kill() if self.monitor: self.monitor.kill() self.transport.kill() @@ -626,10 +990,9 @@ def close( def flush( self, - timeout=None, # type: Optional[float] - callback=None, # type: Optional[Callable[[int, float], None]] - ): - # type: (...) -> None + timeout: "Optional[float]" = None, + callback: "Optional[Callable[[int, float], None]]" = None, + ) -> None: """ Wait for the current events to be sent. @@ -641,20 +1004,20 @@ def flush( if timeout is None: timeout = self.options["shutdown_timeout"] self.session_flusher.flush() - if self.metrics_aggregator is not None: - self.metrics_aggregator.flush() + if self.log_batcher is not None: + self.log_batcher.flush() + if self.metrics_batcher is not None: + self.metrics_batcher.flush() self.transport.flush(timeout=timeout, callback=callback) - def __enter__(self): - # type: () -> _Client + def __enter__(self) -> "_Client": return self - def __exit__(self, exc_type, exc_value, tb): - # type: (Any, Any, Any) -> None + def __exit__(self, exc_type: "Any", exc_value: "Any", tb: "Any") -> None: self.close() -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: # Make mypy, PyCharm and other static analyzers think `get_options` is a diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index e1e6abe8f8..78c3a2912f 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -1,31 +1,63 @@ -from sentry_sdk._types import TYPE_CHECKING +import itertools +from enum import Enum +from typing import TYPE_CHECKING # up top to prevent circular import due to integration import -DEFAULT_MAX_VALUE_LENGTH = 1024 +# This is more or less an arbitrary large-ish value for now, so that we allow +# pretty long strings (like LLM prompts), but still have *some* upper limit +# until we verify that removing the trimming completely is safe. +DEFAULT_MAX_VALUE_LENGTH = 100_000 + +DEFAULT_MAX_STACK_FRAMES = 100 +DEFAULT_ADD_FULL_STACK = False + + +# Also needs to be at the top to prevent circular import +class EndpointType(Enum): + """ + The type of an endpoint. This is an enum, rather than a constant, for historical reasons + (the old /store endpoint). The enum also preserve future compatibility, in case we ever + have a new endpoint. + """ + + ENVELOPE = "envelope" + OTLP_TRACES = "integration/otlp/v1/traces" + + +class CompressionAlgo(Enum): + GZIP = "gzip" + BROTLI = "br" -if TYPE_CHECKING: - import sentry_sdk - from typing import Optional - from typing import Callable - from typing import Union - from typing import List - from typing import Type - from typing import Dict - from typing import Any - from typing import Sequence - from typing_extensions import TypedDict +if TYPE_CHECKING: + from typing import ( + AbstractSet, + Any, + Callable, + Dict, + List, + Optional, + Sequence, + Tuple, + Type, + Union, + ) - from sentry_sdk.integrations import Integration + from typing_extensions import Literal, TypedDict + import sentry_sdk from sentry_sdk._types import ( BreadcrumbProcessor, + ContinuousProfilerMode, Event, EventProcessor, + Hint, + Log, + MeasurementUnit, + Metric, ProfilerMode, TracesSampler, TransactionProcessor, - MetricTags, ) # Experiments are feature flags to enable and disable certain unstable SDK @@ -35,16 +67,21 @@ Experiments = TypedDict( "Experiments", { - "attach_explain_plans": dict[str, Any], "max_spans": Optional[int], + "max_flags": Optional[int], "record_sql_params": Optional[bool], - # TODO: Remove these 2 profiling related experiments - "profiles_sample_rate": Optional[float], - "profiler_mode": Optional[ProfilerMode], + "continuous_profiling_auto_start": Optional[bool], + "continuous_profiling_mode": Optional[ContinuousProfilerMode], "otel_powered_performance": Optional[bool], "transport_zlib_compression_level": Optional[int], + "transport_compression_level": Optional[int], + "transport_compression_algo": Optional[CompressionAlgo], + "transport_num_pools": Optional[int], + "transport_http2": Optional[bool], + "enable_logs": Optional[bool], + "before_send_log": Optional[Callable[[Log, Hint], Optional[Log]]], "enable_metrics": Optional[bool], - "before_emit_metric": Optional[Callable[[str, MetricTags], bool]], + "before_send_metric": Optional[Callable[[Metric, Hint], Optional[Metric]]], }, total=False, ) @@ -62,78 +99,635 @@ ] +class SPANTEMPLATE(str, Enum): + DEFAULT = "default" + AI_AGENT = "ai_agent" + AI_TOOL = "ai_tool" + AI_CHAT = "ai_chat" + + def __str__(self) -> str: + return self.value + + class INSTRUMENTER: SENTRY = "sentry" OTEL = "otel" +class SPANNAME: + DB_COMMIT = "COMMIT" + DB_ROLLBACK = "ROLLBACK" + + class SPANDATA: """ Additional information describing the type of the span. See: https://develop.sentry.dev/sdk/performance/span-data-conventions/ """ - DB_NAME = "db.name" + AI_CITATIONS = "ai.citations" + """ + .. deprecated:: + This attribute is deprecated. Use GEN_AI_* attributes instead. + + References or sources cited by the AI model in its response. + Example: ["Smith et al. 2020", "Jones 2019"] + """ + + AI_DOCUMENTS = "ai.documents" + """ + .. deprecated:: + This attribute is deprecated. Use GEN_AI_* attributes instead. + + Documents or content chunks used as context for the AI model. + Example: ["doc1.txt", "doc2.pdf"] + """ + + AI_FINISH_REASON = "ai.finish_reason" + """ + .. deprecated:: + This attribute is deprecated. Use GEN_AI_RESPONSE_FINISH_REASONS instead. + + The reason why the model stopped generating. + Example: "length" + """ + + AI_FREQUENCY_PENALTY = "ai.frequency_penalty" + """ + .. deprecated:: + This attribute is deprecated. Use GEN_AI_REQUEST_FREQUENCY_PENALTY instead. + + Used to reduce repetitiveness of generated tokens. + Example: 0.5 + """ + + AI_FUNCTION_CALL = "ai.function_call" + """ + .. deprecated:: + This attribute is deprecated. Use GEN_AI_RESPONSE_TOOL_CALLS instead. + + For an AI model call, the function that was called. This is deprecated for OpenAI, and replaced by tool_calls + """ + + AI_GENERATION_ID = "ai.generation_id" + """ + .. deprecated:: + This attribute is deprecated. Use GEN_AI_RESPONSE_ID instead. + + Unique identifier for the completion. + Example: "gen_123abc" + """ + + AI_INPUT_MESSAGES = "ai.input_messages" + """ + .. deprecated:: + This attribute is deprecated. Use GEN_AI_REQUEST_MESSAGES instead. + + The input messages to an LLM call. + Example: [{"role": "user", "message": "hello"}] + """ + + AI_LOGIT_BIAS = "ai.logit_bias" + """ + .. deprecated:: + This attribute is deprecated. Use GEN_AI_* attributes instead. + + For an AI model call, the logit bias + """ + + AI_METADATA = "ai.metadata" + """ + .. deprecated:: + This attribute is deprecated. Use GEN_AI_* attributes instead. + + Extra metadata passed to an AI pipeline step. + Example: {"executed_function": "add_integers"} + """ + + AI_MODEL_ID = "ai.model_id" + """ + .. deprecated:: + This attribute is deprecated. Use GEN_AI_REQUEST_MODEL or GEN_AI_RESPONSE_MODEL instead. + + The unique descriptor of the model being executed. + Example: gpt-4 + """ + + AI_PIPELINE_NAME = "ai.pipeline.name" + """ + .. deprecated:: + This attribute is deprecated. Use GEN_AI_PIPELINE_NAME instead. + + Name of the AI pipeline or chain being executed. + Example: "qa-pipeline" + """ + + AI_PREAMBLE = "ai.preamble" + """ + .. deprecated:: + This attribute is deprecated. Use GEN_AI_* attributes instead. + + For an AI model call, the preamble parameter. + Preambles are a part of the prompt used to adjust the model's overall behavior and conversation style. + Example: "You are now a clown." + """ + + AI_PRESENCE_PENALTY = "ai.presence_penalty" + """ + .. deprecated:: + This attribute is deprecated. Use GEN_AI_REQUEST_PRESENCE_PENALTY instead. + + Used to reduce repetitiveness of generated tokens. + Example: 0.5 + """ + + AI_RAW_PROMPTING = "ai.raw_prompting" + """ + .. deprecated:: + This attribute is deprecated. Use GEN_AI_* attributes instead. + + Minimize pre-processing done to the prompt sent to the LLM. + Example: true + """ + + AI_RESPONSE_FORMAT = "ai.response_format" + """ + .. deprecated:: + This attribute is deprecated. Use GEN_AI_* attributes instead. + + For an AI model call, the format of the response + """ + + AI_RESPONSES = "ai.responses" + """ + .. deprecated:: + This attribute is deprecated. Use GEN_AI_RESPONSE_TEXT instead. + + The responses to an AI model call. Always as a list. + Example: ["hello", "world"] + """ + + AI_SEARCH_QUERIES = "ai.search_queries" + """ + .. deprecated:: + This attribute is deprecated. Use GEN_AI_* attributes instead. + + Queries used to search for relevant context or documents. + Example: ["climate change effects", "renewable energy"] + """ + + AI_SEARCH_REQUIRED = "ai.is_search_required" + """ + .. deprecated:: + This attribute is deprecated. Use GEN_AI_* attributes instead. + + Boolean indicating if the model needs to perform a search. + Example: true + """ + + AI_SEARCH_RESULTS = "ai.search_results" + """ + .. deprecated:: + This attribute is deprecated. Use GEN_AI_* attributes instead. + + Results returned from search queries for context. + Example: ["Result 1", "Result 2"] + """ + + AI_SEED = "ai.seed" + """ + .. deprecated:: + This attribute is deprecated. Use GEN_AI_REQUEST_SEED instead. + + The seed, ideally models given the same seed and same other parameters will produce the exact same output. + Example: 123.45 + """ + + AI_STREAMING = "ai.streaming" + """ + .. deprecated:: + This attribute is deprecated. Use GEN_AI_RESPONSE_STREAMING instead. + + Whether or not the AI model call's response was streamed back asynchronously + Example: true + """ + + AI_TAGS = "ai.tags" + """ + .. deprecated:: + This attribute is deprecated. Use GEN_AI_* attributes instead. + + Tags that describe an AI pipeline step. + Example: {"executed_function": "add_integers"} + """ + + AI_TEMPERATURE = "ai.temperature" + """ + .. deprecated:: + This attribute is deprecated. Use GEN_AI_REQUEST_TEMPERATURE instead. + + For an AI model call, the temperature parameter. Temperature essentially means how random the output will be. + Example: 0.5 + """ + + AI_TEXTS = "ai.texts" + """ + .. deprecated:: + This attribute is deprecated. Use GEN_AI_* attributes instead. + + Raw text inputs provided to the model. + Example: ["What is machine learning?"] + """ + + AI_TOP_K = "ai.top_k" + """ + .. deprecated:: + This attribute is deprecated. Use GEN_AI_REQUEST_TOP_K instead. + + For an AI model call, the top_k parameter. Top_k essentially controls how random the output will be. + Example: 35 + """ + + AI_TOP_P = "ai.top_p" + """ + .. deprecated:: + This attribute is deprecated. Use GEN_AI_REQUEST_TOP_P instead. + + For an AI model call, the top_p parameter. Top_p essentially controls how random the output will be. + Example: 0.5 + """ + + AI_TOOL_CALLS = "ai.tool_calls" + """ + .. deprecated:: + This attribute is deprecated. Use GEN_AI_RESPONSE_TOOL_CALLS instead. + + For an AI model call, the function that was called. This is deprecated for OpenAI, and replaced by tool_calls + """ + + AI_TOOLS = "ai.tools" + """ + .. deprecated:: + This attribute is deprecated. Use GEN_AI_REQUEST_AVAILABLE_TOOLS instead. + + For an AI model call, the functions that are available + """ + + AI_WARNINGS = "ai.warnings" + """ + .. deprecated:: + This attribute is deprecated. Use GEN_AI_* attributes instead. + + Warning messages generated during model execution. + Example: ["Token limit exceeded"] + """ + + CACHE_HIT = "cache.hit" + """ + A boolean indicating whether the requested data was found in the cache. + Example: true + """ + + CACHE_ITEM_SIZE = "cache.item_size" + """ + The size of the requested data in bytes. + Example: 58 + """ + + CACHE_KEY = "cache.key" + """ + The key of the requested data. + Example: template.cache.some_item.867da7e2af8e6b2f3aa7213a4080edb3 + """ + + CODE_FILEPATH = "code.filepath" + """ + The source code file name that identifies the code unit as uniquely as possible (preferably an absolute file path). + Example: "/app/myapplication/http/handler/server.py" + """ + + CODE_FUNCTION = "code.function" + """ + The method or function name, or equivalent (usually rightmost part of the code unit's name). + Example: "server_request" + """ + + CODE_LINENO = "code.lineno" + """ + The line number in `code.filepath` best representing the operation. It SHOULD point within the code unit named in `code.function`. + Example: 42 + """ + + CODE_NAMESPACE = "code.namespace" + """ + The "namespace" within which `code.function` is defined. Usually the qualified class or module name, such that `code.namespace` + some separator + `code.function` form a unique identifier for the code unit. + Example: "http.handler" + """ + + DB_MONGODB_COLLECTION = "db.mongodb.collection" + """ + The MongoDB collection being accessed within the database. + See: https://github.com/open-telemetry/semantic-conventions/blob/main/docs/database/mongodb.md#attributes + Example: public.users; customers + """ + + DB_NAME = "db.name" + """ + The name of the database being accessed. For commands that switch the database, this should be set to the target database (even if the command fails). + Example: myDatabase + """ + + DB_OPERATION = "db.operation" + """ + The name of the operation being executed, e.g. the MongoDB command name such as findAndModify, or the SQL keyword. + See: https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/semantic_conventions/database.md + Example: findAndModify, HMSET, SELECT + """ + + DB_SYSTEM = "db.system" + """ + An identifier for the database management system (DBMS) product being used. + See: https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/semantic_conventions/database.md + Example: postgresql + """ + + DB_USER = "db.user" + """ + The name of the database user used for connecting to the database. + See: https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/semantic_conventions/database.md + Example: my_user + """ + + GEN_AI_AGENT_NAME = "gen_ai.agent.name" + """ + The name of the agent being used. + Example: "ResearchAssistant" + """ + + GEN_AI_CHOICE = "gen_ai.choice" + """ + The model's response message. + Example: "The weather in Paris is rainy and overcast, with temperatures around 57°F" + """ + + GEN_AI_EMBEDDINGS_INPUT = "gen_ai.embeddings.input" + """ + The input to the embeddings operation. + Example: "Hello!" + """ + + GEN_AI_OPERATION_NAME = "gen_ai.operation.name" + """ + The name of the operation being performed. + Example: "chat" + """ + + GEN_AI_PIPELINE_NAME = "gen_ai.pipeline.name" + """ + Name of the AI pipeline or chain being executed. + Example: "qa-pipeline" + """ + + GEN_AI_RESPONSE_FINISH_REASONS = "gen_ai.response.finish_reasons" + """ + The reason why the model stopped generating. + Example: "COMPLETE" + """ + + GEN_AI_RESPONSE_ID = "gen_ai.response.id" + """ + Unique identifier for the completion. + Example: "gen_123abc" + """ + + GEN_AI_RESPONSE_MODEL = "gen_ai.response.model" + """ + Exact model identifier used to generate the response + Example: gpt-4o-mini-2024-07-18 + """ + + GEN_AI_RESPONSE_STREAMING = "gen_ai.response.streaming" + """ + Whether or not the AI model call's response was streamed back asynchronously + Example: true + """ + + GEN_AI_RESPONSE_TEXT = "gen_ai.response.text" + """ + The model's response text messages. + Example: ["The weather in Paris is rainy and overcast, with temperatures around 57°F", "The weather in London is sunny and warm, with temperatures around 65°F"] + """ + + GEN_AI_RESPONSE_TOOL_CALLS = "gen_ai.response.tool_calls" + """ + The tool calls in the model's response. + Example: [{"name": "get_weather", "arguments": {"location": "Paris"}}] + """ + + GEN_AI_REQUEST_AVAILABLE_TOOLS = "gen_ai.request.available_tools" + """ + The available tools for the model. + Example: [{"name": "get_weather", "description": "Get the weather for a given location"}, {"name": "get_news", "description": "Get the news for a given topic"}] + """ + + GEN_AI_REQUEST_FREQUENCY_PENALTY = "gen_ai.request.frequency_penalty" + """ + The frequency penalty parameter used to reduce repetitiveness of generated tokens. + Example: 0.1 + """ + + GEN_AI_REQUEST_MAX_TOKENS = "gen_ai.request.max_tokens" + """ + The maximum number of tokens to generate in the response. + Example: 2048 + """ + + GEN_AI_REQUEST_MESSAGES = "gen_ai.request.messages" + """ + The messages passed to the model. The "content" can be a string or an array of objects. + Example: [{role: "system", "content: "Generate a random number."}, {"role": "user", "content": [{"text": "Generate a random number between 0 and 10.", "type": "text"}]}] + """ + + GEN_AI_REQUEST_MODEL = "gen_ai.request.model" + """ + The model identifier being used for the request. + Example: "gpt-4-turbo" + """ + + GEN_AI_REQUEST_PRESENCE_PENALTY = "gen_ai.request.presence_penalty" + """ + The presence penalty parameter used to reduce repetitiveness of generated tokens. + Example: 0.1 + """ + + GEN_AI_REQUEST_SEED = "gen_ai.request.seed" + """ + The seed, ideally models given the same seed and same other parameters will produce the exact same output. + Example: "1234567890" + """ + + GEN_AI_REQUEST_TEMPERATURE = "gen_ai.request.temperature" + """ + The temperature parameter used to control randomness in the output. + Example: 0.7 + """ + + GEN_AI_REQUEST_TOP_K = "gen_ai.request.top_k" + """ + Limits the model to only consider the K most likely next tokens, where K is an integer (e.g., top_k=20 means only the 20 highest probability tokens are considered). + Example: 35 + """ + + GEN_AI_REQUEST_TOP_P = "gen_ai.request.top_p" + """ + The top_p parameter used to control diversity via nucleus sampling. + Example: 1.0 + """ + + GEN_AI_SYSTEM = "gen_ai.system" + """ + The name of the AI system being used. + Example: "openai" + """ + + GEN_AI_TOOL_DESCRIPTION = "gen_ai.tool.description" + """ + The description of the tool being used. + Example: "Searches the web for current information about a topic" + """ + + GEN_AI_TOOL_INPUT = "gen_ai.tool.input" + """ + The input of the tool being used. + Example: {"location": "Paris"} + """ + + GEN_AI_TOOL_NAME = "gen_ai.tool.name" + """ + The name of the tool being used. + Example: "web_search" + """ + + GEN_AI_TOOL_OUTPUT = "gen_ai.tool.output" + """ + The output of the tool being used. + Example: "rainy, 57°F" + """ + + GEN_AI_TOOL_TYPE = "gen_ai.tool.type" + """ + The type of tool being used. + Example: "function" + """ + + GEN_AI_USAGE_INPUT_TOKENS = "gen_ai.usage.input_tokens" + """ + The number of tokens in the input. + Example: 150 + """ + + GEN_AI_USAGE_INPUT_TOKENS_CACHED = "gen_ai.usage.input_tokens.cached" + """ + The number of cached tokens in the input. + Example: 50 + """ + + GEN_AI_USAGE_OUTPUT_TOKENS = "gen_ai.usage.output_tokens" + """ + The number of tokens in the output. + Example: 250 + """ + + GEN_AI_USAGE_OUTPUT_TOKENS_REASONING = "gen_ai.usage.output_tokens.reasoning" + """ + The number of tokens used for reasoning in the output. + Example: 75 + """ + + GEN_AI_USAGE_TOTAL_TOKENS = "gen_ai.usage.total_tokens" + """ + The total number of tokens used (input + output). + Example: 400 + """ + + GEN_AI_USER_MESSAGE = "gen_ai.user.message" + """ + The user message passed to the model. + Example: "What's the weather in Paris?" + """ + + HTTP_FRAGMENT = "http.fragment" + """ + The Fragments present in the URL. + Example: #foo=bar + """ + + HTTP_METHOD = "http.method" + """ + The HTTP method used. + Example: GET + """ + + HTTP_QUERY = "http.query" + """ + The Query string present in the URL. + Example: ?foo=bar&bar=baz + """ + + HTTP_STATUS_CODE = "http.response.status_code" """ - The name of the database being accessed. For commands that switch the database, this should be set to the target database (even if the command fails). - Example: myDatabase + The HTTP status code as an integer. + Example: 418 """ - DB_USER = "db.user" + MESSAGING_DESTINATION_NAME = "messaging.destination.name" """ - The name of the database user used for connecting to the database. - See: https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/semantic_conventions/database.md - Example: my_user + The destination name where the message is being consumed from, + e.g. the queue name or topic. """ - DB_OPERATION = "db.operation" + MESSAGING_MESSAGE_ID = "messaging.message.id" """ - The name of the operation being executed, e.g. the MongoDB command name such as findAndModify, or the SQL keyword. - See: https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/semantic_conventions/database.md - Example: findAndModify, HMSET, SELECT + The message's identifier. """ - DB_SYSTEM = "db.system" + MESSAGING_MESSAGE_RECEIVE_LATENCY = "messaging.message.receive.latency" """ - An identifier for the database management system (DBMS) product being used. - See: https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/semantic_conventions/database.md - Example: postgresql + The latency between when the task was enqueued and when it was started to be processed. """ - CACHE_HIT = "cache.hit" + MESSAGING_MESSAGE_RETRY_COUNT = "messaging.message.retry.count" """ - A boolean indicating whether the requested data was found in the cache. - Example: true + Number of retries/attempts to process a message. """ - CACHE_ITEM_SIZE = "cache.item_size" + MESSAGING_SYSTEM = "messaging.system" """ - The size of the requested data in bytes. - Example: 58 + The messaging system's name, e.g. `kafka`, `aws_sqs` """ - HTTP_QUERY = "http.query" + NETWORK_PEER_ADDRESS = "network.peer.address" """ - The Query string present in the URL. - Example: ?foo=bar&bar=baz + Peer address of the network connection - IP address or Unix domain socket name. + Example: 10.1.2.80, /tmp/my.sock, localhost """ - HTTP_FRAGMENT = "http.fragment" + NETWORK_PEER_PORT = "network.peer.port" """ - The Fragments present in the URL. - Example: #foo=bar + Peer port number of the network connection. + Example: 6379 """ - HTTP_METHOD = "http.method" + NETWORK_TRANSPORT = "network.transport" """ - The HTTP method used. - Example: GET + The transport protocol used for the network connection. + Example: "tcp", "udp", "unix" """ - HTTP_STATUS_CODE = "http.response.status_code" + PROFILER_ID = "profiler_id" """ - The HTTP status code as an integer. - Example: 418 + Label identifying the profiler id that the span occurred in. This should be a string. + Example: "5249fbada8d5416482c2f6e47e337372" """ SERVER_ADDRESS = "server.address" @@ -161,15 +755,150 @@ class SPANDATA: Example: 16456 """ + THREAD_ID = "thread.id" + """ + Identifier of a thread from where the span originated. This should be a string. + Example: "7972576320" + """ + + THREAD_NAME = "thread.name" + """ + Label identifying a thread from where the span originated. This should be a string. + Example: "MainThread" + """ + + MCP_TOOL_NAME = "mcp.tool.name" + """ + The name of the MCP tool being called. + Example: "get_weather" + """ + + MCP_PROMPT_NAME = "mcp.prompt.name" + """ + The name of the MCP prompt being retrieved. + Example: "code_review" + """ + + MCP_RESOURCE_URI = "mcp.resource.uri" + """ + The URI of the MCP resource being accessed. + Example: "file:///path/to/resource" + """ + + MCP_METHOD_NAME = "mcp.method.name" + """ + The MCP protocol method name being called. + Example: "tools/call", "prompts/get", "resources/read" + """ + + MCP_REQUEST_ID = "mcp.request.id" + """ + The unique identifier for the MCP request. + Example: "req_123abc" + """ + + MCP_TOOL_RESULT_CONTENT = "mcp.tool.result.content" + """ + The result/output content from an MCP tool execution. + Example: "The weather is sunny" + """ + + MCP_TOOL_RESULT_CONTENT_COUNT = "mcp.tool.result.content_count" + """ + The number of items/keys in the MCP tool result. + Example: 5 + """ + + MCP_TOOL_RESULT_IS_ERROR = "mcp.tool.result.is_error" + """ + Whether the MCP tool execution resulted in an error. + Example: True + """ + + MCP_PROMPT_RESULT_MESSAGE_CONTENT = "mcp.prompt.result.message_content" + """ + The message content from an MCP prompt retrieval. + Example: "Review the following code..." + """ + + MCP_PROMPT_RESULT_MESSAGE_ROLE = "mcp.prompt.result.message_role" + """ + The role of the message in an MCP prompt retrieval (only set for single-message prompts). + Example: "user", "assistant", "system" + """ + + MCP_PROMPT_RESULT_MESSAGE_COUNT = "mcp.prompt.result.message_count" + """ + The number of messages in an MCP prompt result. + Example: 1, 3 + """ + + MCP_RESOURCE_PROTOCOL = "mcp.resource.protocol" + """ + The protocol/scheme of the MCP resource URI. + Example: "file", "http", "https" + """ + + MCP_TRANSPORT = "mcp.transport" + """ + The transport method used for MCP communication. + Example: "http", "sse", "stdio" + """ + + MCP_SESSION_ID = "mcp.session.id" + """ + The session identifier for the MCP connection. + Example: "a1b2c3d4e5f6" + """ + + +class SPANSTATUS: + """ + The status of a Sentry span. + + See: https://develop.sentry.dev/sdk/event-payloads/contexts/#trace-context + """ + + ABORTED = "aborted" + ALREADY_EXISTS = "already_exists" + CANCELLED = "cancelled" + DATA_LOSS = "data_loss" + DEADLINE_EXCEEDED = "deadline_exceeded" + FAILED_PRECONDITION = "failed_precondition" + INTERNAL_ERROR = "internal_error" + INVALID_ARGUMENT = "invalid_argument" + NOT_FOUND = "not_found" + OK = "ok" + OUT_OF_RANGE = "out_of_range" + PERMISSION_DENIED = "permission_denied" + RESOURCE_EXHAUSTED = "resource_exhausted" + UNAUTHENTICATED = "unauthenticated" + UNAVAILABLE = "unavailable" + UNIMPLEMENTED = "unimplemented" + UNKNOWN_ERROR = "unknown_error" + class OP: - CACHE_GET_ITEM = "cache.get_item" + ANTHROPIC_MESSAGES_CREATE = "ai.messages.create.anthropic" + CACHE_GET = "cache.get" + CACHE_PUT = "cache.put" + COHERE_CHAT_COMPLETIONS_CREATE = "ai.chat_completions.create.cohere" + COHERE_EMBEDDINGS_CREATE = "ai.embeddings.create.cohere" DB = "db" DB_REDIS = "db.redis" EVENT_DJANGO = "event.django" FUNCTION = "function" FUNCTION_AWS = "function.aws" FUNCTION_GCP = "function.gcp" + GEN_AI_CHAT = "gen_ai.chat" + GEN_AI_CREATE_AGENT = "gen_ai.create_agent" + GEN_AI_EMBEDDINGS = "gen_ai.embeddings" + GEN_AI_EXECUTE_TOOL = "gen_ai.execute_tool" + GEN_AI_GENERATE_TEXT = "gen_ai.generate_text" + GEN_AI_HANDOFF = "gen_ai.handoff" + GEN_AI_PIPELINE = "gen_ai.pipeline" + GEN_AI_INVOKE_AGENT = "gen_ai.invoke_agent" + GEN_AI_RESPONSES = "gen_ai.responses" GRAPHQL_EXECUTE = "graphql.execute" GRAPHQL_MUTATION = "graphql.mutation" GRAPHQL_PARSE = "graphql.parse" @@ -183,12 +912,20 @@ class OP: HTTP_CLIENT_STREAM = "http.client.stream" HTTP_SERVER = "http.server" MIDDLEWARE_DJANGO = "middleware.django" + MIDDLEWARE_LITESTAR = "middleware.litestar" + MIDDLEWARE_LITESTAR_RECEIVE = "middleware.litestar.receive" + MIDDLEWARE_LITESTAR_SEND = "middleware.litestar.send" MIDDLEWARE_STARLETTE = "middleware.starlette" MIDDLEWARE_STARLETTE_RECEIVE = "middleware.starlette.receive" MIDDLEWARE_STARLETTE_SEND = "middleware.starlette.send" MIDDLEWARE_STARLITE = "middleware.starlite" MIDDLEWARE_STARLITE_RECEIVE = "middleware.starlite.receive" MIDDLEWARE_STARLITE_SEND = "middleware.starlite.send" + HUGGINGFACE_HUB_CHAT_COMPLETIONS_CREATE = ( + "ai.chat_completions.create.huggingface_hub" + ) + QUEUE_PROCESS = "queue.process" + QUEUE_PUBLISH = "queue.publish" QUEUE_SUBMIT_ARQ = "queue.submit.arq" QUEUE_TASK_ARQ = "queue.task.arq" QUEUE_SUBMIT_CELERY = "queue.submit.celery" @@ -196,6 +933,10 @@ class OP: QUEUE_TASK_RQ = "queue.task.rq" QUEUE_SUBMIT_HUEY = "queue.submit.huey" QUEUE_TASK_HUEY = "queue.task.huey" + QUEUE_SUBMIT_RAY = "queue.submit.ray" + QUEUE_TASK_RAY = "queue.task.ray" + QUEUE_TASK_DRAMATIQ = "queue.task.dramatiq" + QUEUE_SUBMIT_DJANGO = "queue.submit.django" SUBPROCESS = "subprocess" SUBPROCESS_WAIT = "subprocess.wait" SUBPROCESS_COMMUNICATE = "subprocess.communicate" @@ -205,82 +946,523 @@ class OP: WEBSOCKET_SERVER = "websocket.server" SOCKET_CONNECTION = "socket.connection" SOCKET_DNS = "socket.dns" + MCP_SERVER = "mcp.server" # This type exists to trick mypy and PyCharm into thinking `init` and `Client` # take these arguments (even though they take opaque **kwargs) -class ClientConstructor(object): +class ClientConstructor: def __init__( self, - dsn=None, # type: Optional[str] - max_breadcrumbs=DEFAULT_MAX_BREADCRUMBS, # type: int - release=None, # type: Optional[str] - environment=None, # type: Optional[str] - server_name=None, # type: Optional[str] - shutdown_timeout=2, # type: float - integrations=[], # type: Sequence[Integration] # noqa: B006 - in_app_include=[], # type: List[str] # noqa: B006 - in_app_exclude=[], # type: List[str] # noqa: B006 - default_integrations=True, # type: bool - dist=None, # type: Optional[str] - transport=None, # type: Optional[Union[sentry_sdk.transport.Transport, Type[sentry_sdk.transport.Transport], Callable[[Event], None]]] - transport_queue_size=DEFAULT_QUEUE_SIZE, # type: int - sample_rate=1.0, # type: float - send_default_pii=False, # type: bool - http_proxy=None, # type: Optional[str] - https_proxy=None, # type: Optional[str] - ignore_errors=[], # type: Sequence[Union[type, str]] # noqa: B006 - max_request_body_size="medium", # type: str - before_send=None, # type: Optional[EventProcessor] - before_breadcrumb=None, # type: Optional[BreadcrumbProcessor] - debug=False, # type: bool - attach_stacktrace=False, # type: bool - ca_certs=None, # type: Optional[str] - propagate_traces=True, # type: bool - traces_sample_rate=None, # type: Optional[float] - traces_sampler=None, # type: Optional[TracesSampler] - profiles_sample_rate=None, # type: Optional[float] - profiles_sampler=None, # type: Optional[TracesSampler] - profiler_mode=None, # type: Optional[ProfilerMode] - auto_enabling_integrations=True, # type: bool - auto_session_tracking=True, # type: bool - send_client_reports=True, # type: bool - _experiments={}, # type: Experiments # noqa: B006 - proxy_headers=None, # type: Optional[Dict[str, str]] - instrumenter=INSTRUMENTER.SENTRY, # type: Optional[str] - before_send_transaction=None, # type: Optional[TransactionProcessor] - project_root=None, # type: Optional[str] - enable_tracing=None, # type: Optional[bool] - include_local_variables=True, # type: Optional[bool] - include_source_context=True, # type: Optional[bool] - trace_propagation_targets=[ # noqa: B006 + dsn: "Optional[str]" = None, + *, + max_breadcrumbs: int = DEFAULT_MAX_BREADCRUMBS, + release: "Optional[str]" = None, + environment: "Optional[str]" = None, + server_name: "Optional[str]" = None, + shutdown_timeout: float = 2, + integrations: "Sequence[sentry_sdk.integrations.Integration]" = [], # noqa: B006 + in_app_include: "List[str]" = [], # noqa: B006 + in_app_exclude: "List[str]" = [], # noqa: B006 + default_integrations: bool = True, + dist: "Optional[str]" = None, + transport: "Optional[Union[sentry_sdk.transport.Transport, Type[sentry_sdk.transport.Transport], Callable[[Event], None]]]" = None, + transport_queue_size: int = DEFAULT_QUEUE_SIZE, + sample_rate: float = 1.0, + send_default_pii: "Optional[bool]" = None, + http_proxy: "Optional[str]" = None, + https_proxy: "Optional[str]" = None, + ignore_errors: "Sequence[Union[type, str]]" = [], # noqa: B006 + max_request_body_size: str = "medium", + socket_options: "Optional[List[Tuple[int, int, int | bytes]]]" = None, + keep_alive: "Optional[bool]" = None, + before_send: "Optional[EventProcessor]" = None, + before_breadcrumb: "Optional[BreadcrumbProcessor]" = None, + debug: "Optional[bool]" = None, + attach_stacktrace: bool = False, + ca_certs: "Optional[str]" = None, + propagate_traces: bool = True, + traces_sample_rate: "Optional[float]" = None, + traces_sampler: "Optional[TracesSampler]" = None, + profiles_sample_rate: "Optional[float]" = None, + profiles_sampler: "Optional[TracesSampler]" = None, + profiler_mode: "Optional[ProfilerMode]" = None, + profile_lifecycle: 'Literal["manual", "trace"]' = "manual", + profile_session_sample_rate: "Optional[float]" = None, + auto_enabling_integrations: bool = True, + disabled_integrations: "Optional[Sequence[sentry_sdk.integrations.Integration]]" = None, + auto_session_tracking: bool = True, + send_client_reports: bool = True, + _experiments: "Experiments" = {}, # noqa: B006 + proxy_headers: "Optional[Dict[str, str]]" = None, + instrumenter: "Optional[str]" = INSTRUMENTER.SENTRY, + before_send_transaction: "Optional[TransactionProcessor]" = None, + project_root: "Optional[str]" = None, + enable_tracing: "Optional[bool]" = None, + include_local_variables: "Optional[bool]" = True, + include_source_context: "Optional[bool]" = True, + trace_propagation_targets: "Optional[Sequence[str]]" = [ # noqa: B006 MATCH_ALL - ], # type: Optional[Sequence[str]] - functions_to_trace=[], # type: Sequence[Dict[str, str]] # noqa: B006 - event_scrubber=None, # type: Optional[sentry_sdk.scrubber.EventScrubber] - max_value_length=DEFAULT_MAX_VALUE_LENGTH, # type: int - enable_backpressure_handling=True, # type: bool - ): - # type: (...) -> None + ], + functions_to_trace: "Sequence[Dict[str, str]]" = [], # noqa: B006 + event_scrubber: "Optional[sentry_sdk.scrubber.EventScrubber]" = None, + max_value_length: int = DEFAULT_MAX_VALUE_LENGTH, + enable_backpressure_handling: bool = True, + error_sampler: "Optional[Callable[[Event, Hint], Union[float, bool]]]" = None, + enable_db_query_source: bool = True, + db_query_source_threshold_ms: int = 100, + enable_http_request_source: bool = True, + http_request_source_threshold_ms: int = 100, + spotlight: "Optional[Union[bool, str]]" = None, + cert_file: "Optional[str]" = None, + key_file: "Optional[str]" = None, + custom_repr: "Optional[Callable[..., Optional[str]]]" = None, + add_full_stack: bool = DEFAULT_ADD_FULL_STACK, + max_stack_frames: "Optional[int]" = DEFAULT_MAX_STACK_FRAMES, + enable_logs: bool = False, + before_send_log: "Optional[Callable[[Log, Hint], Optional[Log]]]" = None, + trace_ignore_status_codes: "AbstractSet[int]" = frozenset(), + enable_metrics: bool = True, + before_send_metric: "Optional[Callable[[Metric, Hint], Optional[Metric]]]" = None, + org_id: "Optional[str]" = None, + strict_trace_continuation: bool = False, + ) -> None: + """Initialize the Sentry SDK with the given parameters. All parameters described here can be used in a call to `sentry_sdk.init()`. + + :param dsn: The DSN tells the SDK where to send the events. + + If this option is not set, the SDK will just not send any data. + + The `dsn` config option takes precedence over the environment variable. + + Learn more about `DSN utilization `_. + + :param debug: Turns debug mode on or off. + + When `True`, the SDK will attempt to print out debugging information. This can be useful if something goes + wrong with event sending. + + The default is always `False`. It's generally not recommended to turn it on in production because of the + increase in log output. + + The `debug` config option takes precedence over the environment variable. + + :param release: Sets the release. + + If not set, the SDK will try to automatically configure a release out of the box but it's a better idea to + manually set it to guarantee that the release is in sync with your deploy integrations. + + Release names are strings, but some formats are detected by Sentry and might be rendered differently. + + See `the releases documentation `_ to learn how the SDK tries to + automatically configure a release. + + The `release` config option takes precedence over the environment variable. + + Learn more about how to send release data so Sentry can tell you about regressions between releases and + identify the potential source in `the product documentation `_. + + :param environment: Sets the environment. This string is freeform and set to `production` by default. + + A release can be associated with more than one environment to separate them in the UI (think `staging` vs + `production` or similar). + + The `environment` config option takes precedence over the environment variable. + + :param dist: The distribution of the application. + + Distributions are used to disambiguate build or deployment variants of the same release of an application. + + The dist can be for example a build number. + + :param sample_rate: Configures the sample rate for error events, in the range of `0.0` to `1.0`. + + The default is `1.0`, which means that 100% of error events will be sent. If set to `0.1`, only 10% of + error events will be sent. + + Events are picked randomly. + + :param error_sampler: Dynamically configures the sample rate for error events on a per-event basis. + + This configuration option accepts a function, which takes two parameters (the `event` and the `hint`), and + which returns a boolean (indicating whether the event should be sent to Sentry) or a floating-point number + between `0.0` and `1.0`, inclusive. + + The number indicates the probability the event is sent to Sentry; the SDK will randomly decide whether to + send the event with the given probability. + + If this configuration option is specified, the `sample_rate` option is ignored. + + :param ignore_errors: A list of exception class names that shouldn't be sent to Sentry. + + Errors that are an instance of these exceptions or a subclass of them, will be filtered out before they're + sent to Sentry. + + By default, all errors are sent. + + :param max_breadcrumbs: This variable controls the total amount of breadcrumbs that should be captured. + + This defaults to `100`, but you can set this to any number. + + However, you should be aware that Sentry has a `maximum payload size `_ + and any events exceeding that payload size will be dropped. + + :param attach_stacktrace: When enabled, stack traces are automatically attached to all messages logged. + + Stack traces are always attached to exceptions; however, when this option is set, stack traces are also + sent with messages. + + This option means that stack traces appear next to all log messages. + + Grouping in Sentry is different for events with stack traces and without. As a result, you will get new + groups as you enable or disable this flag for certain events. + + :param send_default_pii: If this flag is enabled, `certain personally identifiable information (PII) + `_ is added by active integrations. + + If you enable this option, be sure to manually remove what you don't want to send using our features for + managing `Sensitive Data `_. + + :param event_scrubber: Scrubs the event payload for sensitive information such as cookies, sessions, and + passwords from a `denylist`. + + It can additionally be used to scrub from another `pii_denylist` if `send_default_pii` is disabled. + + See how to `configure the scrubber here `_. + + :param include_source_context: When enabled, source context will be included in events sent to Sentry. + + This source context includes the five lines of code above and below the line of code where an error + happened. + + :param include_local_variables: When enabled, the SDK will capture a snapshot of local variables to send with + the event to help with debugging. + + :param add_full_stack: When capturing errors, Sentry stack traces typically only include frames that start the + moment an error occurs. + + But if the `add_full_stack` option is enabled (set to `True`), all frames from the start of execution will + be included in the stack trace sent to Sentry. + + :param max_stack_frames: This option limits the number of stack frames that will be captured when + `add_full_stack` is enabled. + + :param server_name: This option can be used to supply a server name. + + When provided, the name of the server is sent along and persisted in the event. + + For many integrations, the server name actually corresponds to the device hostname, even in situations + where the machine is not actually a server. + + :param project_root: The full path to the root directory of your application. + + The `project_root` is used to mark frames in a stack trace either as being in your application or outside + of the application. + + :param in_app_include: A list of string prefixes of module names that belong to the app. + + This option takes precedence over `in_app_exclude`. + + Sentry differentiates stack frames that are directly related to your application ("in application") from + stack frames that come from other packages such as the standard library, frameworks, or other dependencies. + + The application package is automatically marked as `inApp`. + + The difference is visible in [sentry.io](https://sentry.io), where only the "in application" frames are + displayed by default. + + :param in_app_exclude: A list of string prefixes of module names that do not belong to the app, but rather to + third-party packages. + + Modules considered not part of the app will be hidden from stack traces by default. + + This option can be overridden using `in_app_include`. + + :param max_request_body_size: This parameter controls whether integrations should capture HTTP request bodies. + It can be set to one of the following values: + + - `never`: Request bodies are never sent. + - `small`: Only small request bodies will be captured. The cutoff for small depends on the SDK (typically + 4KB). + - `medium`: Medium and small requests will be captured (typically 10KB). + - `always`: The SDK will always capture the request body as long as Sentry can make sense of it. + + Please note that the Sentry server [limits HTTP request body size](https://develop.sentry.dev/sdk/ + expected-features/data-handling/#variable-size). The server always enforces its size limit, regardless of + how you configure this option. + + :param max_value_length: The number of characters after which the values containing text in the event payload + will be truncated. + + WARNING: If the value you set for this is exceptionally large, the event may exceed 1 MiB and will be + dropped by Sentry. + + :param ca_certs: A path to an alternative CA bundle file in PEM-format. + + :param send_client_reports: Set this boolean to `False` to disable sending of client reports. + + Client reports allow the client to send status reports about itself to Sentry, such as information about + events that were dropped before being sent. + + :param integrations: List of integrations to enable in addition to `auto-enabling integrations (overview) + `_. + + This setting can be used to override the default config options for a specific auto-enabling integration + or to add an integration that is not auto-enabled. + + :param disabled_integrations: List of integrations that will be disabled. + + This setting can be used to explicitly turn off specific `auto-enabling integrations (list) + `_ or + `default `_ integrations. + + :param auto_enabling_integrations: Configures whether `auto-enabling integrations (configuration) + `_ should be enabled. + + When set to `False`, no auto-enabling integrations will be enabled by default, even if the corresponding + framework/library is detected. + + :param default_integrations: Configures whether `default integrations + `_ should be enabled. + + Setting `default_integrations` to `False` disables all default integrations **as well as all auto-enabling + integrations**, unless they are specifically added in the `integrations` option, described above. + + :param before_send: This function is called with an SDK-specific message or error event object, and can return + a modified event object, or `null` to skip reporting the event. + + This can be used, for instance, for manual PII stripping before sending. + + By the time `before_send` is executed, all scope data has already been applied to the event. Further + modification of the scope won't have any effect. + + :param before_send_transaction: This function is called with an SDK-specific transaction event object, and can + return a modified transaction event object, or `null` to skip reporting the event. + + One way this might be used is for manual PII stripping before sending. + + :param before_breadcrumb: This function is called with an SDK-specific breadcrumb object before the breadcrumb + is added to the scope. + + When nothing is returned from the function, the breadcrumb is dropped. + + To pass the breadcrumb through, return the first argument, which contains the breadcrumb object. + + The callback typically gets a second argument (called a "hint") which contains the original object from + which the breadcrumb was created to further customize what the breadcrumb should look like. + + :param transport: Switches out the transport used to send events. + + How this works depends on the SDK. It can, for instance, be used to capture events for unit-testing or to + send it through some more complex setup that requires proxy authentication. + + :param transport_queue_size: The maximum number of events that will be queued before the transport is forced to + flush. + + :param http_proxy: When set, a proxy can be configured that should be used for outbound requests. + + This is also used for HTTPS requests unless a separate `https_proxy` is configured. However, not all SDKs + support a separate HTTPS proxy. + + SDKs will attempt to default to the system-wide configured proxy, if possible. For instance, on Unix + systems, the `http_proxy` environment variable will be picked up. + + :param https_proxy: Configures a separate proxy for outgoing HTTPS requests. + + This value might not be supported by all SDKs. When not supported the `http-proxy` value is also used for + HTTPS requests at all times. + + :param proxy_headers: A dict containing additional proxy headers (usually for authentication) to be forwarded + to `urllib3`'s `ProxyManager `_. + + :param shutdown_timeout: Controls how many seconds to wait before shutting down. + + Sentry SDKs send events from a background queue. This queue is given a certain amount to drain pending + events. The default is SDK specific but typically around two seconds. + + Setting this value too low may cause problems for sending events from command line applications. + + Setting the value too high will cause the application to block for a long time for users experiencing + network connectivity problems. + + :param keep_alive: Determines whether to keep the connection alive between requests. + + This can be useful in environments where you encounter frequent network issues such as connection resets. + + :param cert_file: Path to the client certificate to use. + + If set, supersedes the `CLIENT_CERT_FILE` environment variable. + + :param key_file: Path to the key file to use. + + If set, supersedes the `CLIENT_KEY_FILE` environment variable. + + :param socket_options: An optional list of socket options to use. + + These provide fine-grained, low-level control over the way the SDK connects to Sentry. + + If provided, the options will override the default `urllib3` `socket options + `_. + + :param traces_sample_rate: A number between `0` and `1`, controlling the percentage chance a given transaction + will be sent to Sentry. + + (`0` represents 0% while `1` represents 100%.) Applies equally to all transactions created in the app. + + Either this or `traces_sampler` must be defined to enable tracing. + + If `traces_sample_rate` is `0`, this means that no new traces will be created. However, if you have + another service (for example a JS frontend) that makes requests to your service that include trace + information, those traces will be continued and thus transactions will be sent to Sentry. + + If you want to disable all tracing you need to set `traces_sample_rate=None`. In this case, no new traces + will be started and no incoming traces will be continued. + + :param traces_sampler: A function responsible for determining the percentage chance a given transaction will be + sent to Sentry. + + It will automatically be passed information about the transaction and the context in which it's being + created, and must return a number between `0` (0% chance of being sent) and `1` (100% chance of being + sent). + + Can also be used for filtering transactions, by returning `0` for those that are unwanted. + + Either this or `traces_sample_rate` must be defined to enable tracing. + + :param trace_propagation_targets: An optional property that controls which downstream services receive tracing + data, in the form of a `sentry-trace` and a `baggage` header attached to any outgoing HTTP requests. + + The option may contain a list of strings or regex against which the URLs of outgoing requests are matched. + + If one of the entries in the list matches the URL of an outgoing request, trace data will be attached to + that request. + + String entries do not have to be full matches, meaning the URL of a request is matched when it _contains_ + a string provided through the option. + + If `trace_propagation_targets` is not provided, trace data is attached to every outgoing request from the + instrumented client. + + :param functions_to_trace: An optional list of functions that should be set up for tracing. + + For each function in the list, a span will be created when the function is executed. + + Functions in the list are represented as strings containing the fully qualified name of the function. + + This is a convenient option, making it possible to have one central place for configuring what functions + to trace, instead of having custom instrumentation scattered all over your code base. + + To learn more, see the `Custom Instrumentation `_ documentation. + + :param enable_backpressure_handling: When enabled, a new monitor thread will be spawned to perform health + checks on the SDK. + + If the system is unhealthy, the SDK will keep halving the `traces_sample_rate` set by you in 10 second + intervals until recovery. + + This down sampling helps ensure that the system stays stable and reduces SDK overhead under high load. + + This option is enabled by default. + + :param enable_db_query_source: When enabled, the source location will be added to database queries. + + :param db_query_source_threshold_ms: The threshold in milliseconds for adding the source location to database + queries. + + The query location will be added to the query for queries slower than the specified threshold. + + :param enable_http_request_source: When enabled, the source location will be added to outgoing HTTP requests. + + :param http_request_source_threshold_ms: The threshold in milliseconds for adding the source location to an + outgoing HTTP request. + + The request location will be added to the request for requests slower than the specified threshold. + + :param custom_repr: A custom `repr `_ function to run + while serializing an object. + + Use this to control how your custom objects and classes are visible in Sentry. + + Return a string for that repr value to be used or `None` to continue serializing how Sentry would have + done it anyway. + + :param profiles_sample_rate: A number between `0` and `1`, controlling the percentage chance a given sampled + transaction will be profiled. + + (`0` represents 0% while `1` represents 100%.) Applies equally to all transactions created in the app. + + This is relative to the tracing sample rate - e.g. `0.5` means 50% of sampled transactions will be + profiled. + + :param profiles_sampler: + + :param profiler_mode: + + :param profile_lifecycle: + + :param profile_session_sample_rate: + + :param enable_tracing: + + :param propagate_traces: + + :param auto_session_tracking: + + :param spotlight: + + :param instrumenter: + + :param enable_logs: Set `enable_logs` to True to enable the SDK to emit + Sentry logs. Defaults to False. + + :param before_send_log: An optional function to modify or filter out logs + before they're sent to Sentry. Any modifications to the log in this + function will be retained. If the function returns None, the log will + not be sent to Sentry. + + :param trace_ignore_status_codes: An optional property that disables tracing for + HTTP requests with certain status codes. + + Requests are not traced if the status code is contained in the provided set. + + If `trace_ignore_status_codes` is not provided, requests with any status code + may be traced. + + :param strict_trace_continuation: If set to `True`, the SDK will only continue a trace if the `org_id` of the incoming trace found in the + `baggage` header matches the `org_id` of the current Sentry client and only if BOTH are present. + + If set to `False`, consistency of `org_id` will only be enforced if both are present. If either are missing, the trace will be continued. + + The client's organization ID is extracted from the DSN or can be set with the `org_id` option. + If the organization IDs do not match, the SDK will start a new trace instead of continuing the incoming one. + This is useful to prevent traces of unknown third-party services from being continued in your application. + + :param org_id: An optional organization ID. The SDK will try to extract if from the DSN in most cases + but you can provide it explicitly for self-hosted and Relay setups. This value is used for + trace propagation and for features like `strict_trace_continuation`. + + :param _experiments: + """ pass -def _get_default_options(): - # type: () -> Dict[str, Any] +def _get_default_options() -> "dict[str, Any]": import inspect - if hasattr(inspect, "getfullargspec"): - getargspec = inspect.getfullargspec - else: - getargspec = inspect.getargspec # type: ignore - - a = getargspec(ClientConstructor.__init__) + a = inspect.getfullargspec(ClientConstructor.__init__) defaults = a.defaults or () - return dict(zip(a.args[-len(defaults) :], defaults)) + kwonlydefaults = a.kwonlydefaults or {} + + return dict( + itertools.chain( + zip(a.args[-len(defaults) :], defaults), + kwonlydefaults.items(), + ) + ) DEFAULT_OPTIONS = _get_default_options() del _get_default_options -VERSION = "1.32.0" +VERSION = "2.48.0" diff --git a/sentry_sdk/crons/__init__.py b/sentry_sdk/crons/__init__.py index 5d1fe357d2..6f748aaecb 100644 --- a/sentry_sdk/crons/__init__.py +++ b/sentry_sdk/crons/__init__.py @@ -1,3 +1,10 @@ -from sentry_sdk.crons.api import capture_checkin # noqa -from sentry_sdk.crons.consts import MonitorStatus # noqa -from sentry_sdk.crons.decorator import monitor # noqa +from sentry_sdk.crons.api import capture_checkin +from sentry_sdk.crons.consts import MonitorStatus +from sentry_sdk.crons.decorator import monitor + + +__all__ = [ + "capture_checkin", + "MonitorStatus", + "monitor", +] diff --git a/sentry_sdk/crons/api.py b/sentry_sdk/crons/api.py index cd240a7dcd..5b7bdc2480 100644 --- a/sentry_sdk/crons/api.py +++ b/sentry_sdk/crons/api.py @@ -1,25 +1,26 @@ import uuid -from sentry_sdk import Hub -from sentry_sdk._types import TYPE_CHECKING +import sentry_sdk +from sentry_sdk.utils import logger +from typing import TYPE_CHECKING if TYPE_CHECKING: - from typing import Any, Dict, Optional + from typing import Optional + from sentry_sdk._types import Event, MonitorConfig def _create_check_in_event( - monitor_slug=None, - check_in_id=None, - status=None, - duration_s=None, - monitor_config=None, -): - # type: (Optional[str], Optional[str], Optional[str], Optional[float], Optional[Dict[str, Any]]) -> Dict[str, Any] - options = Hub.current.client.options if Hub.current.client else {} - check_in_id = check_in_id or uuid.uuid4().hex # type: str - - check_in = { + monitor_slug: "Optional[str]" = None, + check_in_id: "Optional[str]" = None, + status: "Optional[str]" = None, + duration_s: "Optional[float]" = None, + monitor_config: "Optional[MonitorConfig]" = None, +) -> "Event": + options = sentry_sdk.get_client().options + check_in_id: str = check_in_id or uuid.uuid4().hex + + check_in: "Event" = { "type": "check_in", "monitor_slug": monitor_slug, "check_in_id": check_in_id, @@ -36,13 +37,12 @@ def _create_check_in_event( def capture_checkin( - monitor_slug=None, - check_in_id=None, - status=None, - duration=None, - monitor_config=None, -): - # type: (Optional[str], Optional[str], Optional[str], Optional[float], Optional[Dict[str, Any]]) -> str + monitor_slug: "Optional[str]" = None, + check_in_id: "Optional[str]" = None, + status: "Optional[str]" = None, + duration: "Optional[float]" = None, + monitor_config: "Optional[MonitorConfig]" = None, +) -> str: check_in_event = _create_check_in_event( monitor_slug=monitor_slug, check_in_id=check_in_id, @@ -51,7 +51,10 @@ def capture_checkin( monitor_config=monitor_config, ) - hub = Hub.current - hub.capture_event(check_in_event) + sentry_sdk.capture_event(check_in_event) + + logger.debug( + f"[Crons] Captured check-in ({check_in_event.get('check_in_id')}): {check_in_event.get('monitor_slug')} -> {check_in_event.get('status')}" + ) return check_in_event["check_in_id"] diff --git a/sentry_sdk/crons/decorator.py b/sentry_sdk/crons/decorator.py index 34f4d0ac95..7032183f80 100644 --- a/sentry_sdk/crons/decorator.py +++ b/sentry_sdk/crons/decorator.py @@ -1,18 +1,32 @@ -import sys +from functools import wraps +from inspect import iscoroutinefunction -from sentry_sdk._compat import contextmanager, reraise -from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.crons import capture_checkin from sentry_sdk.crons.consts import MonitorStatus from sentry_sdk.utils import now +from typing import TYPE_CHECKING + if TYPE_CHECKING: - from typing import Generator, Optional + from collections.abc import Awaitable, Callable + from types import TracebackType + from typing import ( + Any, + Optional, + ParamSpec, + Type, + TypeVar, + Union, + cast, + overload, + ) + from sentry_sdk._types import MonitorConfig + + P = ParamSpec("P") + R = TypeVar("R") -@contextmanager -def monitor(monitor_slug=None): - # type: (Optional[str]) -> Generator[None, None, None] +class monitor: # noqa: N801 """ Decorator/context manager to capture checkin events for a monitor. @@ -39,32 +53,85 @@ def test(arg): with sentry_sdk.monitor(monitor_slug='my-fancy-slug'): print(arg) ``` + """ + def __init__( + self, + monitor_slug: "Optional[str]" = None, + monitor_config: "Optional[MonitorConfig]" = None, + ) -> None: + self.monitor_slug = monitor_slug + self.monitor_config = monitor_config - """ + def __enter__(self) -> None: + self.start_timestamp = now() + self.check_in_id = capture_checkin( + monitor_slug=self.monitor_slug, + status=MonitorStatus.IN_PROGRESS, + monitor_config=self.monitor_config, + ) - start_timestamp = now() - check_in_id = capture_checkin( - monitor_slug=monitor_slug, status=MonitorStatus.IN_PROGRESS - ) + def __exit__( + self, + exc_type: "Optional[Type[BaseException]]", + exc_value: "Optional[BaseException]", + traceback: "Optional[TracebackType]", + ) -> None: + duration_s = now() - self.start_timestamp + + if exc_type is None and exc_value is None and traceback is None: + status = MonitorStatus.OK + else: + status = MonitorStatus.ERROR - try: - yield - except Exception: - duration_s = now() - start_timestamp capture_checkin( - monitor_slug=monitor_slug, - check_in_id=check_in_id, - status=MonitorStatus.ERROR, + monitor_slug=self.monitor_slug, + check_in_id=self.check_in_id, + status=status, duration=duration_s, + monitor_config=self.monitor_config, ) - exc_info = sys.exc_info() - reraise(*exc_info) - - duration_s = now() - start_timestamp - capture_checkin( - monitor_slug=monitor_slug, - check_in_id=check_in_id, - status=MonitorStatus.OK, - duration=duration_s, - ) + + if TYPE_CHECKING: + + @overload + def __call__( + self, fn: "Callable[P, Awaitable[Any]]" + ) -> "Callable[P, Awaitable[Any]]": + # Unfortunately, mypy does not give us any reliable way to type check the + # return value of an Awaitable (i.e. async function) for this overload, + # since calling iscouroutinefunction narrows the type to Callable[P, Awaitable[Any]]. + ... + + @overload + def __call__(self, fn: "Callable[P, R]") -> "Callable[P, R]": ... + + def __call__( + self, + fn: "Union[Callable[P, R], Callable[P, Awaitable[Any]]]", + ) -> "Union[Callable[P, R], Callable[P, Awaitable[Any]]]": + if iscoroutinefunction(fn): + return self._async_wrapper(fn) + + else: + if TYPE_CHECKING: + fn = cast("Callable[P, R]", fn) + return self._sync_wrapper(fn) + + def _async_wrapper( + self, fn: "Callable[P, Awaitable[Any]]" + ) -> "Callable[P, Awaitable[Any]]": + @wraps(fn) + async def inner(*args: "P.args", **kwargs: "P.kwargs") -> "R": + with self: + return await fn(*args, **kwargs) + + return inner + + def _sync_wrapper(self, fn: "Callable[P, R]") -> "Callable[P, R]": + @wraps(fn) + def inner(*args: "P.args", **kwargs: "P.kwargs") -> "R": + with self: + return fn(*args, **kwargs) + + return inner diff --git a/sentry_sdk/db/explain_plan/__init__.py b/sentry_sdk/db/explain_plan/__init__.py deleted file mode 100644 index 2699b6f49e..0000000000 --- a/sentry_sdk/db/explain_plan/__init__.py +++ /dev/null @@ -1,61 +0,0 @@ -import datetime - -from sentry_sdk._compat import datetime_utcnow -from sentry_sdk.consts import TYPE_CHECKING - -if TYPE_CHECKING: - from typing import Any - - -EXPLAIN_CACHE = {} -EXPLAIN_CACHE_SIZE = 50 -EXPLAIN_CACHE_TIMEOUT_SECONDS = 60 * 60 * 24 - - -def cache_statement(statement, options): - # type: (str, dict[str, Any]) -> None - global EXPLAIN_CACHE - - now = datetime_utcnow() - explain_cache_timeout_seconds = options.get( - "explain_cache_timeout_seconds", EXPLAIN_CACHE_TIMEOUT_SECONDS - ) - expiration_time = now + datetime.timedelta(seconds=explain_cache_timeout_seconds) - - EXPLAIN_CACHE[hash(statement)] = expiration_time - - -def remove_expired_cache_items(): - # type: () -> None - """ - Remove expired cache items from the cache. - """ - global EXPLAIN_CACHE - - now = datetime_utcnow() - - for key, expiration_time in EXPLAIN_CACHE.items(): - expiration_in_the_past = expiration_time < now - if expiration_in_the_past: - del EXPLAIN_CACHE[key] - - -def should_run_explain_plan(statement, options): - # type: (str, dict[str, Any]) -> bool - """ - Check cache if the explain plan for the given statement should be run. - """ - global EXPLAIN_CACHE - - remove_expired_cache_items() - - key = hash(statement) - if key in EXPLAIN_CACHE: - return False - - explain_cache_size = options.get("explain_cache_size", EXPLAIN_CACHE_SIZE) - cache_is_full = len(EXPLAIN_CACHE.keys()) >= explain_cache_size - if cache_is_full: - return False - - return True diff --git a/sentry_sdk/db/explain_plan/django.py b/sentry_sdk/db/explain_plan/django.py deleted file mode 100644 index b395f1c82b..0000000000 --- a/sentry_sdk/db/explain_plan/django.py +++ /dev/null @@ -1,47 +0,0 @@ -from sentry_sdk.consts import TYPE_CHECKING -from sentry_sdk.db.explain_plan import cache_statement, should_run_explain_plan - -if TYPE_CHECKING: - from typing import Any - from typing import Callable - - from sentry_sdk.tracing import Span - - -def attach_explain_plan_to_span( - span, connection, statement, parameters, mogrify, options -): - # type: (Span, Any, str, Any, Callable[[str, Any], bytes], dict[str, Any]) -> None - """ - Run EXPLAIN or EXPLAIN ANALYZE on the given statement and attach the explain plan to the span data. - - Usage: - ``` - sentry_sdk.init( - dsn="...", - _experiments={ - "attach_explain_plans": { - "explain_cache_size": 1000, # Run explain plan for the 1000 most run queries - "explain_cache_timeout_seconds": 60 * 60 * 24, # Run the explain plan for each statement only every 24 hours - "use_explain_analyze": True, # Run "explain analyze" instead of only "explain" - } - } - ``` - """ - if not statement.strip().upper().startswith("SELECT"): - return - - if not should_run_explain_plan(statement, options): - return - - analyze = "ANALYZE" if options.get("use_explain_analyze", False) else "" - explain_statement = ("EXPLAIN %s " % analyze) + mogrify( - statement, parameters - ).decode("utf-8") - - with connection.cursor() as cursor: - cursor.execute(explain_statement) - explain_plan = [row for row in cursor.fetchall()] - - span.set_data("db.explain_plan", explain_plan) - cache_statement(statement, options) diff --git a/sentry_sdk/db/explain_plan/sqlalchemy.py b/sentry_sdk/db/explain_plan/sqlalchemy.py deleted file mode 100644 index fac0729f70..0000000000 --- a/sentry_sdk/db/explain_plan/sqlalchemy.py +++ /dev/null @@ -1,49 +0,0 @@ -from __future__ import absolute_import - -from sentry_sdk.consts import TYPE_CHECKING -from sentry_sdk.db.explain_plan import cache_statement, should_run_explain_plan -from sentry_sdk.integrations import DidNotEnable - -try: - from sqlalchemy.sql import text # type: ignore -except ImportError: - raise DidNotEnable("SQLAlchemy not installed.") - -if TYPE_CHECKING: - from typing import Any - - from sentry_sdk.tracing import Span - - -def attach_explain_plan_to_span(span, connection, statement, parameters, options): - # type: (Span, Any, str, Any, dict[str, Any]) -> None - """ - Run EXPLAIN or EXPLAIN ANALYZE on the given statement and attach the explain plan to the span data. - - Usage: - ``` - sentry_sdk.init( - dsn="...", - _experiments={ - "attach_explain_plans": { - "explain_cache_size": 1000, # Run explain plan for the 1000 most run queries - "explain_cache_timeout_seconds": 60 * 60 * 24, # Run the explain plan for each statement only every 24 hours - "use_explain_analyze": True, # Run "explain analyze" instead of only "explain" - } - } - ``` - """ - if not statement.strip().upper().startswith("SELECT"): - return - - if not should_run_explain_plan(statement, options): - return - - analyze = "ANALYZE" if options.get("use_explain_analyze", False) else "" - explain_statement = (("EXPLAIN %s " % analyze) + statement) % parameters - - result = connection.execute(text(explain_statement)) - explain_plan = [row for row in result] - - span.set_data("db.explain_plan", explain_plan) - cache_statement(statement, options) diff --git a/sentry_sdk/debug.py b/sentry_sdk/debug.py index fe8ae50cea..513ba8813f 100644 --- a/sentry_sdk/debug.py +++ b/sentry_sdk/debug.py @@ -1,44 +1,37 @@ import sys import logging +import warnings -from sentry_sdk import utils -from sentry_sdk.hub import Hub -from sentry_sdk.utils import logger +from sentry_sdk import get_client from sentry_sdk.client import _client_init_debug +from sentry_sdk.utils import logger from logging import LogRecord -class _HubBasedClientFilter(logging.Filter): - def filter(self, record): - # type: (LogRecord) -> bool +class _DebugFilter(logging.Filter): + def filter(self, record: "LogRecord") -> bool: if _client_init_debug.get(False): return True - hub = Hub.current - if hub is not None and hub.client is not None: - return hub.client.options["debug"] - return False + return get_client().options["debug"] -def init_debug_support(): - # type: () -> None + +def init_debug_support() -> None: if not logger.handlers: configure_logger() - configure_debug_hub() -def configure_logger(): - # type: () -> None +def configure_logger() -> None: _handler = logging.StreamHandler(sys.stderr) _handler.setFormatter(logging.Formatter(" [sentry] %(levelname)s: %(message)s")) logger.addHandler(_handler) logger.setLevel(logging.DEBUG) - logger.addFilter(_HubBasedClientFilter()) - + logger.addFilter(_DebugFilter()) -def configure_debug_hub(): - # type: () -> None - def _get_debug_hub(): - # type: () -> Hub - return Hub.current - utils._get_debug_hub = _get_debug_hub +def configure_debug_hub() -> None: + warnings.warn( + "configure_debug_hub is deprecated. Please remove calls to it, as it is a no-op.", + DeprecationWarning, + stacklevel=2, + ) diff --git a/sentry_sdk/envelope.py b/sentry_sdk/envelope.py index a3e4b5a940..307fb26fd6 100644 --- a/sentry_sdk/envelope.py +++ b/sentry_sdk/envelope.py @@ -2,11 +2,11 @@ import json import mimetypes -from sentry_sdk._compat import text_type, PY2 -from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.session import Session from sentry_sdk.utils import json_dumps, capture_internal_exceptions +from typing import TYPE_CHECKING + if TYPE_CHECKING: from typing import Any from typing import Optional @@ -18,21 +18,25 @@ from sentry_sdk._types import Event, EventDataCategory -def parse_json(data): - # type: (Union[bytes, text_type]) -> Any +def parse_json(data: "Union[bytes, str]") -> "Any": # on some python 3 versions this needs to be bytes - if not PY2 and isinstance(data, bytes): + if isinstance(data, bytes): data = data.decode("utf-8", "replace") return json.loads(data) -class Envelope(object): +class Envelope: + """ + Represents a Sentry Envelope. The calling code is responsible for adhering to the constraints + documented in the Sentry docs: https://develop.sentry.dev/sdk/envelopes/#data-model. In particular, + each envelope may have at most one Item with type "event" or "transaction" (but not both). + """ + def __init__( self, - headers=None, # type: Optional[Dict[str, Any]] - items=None, # type: Optional[List[Item]] - ): - # type: (...) -> None + headers: "Optional[Dict[str, Any]]" = None, + items: "Optional[List[Item]]" = None, + ) -> None: if headers is not None: headers = dict(headers) self.headers = headers or {} @@ -43,97 +47,104 @@ def __init__( self.items = items @property - def description(self): - # type: (...) -> str + def description(self) -> str: return "envelope with %s items (%s)" % ( len(self.items), ", ".join(x.data_category for x in self.items), ) def add_event( - self, event # type: Event - ): - # type: (...) -> None + self, + event: "Event", + ) -> None: self.add_item(Item(payload=PayloadRef(json=event), type="event")) def add_transaction( - self, transaction # type: Event - ): - # type: (...) -> None + self, + transaction: "Event", + ) -> None: self.add_item(Item(payload=PayloadRef(json=transaction), type="transaction")) def add_profile( - self, profile # type: Any - ): - # type: (...) -> None + self, + profile: "Any", + ) -> None: self.add_item(Item(payload=PayloadRef(json=profile), type="profile")) + def add_profile_chunk( + self, + profile_chunk: "Any", + ) -> None: + self.add_item( + Item( + payload=PayloadRef(json=profile_chunk), + type="profile_chunk", + headers={"platform": profile_chunk.get("platform", "python")}, + ) + ) + def add_checkin( - self, checkin # type: Any - ): - # type: (...) -> None + self, + checkin: "Any", + ) -> None: self.add_item(Item(payload=PayloadRef(json=checkin), type="check_in")) def add_session( - self, session # type: Union[Session, Any] - ): - # type: (...) -> None + self, + session: "Union[Session, Any]", + ) -> None: if isinstance(session, Session): session = session.to_json() self.add_item(Item(payload=PayloadRef(json=session), type="session")) def add_sessions( - self, sessions # type: Any - ): - # type: (...) -> None + self, + sessions: "Any", + ) -> None: self.add_item(Item(payload=PayloadRef(json=sessions), type="sessions")) def add_item( - self, item # type: Item - ): - # type: (...) -> None + self, + item: "Item", + ) -> None: self.items.append(item) - def get_event(self): - # type: (...) -> Optional[Event] + def get_event(self) -> "Optional[Event]": for items in self.items: event = items.get_event() if event is not None: return event return None - def get_transaction_event(self): - # type: (...) -> Optional[Event] + def get_transaction_event(self) -> "Optional[Event]": for item in self.items: event = item.get_transaction_event() if event is not None: return event return None - def __iter__(self): - # type: (...) -> Iterator[Item] + def __iter__(self) -> "Iterator[Item]": return iter(self.items) def serialize_into( - self, f # type: Any - ): - # type: (...) -> None + self, + f: "Any", + ) -> None: f.write(json_dumps(self.headers)) f.write(b"\n") for item in self.items: item.serialize_into(f) - def serialize(self): - # type: (...) -> bytes + def serialize(self) -> bytes: out = io.BytesIO() self.serialize_into(out) return out.getvalue() @classmethod def deserialize_from( - cls, f # type: Any - ): - # type: (...) -> Envelope + cls, + f: "Any", + ) -> "Envelope": headers = parse_json(f.readline()) items = [] while 1: @@ -145,30 +156,27 @@ def deserialize_from( @classmethod def deserialize( - cls, bytes # type: bytes - ): - # type: (...) -> Envelope + cls, + bytes: bytes, + ) -> "Envelope": return cls.deserialize_from(io.BytesIO(bytes)) - def __repr__(self): - # type: (...) -> str + def __repr__(self) -> str: return "" % (self.headers, self.items) -class PayloadRef(object): +class PayloadRef: def __init__( self, - bytes=None, # type: Optional[bytes] - path=None, # type: Optional[Union[bytes, text_type]] - json=None, # type: Optional[Any] - ): - # type: (...) -> None + bytes: "Optional[bytes]" = None, + path: "Optional[Union[bytes, str]]" = None, + json: "Optional[Any]" = None, + ) -> None: self.json = json self.bytes = bytes self.path = path - def get_bytes(self): - # type: (...) -> bytes + def get_bytes(self) -> bytes: if self.bytes is None: if self.path is not None: with capture_internal_exceptions(): @@ -176,13 +184,10 @@ def get_bytes(self): self.bytes = f.read() elif self.json is not None: self.bytes = json_dumps(self.json) - else: - self.bytes = b"" - return self.bytes + return self.bytes or b"" @property - def inferred_content_type(self): - # type: (...) -> str + def inferred_content_type(self) -> str: if self.json is not None: return "application/json" elif self.path is not None: @@ -194,19 +199,18 @@ def inferred_content_type(self): return ty return "application/octet-stream" - def __repr__(self): - # type: (...) -> str + def __repr__(self) -> str: return "" % (self.inferred_content_type,) -class Item(object): +class Item: def __init__( self, - payload, # type: Union[bytes, text_type, PayloadRef] - headers=None, # type: Optional[Dict[str, Any]] - type=None, # type: Optional[str] - content_type=None, # type: Optional[str] - filename=None, # type: Optional[str] + payload: "Union[bytes, str, PayloadRef]", + headers: "Optional[Dict[str, Any]]" = None, + type: "Optional[str]" = None, + content_type: "Optional[str]" = None, + filename: "Optional[str]" = None, ): if headers is not None: headers = dict(headers) @@ -215,7 +219,7 @@ def __init__( self.headers = headers if isinstance(payload, bytes): payload = PayloadRef(bytes=payload) - elif isinstance(payload, text_type): + elif isinstance(payload, str): payload = PayloadRef(bytes=payload.encode("utf-8")) else: payload = payload @@ -231,8 +235,7 @@ def __init__( self.payload = payload - def __repr__(self): - # type: (...) -> str + def __repr__(self) -> str: return "" % ( self.headers, self.payload, @@ -240,15 +243,13 @@ def __repr__(self): ) @property - def type(self): - # type: (...) -> Optional[str] + def type(self) -> "Optional[str]": return self.headers.get("type") @property - def data_category(self): - # type: (...) -> EventDataCategory + def data_category(self) -> "EventDataCategory": ty = self.headers.get("type") - if ty == "session": + if ty == "session" or ty == "sessions": return "session" elif ty == "attachment": return "attachment" @@ -256,21 +257,25 @@ def data_category(self): return "transaction" elif ty == "event": return "error" + elif ty == "log": + return "log_item" + elif ty == "trace_metric": + return "trace_metric" elif ty == "client_report": return "internal" elif ty == "profile": return "profile" - elif ty == "statsd": - return "statsd" + elif ty == "profile_chunk": + return "profile_chunk" + elif ty == "check_in": + return "monitor" else: return "default" - def get_bytes(self): - # type: (...) -> bytes + def get_bytes(self) -> bytes: return self.payload.get_bytes() - def get_event(self): - # type: (...) -> Optional[Event] + def get_event(self) -> "Optional[Event]": """ Returns an error event if there is one. """ @@ -278,16 +283,15 @@ def get_event(self): return self.payload.json return None - def get_transaction_event(self): - # type: (...) -> Optional[Event] + def get_transaction_event(self) -> "Optional[Event]": if self.type == "transaction" and self.payload.json is not None: return self.payload.json return None def serialize_into( - self, f # type: Any - ): - # type: (...) -> None + self, + f: "Any", + ) -> None: headers = dict(self.headers) bytes = self.get_bytes() headers["length"] = len(bytes) @@ -296,17 +300,16 @@ def serialize_into( f.write(bytes) f.write(b"\n") - def serialize(self): - # type: (...) -> bytes + def serialize(self) -> bytes: out = io.BytesIO() self.serialize_into(out) return out.getvalue() @classmethod def deserialize_from( - cls, f # type: Any - ): - # type: (...) -> Optional[Item] + cls, + f: "Any", + ) -> "Optional[Item]": line = f.readline().rstrip() if not line: return None @@ -319,7 +322,7 @@ def deserialize_from( # if no length was specified we need to read up to the end of line # and remove it (if it is present, i.e. not the very last char in an eof terminated envelope) payload = f.readline().rstrip(b"\n") - if headers.get("type") in ("event", "transaction", "metric_buckets"): + if headers.get("type") in ("event", "transaction"): rv = cls(headers=headers, payload=PayloadRef(json=parse_json(payload))) else: rv = cls(headers=headers, payload=payload) @@ -327,7 +330,7 @@ def deserialize_from( @classmethod def deserialize( - cls, bytes # type: bytes - ): - # type: (...) -> Optional[Item] + cls, + bytes: bytes, + ) -> "Optional[Item]": return cls.deserialize_from(io.BytesIO(bytes)) diff --git a/sentry_sdk/feature_flags.py b/sentry_sdk/feature_flags.py new file mode 100644 index 0000000000..c9f3f303f9 --- /dev/null +++ b/sentry_sdk/feature_flags.py @@ -0,0 +1,65 @@ +import copy +import sentry_sdk +from sentry_sdk._lru_cache import LRUCache +from threading import Lock + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from typing import TypedDict + + FlagData = TypedDict("FlagData", {"flag": str, "result": bool}) + + +DEFAULT_FLAG_CAPACITY = 100 + + +class FlagBuffer: + def __init__(self, capacity: int) -> None: + self.capacity = capacity + self.lock = Lock() + + # Buffer is private. The name is mangled to discourage use. If you use this attribute + # directly you're on your own! + self.__buffer = LRUCache(capacity) + + def clear(self) -> None: + self.__buffer = LRUCache(self.capacity) + + def __deepcopy__(self, memo: "dict[int, Any]") -> "FlagBuffer": + with self.lock: + buffer = FlagBuffer(self.capacity) + buffer.__buffer = copy.deepcopy(self.__buffer, memo) + return buffer + + def get(self) -> "list[FlagData]": + with self.lock: + return [ + {"flag": key, "result": value} for key, value in self.__buffer.get_all() + ] + + def set(self, flag: str, result: bool) -> None: + if isinstance(result, FlagBuffer): + # If someone were to insert `self` into `self` this would create a circular dependency + # on the lock. This is of course a deadlock. However, this is far outside the expected + # usage of this class. We guard against it here for completeness and to document this + # expected failure mode. + raise ValueError( + "FlagBuffer instances can not be inserted into the dictionary." + ) + + with self.lock: + self.__buffer.set(flag, result) + + +def add_feature_flag(flag: str, result: bool) -> None: + """ + Records a flag and its value to be sent on subsequent error events. + We recommend you do this on flag evaluations. Flags are buffered per Sentry scope. + """ + flags = sentry_sdk.get_isolation_scope().flags + flags.set(flag, result) + + span = sentry_sdk.get_current_span() + if span: + span.set_flag(f"flag.evaluation.{flag}", result) diff --git a/sentry_sdk/hub.py b/sentry_sdk/hub.py index 2525dc56f1..0e5d7df9f9 100644 --- a/sentry_sdk/hub.py +++ b/sentry_sdk/hub.py @@ -1,423 +1,404 @@ -import copy -import sys - +import warnings from contextlib import contextmanager +from typing import TYPE_CHECKING -from sentry_sdk._compat import datetime_utcnow, with_metaclass -from sentry_sdk.consts import INSTRUMENTER -from sentry_sdk.scope import Scope +from sentry_sdk import ( + get_client, + get_current_scope, + get_global_scope, + get_isolation_scope, +) +from sentry_sdk._compat import with_metaclass from sentry_sdk.client import Client -from sentry_sdk.profiler import Profile +from sentry_sdk.consts import INSTRUMENTER +from sentry_sdk.scope import _ScopeManager from sentry_sdk.tracing import ( NoOpSpan, Span, Transaction, - BAGGAGE_HEADER_NAME, - SENTRY_TRACE_HEADER_NAME, -) -from sentry_sdk.session import Session -from sentry_sdk.tracing_utils import ( - has_tracing_enabled, - normalize_incoming_data, ) - from sentry_sdk.utils import ( - exc_info_from_error, - event_from_exception, - logger, ContextVar, + logger, ) -from sentry_sdk._types import TYPE_CHECKING - if TYPE_CHECKING: - from typing import Union - from typing import Any - from typing import Optional - from typing import Tuple - from typing import Dict - from typing import List - from typing import Callable - from typing import Generator - from typing import Type - from typing import TypeVar - from typing import overload - from typing import ContextManager + from typing import ( + Any, + Callable, + ContextManager, + Dict, + Generator, + List, + Optional, + Tuple, + Type, + TypeVar, + Union, + overload, + ) + + from typing_extensions import Unpack - from sentry_sdk.integrations import Integration from sentry_sdk._types import ( - Event, - Hint, Breadcrumb, BreadcrumbHint, + Event, ExcInfo, + Hint, + LogLevelStr, + SamplingContext, ) - from sentry_sdk.consts import ClientConstructor + from sentry_sdk.client import BaseClient + from sentry_sdk.integrations import Integration + from sentry_sdk.scope import Scope + from sentry_sdk.tracing import TransactionKwargs T = TypeVar("T") else: - def overload(x): - # type: (T) -> T + def overload(x: "T") -> "T": return x -_local = ContextVar("sentry_current_hub") - - -def _update_scope(base, scope_change, scope_kwargs): - # type: (Scope, Optional[Any], Dict[str, Any]) -> Scope - if scope_change and scope_kwargs: - raise TypeError("cannot provide scope and kwargs") - if scope_change is not None: - final_scope = copy.copy(base) - if callable(scope_change): - scope_change(final_scope) - else: - final_scope.update_from_scope(scope_change) - elif scope_kwargs: - final_scope = copy.copy(base) - final_scope.update_from_kwargs(**scope_kwargs) - else: - final_scope = base - return final_scope - - -def _should_send_default_pii(): - # type: () -> bool - client = Hub.current.client - if not client: - return False - return client.options["send_default_pii"] - - -class _InitGuard(object): - def __init__(self, client): - # type: (Client) -> None - self._client = client - - def __enter__(self): - # type: () -> _InitGuard - return self - - def __exit__(self, exc_type, exc_value, tb): - # type: (Any, Any, Any) -> None - c = self._client - if c is not None: - c.close() - - -def _check_python_deprecations(): - # type: () -> None - version = sys.version_info[:2] - - if version == (3, 4) or version == (3, 5): - logger.warning( - "sentry-sdk 2.0.0 will drop support for Python %s.", - "{}.{}".format(*version), - ) - logger.warning( - "Please upgrade to the latest version to continue receiving upgrades and bugfixes." - ) - - -def _init(*args, **kwargs): - # type: (*Optional[str], **Any) -> ContextManager[Any] - """Initializes the SDK and optionally integrations. - - This takes the same arguments as the client constructor. +class SentryHubDeprecationWarning(DeprecationWarning): + """ + A custom deprecation warning to inform users that the Hub is deprecated. """ - client = Client(*args, **kwargs) # type: ignore - Hub.current.bind_client(client) - _check_python_deprecations() - rv = _InitGuard(client) - return rv + _MESSAGE = ( + "`sentry_sdk.Hub` is deprecated and will be removed in a future major release. " + "Please consult our 1.x to 2.x migration guide for details on how to migrate " + "`Hub` usage to the new API: " + "https://docs.sentry.io/platforms/python/migration/1.x-to-2.x" + ) -from sentry_sdk._types import TYPE_CHECKING + def __init__(self, *_: object) -> None: + super().__init__(self._MESSAGE) -if TYPE_CHECKING: - # Make mypy, PyCharm and other static analyzers think `init` is a type to - # have nicer autocompletion for params. - # - # Use `ClientConstructor` to define the argument types of `init` and - # `ContextManager[Any]` to tell static analyzers about the return type. - class init(ClientConstructor, _InitGuard): # noqa: N801 - pass +@contextmanager +def _suppress_hub_deprecation_warning() -> "Generator[None, None, None]": + """Utility function to suppress deprecation warnings for the Hub.""" + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=SentryHubDeprecationWarning) + yield -else: - # Alias `init` for actual usage. Go through the lambda indirection to throw - # PyCharm off of the weakly typed signature (it would otherwise discover - # both the weakly typed signature of `_init` and our faked `init` type). - init = (lambda: _init)() +_local = ContextVar("sentry_current_hub") class HubMeta(type): @property - def current(cls): - # type: () -> Hub + def current(cls) -> "Hub": """Returns the current instance of the hub.""" + warnings.warn(SentryHubDeprecationWarning(), stacklevel=2) rv = _local.get(None) if rv is None: - rv = Hub(GLOBAL_HUB) + with _suppress_hub_deprecation_warning(): + # This will raise a deprecation warning; suppress it since we already warned above. + rv = Hub(GLOBAL_HUB) _local.set(rv) return rv @property - def main(cls): - # type: () -> Hub + def main(cls) -> "Hub": """Returns the main instance of the hub.""" + warnings.warn(SentryHubDeprecationWarning(), stacklevel=2) return GLOBAL_HUB -class _ScopeManager(object): - def __init__(self, hub): - # type: (Hub) -> None - self._hub = hub - self._original_len = len(hub._stack) - self._layer = hub._stack[-1] - - def __enter__(self): - # type: () -> Scope - scope = self._layer[1] - assert scope is not None - return scope - - def __exit__(self, exc_type, exc_value, tb): - # type: (Any, Any, Any) -> None - current_len = len(self._hub._stack) - if current_len < self._original_len: - logger.error( - "Scope popped too soon. Popped %s scopes too many.", - self._original_len - current_len, - ) - return - elif current_len > self._original_len: - logger.warning( - "Leaked %s scopes: %s", - current_len - self._original_len, - self._hub._stack[self._original_len :], - ) - - layer = self._hub._stack[self._original_len - 1] - del self._hub._stack[self._original_len - 1 :] - - if layer[1] != self._layer[1]: - logger.error( - "Wrong scope found. Meant to pop %s, but popped %s.", - layer[1], - self._layer[1], - ) - elif layer[0] != self._layer[0]: - warning = ( - "init() called inside of pushed scope. This might be entirely " - "legitimate but usually occurs when initializing the SDK inside " - "a request handler or task/job function. Try to initialize the " - "SDK as early as possible instead." - ) - logger.warning(warning) - - class Hub(with_metaclass(HubMeta)): # type: ignore - """The hub wraps the concurrency management of the SDK. Each thread has + """ + .. deprecated:: 2.0.0 + The Hub is deprecated. Its functionality will be merged into :py:class:`sentry_sdk.scope.Scope`. + + The hub wraps the concurrency management of the SDK. Each thread has its own hub but the hub might transfer with the flow of execution if context vars are available. If the hub is used with a with statement it's temporarily activated. """ - _stack = None # type: List[Tuple[Optional[Client], Scope]] + _stack: "List[Tuple[Optional[Client], Scope]]" = None # type: ignore[assignment] + _scope: "Optional[Scope]" = None # Mypy doesn't pick up on the metaclass. if TYPE_CHECKING: - current = None # type: Hub - main = None # type: Hub + current: "Hub" = None # type: ignore[assignment] + main: "Optional[Hub]" = None def __init__( self, - client_or_hub=None, # type: Optional[Union[Hub, Client]] - scope=None, # type: Optional[Any] - ): - # type: (...) -> None + client_or_hub: "Optional[Union[Hub, Client]]" = None, + scope: "Optional[Any]" = None, + ) -> None: + warnings.warn(SentryHubDeprecationWarning(), stacklevel=2) + + current_scope = None + if isinstance(client_or_hub, Hub): - hub = client_or_hub - client, other_scope = hub._stack[-1] + client = get_client() if scope is None: - scope = copy.copy(other_scope) + # hub cloning is going on, we use a fork of the current/isolation scope for context manager + scope = get_isolation_scope().fork() + current_scope = get_current_scope().fork() else: - client = client_or_hub - if scope is None: - scope = Scope() + client = client_or_hub # type: ignore + get_global_scope().set_client(client) + + if scope is None: # so there is no Hub cloning going on + # just the current isolation scope is used for context manager + scope = get_isolation_scope() + current_scope = get_current_scope() - self._stack = [(client, scope)] - self._last_event_id = None # type: Optional[str] - self._old_hubs = [] # type: List[Hub] + if current_scope is None: + # just the current current scope is used for context manager + current_scope = get_current_scope() - def __enter__(self): - # type: () -> Hub + self._stack = [(client, scope)] # type: ignore + self._last_event_id: "Optional[str]" = None + self._old_hubs: "List[Hub]" = [] + + self._old_current_scopes: "List[Scope]" = [] + self._old_isolation_scopes: "List[Scope]" = [] + self._current_scope: "Scope" = current_scope + self._scope: "Scope" = scope + + def __enter__(self) -> "Hub": self._old_hubs.append(Hub.current) _local.set(self) + + current_scope = get_current_scope() + self._old_current_scopes.append(current_scope) + scope._current_scope.set(self._current_scope) + + isolation_scope = get_isolation_scope() + self._old_isolation_scopes.append(isolation_scope) + scope._isolation_scope.set(self._scope) + return self def __exit__( self, - exc_type, # type: Optional[type] - exc_value, # type: Optional[BaseException] - tb, # type: Optional[Any] - ): - # type: (...) -> None + exc_type: "Optional[type]", + exc_value: "Optional[BaseException]", + tb: "Optional[Any]", + ) -> None: old = self._old_hubs.pop() _local.set(old) + old_current_scope = self._old_current_scopes.pop() + scope._current_scope.set(old_current_scope) + + old_isolation_scope = self._old_isolation_scopes.pop() + scope._isolation_scope.set(old_isolation_scope) + def run( - self, callback # type: Callable[[], T] - ): - # type: (...) -> T - """Runs a callback in the context of the hub. Alternatively the + self, + callback: "Callable[[], T]", + ) -> "T": + """ + .. deprecated:: 2.0.0 + This function is deprecated and will be removed in a future release. + + Runs a callback in the context of the hub. Alternatively the with statement can be used on the hub directly. """ with self: return callback() def get_integration( - self, name_or_class # type: Union[str, Type[Integration]] - ): - # type: (...) -> Any - """Returns the integration for this hub by name or class. If there + self, + name_or_class: "Union[str, Type[Integration]]", + ) -> "Any": + """ + .. deprecated:: 2.0.0 + This function is deprecated and will be removed in a future release. + Please use :py:meth:`sentry_sdk.client._Client.get_integration` instead. + + Returns the integration for this hub by name or class. If there is no client bound or the client does not have that integration then `None` is returned. If the return value is not `None` the hub is guaranteed to have a client attached. """ - if isinstance(name_or_class, str): - integration_name = name_or_class - elif name_or_class.identifier is not None: - integration_name = name_or_class.identifier - else: - raise ValueError("Integration has no name") - - client = self.client - if client is not None: - rv = client.integrations.get(integration_name) - if rv is not None: - return rv + return get_client().get_integration(name_or_class) @property - def client(self): - # type: () -> Optional[Client] - """Returns the current client on the hub.""" - return self._stack[-1][0] + def client(self) -> "Optional[BaseClient]": + """ + .. deprecated:: 2.0.0 + This property is deprecated and will be removed in a future release. + Please use :py:func:`sentry_sdk.api.get_client` instead. + + Returns the current client on the hub. + """ + client = get_client() + + if not client.is_active(): + return None + + return client @property - def scope(self): - # type: () -> Scope - """Returns the current scope on the hub.""" - return self._stack[-1][1] - - def last_event_id(self): - # type: () -> Optional[str] - """Returns the last event ID.""" + def scope(self) -> "Scope": + """ + .. deprecated:: 2.0.0 + This property is deprecated and will be removed in a future release. + Returns the current scope on the hub. + """ + return get_isolation_scope() + + def last_event_id(self) -> "Optional[str]": + """ + Returns the last event ID. + + .. deprecated:: 1.40.5 + This function is deprecated and will be removed in a future release. The functions `capture_event`, `capture_message`, and `capture_exception` return the event ID directly. + """ + logger.warning( + "Deprecated: last_event_id is deprecated. This will be removed in the future. The functions `capture_event`, `capture_message`, and `capture_exception` return the event ID directly." + ) return self._last_event_id def bind_client( - self, new # type: Optional[Client] - ): - # type: (...) -> None - """Binds a new client to the hub.""" - top = self._stack[-1] - self._stack[-1] = (new, top[1]) + self, + new: "Optional[BaseClient]", + ) -> None: + """ + .. deprecated:: 2.0.0 + This function is deprecated and will be removed in a future release. + Please use :py:meth:`sentry_sdk.Scope.set_client` instead. + + Binds a new client to the hub. + """ + get_global_scope().set_client(new) - def capture_event(self, event, hint=None, scope=None, **scope_args): - # type: (Event, Optional[Hint], Optional[Scope], Any) -> Optional[str] + def capture_event( + self, + event: "Event", + hint: "Optional[Hint]" = None, + scope: "Optional[Scope]" = None, + **scope_kwargs: "Any", + ) -> "Optional[str]": """ + .. deprecated:: 2.0.0 + This function is deprecated and will be removed in a future release. + Please use :py:meth:`sentry_sdk.Scope.capture_event` instead. + Captures an event. - Alias of :py:meth:`sentry_sdk.Client.capture_event`. + Alias of :py:meth:`sentry_sdk.Scope.capture_event`. + + :param event: A ready-made event that can be directly sent to Sentry. - :param scope_args: For supported `**scope_args` see - :py:meth:`sentry_sdk.Scope.update_from_kwargs`. + :param hint: Contains metadata about the event that can be read from `before_send`, such as the original exception object or a HTTP request object. + + :param scope: An optional :py:class:`sentry_sdk.Scope` to apply to events. + The `scope` and `scope_kwargs` parameters are mutually exclusive. + + :param scope_kwargs: Optional data to apply to event. + For supported `**scope_kwargs` see :py:meth:`sentry_sdk.Scope.update_from_kwargs`. + The `scope` and `scope_kwargs` parameters are mutually exclusive. """ - client, top_scope = self._stack[-1] - scope = _update_scope(top_scope, scope, scope_args) - if client is not None: - is_transaction = event.get("type") == "transaction" - rv = client.capture_event(event, hint, scope) - if rv is not None and not is_transaction: - self._last_event_id = rv - return rv - return None + last_event_id = get_current_scope().capture_event( + event, hint, scope=scope, **scope_kwargs + ) + + is_transaction = event.get("type") == "transaction" + if last_event_id is not None and not is_transaction: + self._last_event_id = last_event_id - def capture_message(self, message, level=None, scope=None, **scope_args): - # type: (str, Optional[str], Optional[Scope], Any) -> Optional[str] + return last_event_id + + def capture_message( + self, + message: str, + level: "Optional[LogLevelStr]" = None, + scope: "Optional[Scope]" = None, + **scope_kwargs: "Any", + ) -> "Optional[str]": """ + .. deprecated:: 2.0.0 + This function is deprecated and will be removed in a future release. + Please use :py:meth:`sentry_sdk.Scope.capture_message` instead. + Captures a message. - :param message: The string to send as the message. + Alias of :py:meth:`sentry_sdk.Scope.capture_message`. + + :param message: The string to send as the message to Sentry. :param level: If no level is provided, the default level is `info`. - :param scope: An optional :py:class:`sentry_sdk.Scope` to use. + :param scope: An optional :py:class:`sentry_sdk.Scope` to apply to events. + The `scope` and `scope_kwargs` parameters are mutually exclusive. - :param scope_args: For supported `**scope_args` see - :py:meth:`sentry_sdk.Scope.update_from_kwargs`. + :param scope_kwargs: Optional data to apply to event. + For supported `**scope_kwargs` see :py:meth:`sentry_sdk.Scope.update_from_kwargs`. + The `scope` and `scope_kwargs` parameters are mutually exclusive. - :returns: An `event_id` if the SDK decided to send the event (see :py:meth:`sentry_sdk.Client.capture_event`). + :returns: An `event_id` if the SDK decided to send the event (see :py:meth:`sentry_sdk.client._Client.capture_event`). """ - if self.client is None: - return None - if level is None: - level = "info" - return self.capture_event( - {"message": message, "level": level}, scope=scope, **scope_args + last_event_id = get_current_scope().capture_message( + message, level=level, scope=scope, **scope_kwargs ) - def capture_exception(self, error=None, scope=None, **scope_args): - # type: (Optional[Union[BaseException, ExcInfo]], Optional[Scope], Any) -> Optional[str] - """Captures an exception. - - :param error: An exception to catch. If `None`, `sys.exc_info()` will be used. + if last_event_id is not None: + self._last_event_id = last_event_id - :param scope_args: For supported `**scope_args` see - :py:meth:`sentry_sdk.Scope.update_from_kwargs`. + return last_event_id - :returns: An `event_id` if the SDK decided to send the event (see :py:meth:`sentry_sdk.Client.capture_event`). + def capture_exception( + self, + error: "Optional[Union[BaseException, ExcInfo]]" = None, + scope: "Optional[Scope]" = None, + **scope_kwargs: "Any", + ) -> "Optional[str]": """ - client = self.client - if client is None: - return None - if error is not None: - exc_info = exc_info_from_error(error) - else: - exc_info = sys.exc_info() + .. deprecated:: 2.0.0 + This function is deprecated and will be removed in a future release. + Please use :py:meth:`sentry_sdk.Scope.capture_exception` instead. - event, hint = event_from_exception(exc_info, client_options=client.options) - try: - return self.capture_event(event, hint=hint, scope=scope, **scope_args) - except Exception: - self._capture_internal_exception(sys.exc_info()) + Captures an exception. - return None + Alias of :py:meth:`sentry_sdk.Scope.capture_exception`. - def _capture_internal_exception( - self, exc_info # type: Any - ): - # type: (...) -> Any - """ - Capture an exception that is likely caused by a bug in the SDK - itself. + :param error: An exception to capture. If `None`, `sys.exc_info()` will be used. + + :param scope: An optional :py:class:`sentry_sdk.Scope` to apply to events. + The `scope` and `scope_kwargs` parameters are mutually exclusive. - These exceptions do not end up in Sentry and are just logged instead. + :param scope_kwargs: Optional data to apply to event. + For supported `**scope_kwargs` see :py:meth:`sentry_sdk.Scope.update_from_kwargs`. + The `scope` and `scope_kwargs` parameters are mutually exclusive. + + :returns: An `event_id` if the SDK decided to send the event (see :py:meth:`sentry_sdk.client._Client.capture_event`). """ - logger.error("Internal error in sentry_sdk", exc_info=exc_info) + last_event_id = get_current_scope().capture_exception( + error, scope=scope, **scope_kwargs + ) + + if last_event_id is not None: + self._last_event_id = last_event_id - def add_breadcrumb(self, crumb=None, hint=None, **kwargs): - # type: (Optional[Breadcrumb], Optional[BreadcrumbHint], Any) -> None + return last_event_id + + def add_breadcrumb( + self, + crumb: "Optional[Breadcrumb]" = None, + hint: "Optional[BreadcrumbHint]" = None, + **kwargs: "Any", + ) -> None: """ + .. deprecated:: 2.0.0 + This function is deprecated and will be removed in a future release. + Please use :py:meth:`sentry_sdk.Scope.add_breadcrumb` instead. + Adds a breadcrumb. :param crumb: Dictionary with the data as the sentry v7/v8 protocol expects. @@ -425,40 +406,16 @@ def add_breadcrumb(self, crumb=None, hint=None, **kwargs): :param hint: An optional value that can be used by `before_breadcrumb` to customize the breadcrumbs that are emitted. """ - client, scope = self._stack[-1] - if client is None: - logger.info("Dropped breadcrumb because no client bound") - return + get_isolation_scope().add_breadcrumb(crumb, hint, **kwargs) - crumb = dict(crumb or ()) # type: Breadcrumb - crumb.update(kwargs) - if not crumb: - return - - hint = dict(hint or ()) # type: Hint - - if crumb.get("timestamp") is None: - crumb["timestamp"] = datetime_utcnow() - if crumb.get("type") is None: - crumb["type"] = "default" - - if client.options["before_breadcrumb"] is not None: - new_crumb = client.options["before_breadcrumb"](crumb, hint) - else: - new_crumb = crumb - - if new_crumb is not None: - scope._breadcrumbs.append(new_crumb) - else: - logger.info("before breadcrumb dropped breadcrumb (%s)", crumb) - - max_breadcrumbs = client.options["max_breadcrumbs"] # type: int - while len(scope._breadcrumbs) > max_breadcrumbs: - scope._breadcrumbs.popleft() - - def start_span(self, span=None, instrumenter=INSTRUMENTER.SENTRY, **kwargs): - # type: (Optional[Span], str, Any) -> Span + def start_span( + self, instrumenter: str = INSTRUMENTER.SENTRY, **kwargs: "Any" + ) -> "Span": """ + .. deprecated:: 2.0.0 + This function is deprecated and will be removed in a future release. + Please use :py:meth:`sentry_sdk.Scope.start_span` instead. + Start a span whose parent is the currently active span or transaction, if any. The return value is a :py:class:`sentry_sdk.tracing.Span` instance, @@ -473,60 +430,21 @@ def start_span(self, span=None, instrumenter=INSTRUMENTER.SENTRY, **kwargs): For supported `**kwargs` see :py:class:`sentry_sdk.tracing.Span`. """ - configuration_instrumenter = self.client and self.client.options["instrumenter"] - - if instrumenter != configuration_instrumenter: - return NoOpSpan() - - # THIS BLOCK IS DEPRECATED - # TODO: consider removing this in a future release. - # This is for backwards compatibility with releases before - # start_transaction existed, to allow for a smoother transition. - if isinstance(span, Transaction) or "transaction" in kwargs: - deprecation_msg = ( - "Deprecated: use start_transaction to start transactions and " - "Transaction.start_child to start spans." - ) - - if isinstance(span, Transaction): - logger.warning(deprecation_msg) - return self.start_transaction(span) - - if "transaction" in kwargs: - logger.warning(deprecation_msg) - name = kwargs.pop("transaction") - return self.start_transaction(name=name, **kwargs) - - # THIS BLOCK IS DEPRECATED - # We do not pass a span into start_span in our code base, so I deprecate this. - if span is not None: - deprecation_msg = "Deprecated: passing a span into `start_span` is deprecated and will be removed in the future." - logger.warning(deprecation_msg) - return span - - kwargs.setdefault("hub", self) - - active_span = self.scope.span - if active_span is not None: - new_child_span = active_span.start_child(**kwargs) - return new_child_span - - # If there is already a trace_id in the propagation context, use it. - # This does not need to be done for `start_child` above because it takes - # the trace_id from the parent span. - if "trace_id" not in kwargs: - traceparent = self.get_traceparent() - trace_id = traceparent.split("-")[0] if traceparent else None - if trace_id is not None: - kwargs["trace_id"] = trace_id - - return Span(**kwargs) + scope = get_current_scope() + return scope.start_span(instrumenter=instrumenter, **kwargs) def start_transaction( - self, transaction=None, instrumenter=INSTRUMENTER.SENTRY, **kwargs - ): - # type: (Optional[Transaction], str, Any) -> Union[Transaction, NoOpSpan] + self, + transaction: "Optional[Transaction]" = None, + instrumenter: str = INSTRUMENTER.SENTRY, + custom_sampling_context: "Optional[SamplingContext]" = None, + **kwargs: "Unpack[TransactionKwargs]", + ) -> "Union[Transaction, NoOpSpan]": """ + .. deprecated:: 2.0.0 + This function is deprecated and will be removed in a future release. + Please use :py:meth:`sentry_sdk.Scope.start_transaction` instead. + Start and return a transaction. Start an existing transaction if given, otherwise create and start a new @@ -550,77 +468,58 @@ def start_transaction( For supported `**kwargs` see :py:class:`sentry_sdk.tracing.Transaction`. """ - configuration_instrumenter = self.client and self.client.options["instrumenter"] - - if instrumenter != configuration_instrumenter: - return NoOpSpan() - - custom_sampling_context = kwargs.pop("custom_sampling_context", {}) + scope = get_current_scope() - # if we haven't been given a transaction, make one - if transaction is None: - kwargs.setdefault("hub", self) - transaction = Transaction(**kwargs) + # For backwards compatibility, we allow passing the scope as the hub. + # We need a major release to make this nice. (if someone searches the code: deprecated) + # Type checking disabled for this line because deprecated keys are not allowed in the type signature. + kwargs["hub"] = scope # type: ignore - # use traces_sample_rate, traces_sampler, and/or inheritance to make a - # sampling decision - sampling_context = { - "transaction_context": transaction.to_json(), - "parent_sampled": transaction.parent_sampled, - } - sampling_context.update(custom_sampling_context) - transaction._set_initial_sampling_decision(sampling_context=sampling_context) - - profile = Profile(transaction, hub=self) - profile._set_initial_sampling_decision(sampling_context=sampling_context) - - # we don't bother to keep spans if we already know we're not going to - # send the transaction - if transaction.sampled: - max_spans = ( - self.client and self.client.options["_experiments"].get("max_spans") - ) or 1000 - transaction.init_span_recorder(maxlen=max_spans) - - return transaction + return scope.start_transaction( + transaction, instrumenter, custom_sampling_context, **kwargs + ) - def continue_trace(self, environ_or_headers, op=None, name=None, source=None): - # type: (Dict[str, Any], Optional[str], Optional[str], Optional[str]) -> Transaction + def continue_trace( + self, + environ_or_headers: "Dict[str, Any]", + op: "Optional[str]" = None, + name: "Optional[str]" = None, + source: "Optional[str]" = None, + ) -> "Transaction": """ + .. deprecated:: 2.0.0 + This function is deprecated and will be removed in a future release. + Please use :py:meth:`sentry_sdk.Scope.continue_trace` instead. + Sets the propagation context from environment or headers and returns a transaction. """ - with self.configure_scope() as scope: - scope.generate_propagation_context(environ_or_headers) - - transaction = Transaction.continue_from_headers( - normalize_incoming_data(environ_or_headers), - op=op, - name=name, - source=source, + return get_isolation_scope().continue_trace( + environ_or_headers=environ_or_headers, op=op, name=name, source=source ) - return transaction @overload def push_scope( - self, callback=None # type: Optional[None] - ): - # type: (...) -> ContextManager[Scope] + self, + callback: "Optional[None]" = None, + ) -> "ContextManager[Scope]": pass @overload def push_scope( # noqa: F811 - self, callback # type: Callable[[Scope], None] - ): - # type: (...) -> None + self, + callback: "Callable[[Scope], None]", + ) -> None: pass def push_scope( # noqa self, - callback=None, # type: Optional[Callable[[Scope], None]] - continue_trace=True, # type: bool - ): - # type: (...) -> Optional[ContextManager[Scope]] + callback: "Optional[Callable[[Scope], None]]" = None, + continue_trace: bool = True, + ) -> "Optional[ContextManager[Scope]]": """ + .. deprecated:: 2.0.0 + This function is deprecated and will be removed in a future release. + Pushes a new layer on the scope stack. :param callback: If provided, this method pushes a scope, calls @@ -634,21 +533,13 @@ def push_scope( # noqa callback(scope) return None - client, scope = self._stack[-1] - - new_scope = copy.copy(scope) - - if continue_trace: - new_scope.generate_propagation_context() - - new_layer = (client, new_scope) - self._stack.append(new_layer) - return _ScopeManager(self) - def pop_scope_unsafe(self): - # type: () -> Tuple[Optional[Client], Scope] + def pop_scope_unsafe(self) -> "Tuple[Optional[Client], Scope]": """ + .. deprecated:: 2.0.0 + This function is deprecated and will be removed in a future release. + Pops a scope layer from the stack. Try to use the context manager :py:meth:`push_scope` instead. @@ -659,167 +550,173 @@ def pop_scope_unsafe(self): @overload def configure_scope( - self, callback=None # type: Optional[None] - ): - # type: (...) -> ContextManager[Scope] + self, + callback: "Optional[None]" = None, + ) -> "ContextManager[Scope]": pass @overload def configure_scope( # noqa: F811 - self, callback # type: Callable[[Scope], None] - ): - # type: (...) -> None + self, + callback: "Callable[[Scope], None]", + ) -> None: pass def configure_scope( # noqa self, - callback=None, # type: Optional[Callable[[Scope], None]] - continue_trace=True, # type: bool - ): - # type: (...) -> Optional[ContextManager[Scope]] - + callback: "Optional[Callable[[Scope], None]]" = None, + continue_trace: bool = True, + ) -> "Optional[ContextManager[Scope]]": """ + .. deprecated:: 2.0.0 + This function is deprecated and will be removed in a future release. + Reconfigures the scope. :param callback: If provided, call the callback with the current scope. :returns: If no callback is provided, returns a context manager that returns the scope. """ - - client, scope = self._stack[-1] + scope = get_isolation_scope() if continue_trace: scope.generate_propagation_context() if callback is not None: - if client is not None: - callback(scope) + # TODO: used to return None when client is None. Check if this changes behavior. + callback(scope) return None @contextmanager - def inner(): - # type: () -> Generator[Scope, None, None] - if client is not None: - yield scope - else: - yield Scope() + def inner() -> "Generator[Scope, None, None]": + yield scope return inner() def start_session( - self, session_mode="application" # type: str - ): - # type: (...) -> None - """Starts a new session.""" - self.end_session() - client, scope = self._stack[-1] - scope._session = Session( - release=client.options["release"] if client else None, - environment=client.options["environment"] if client else None, - user=scope._user, + self, + session_mode: str = "application", + ) -> None: + """ + .. deprecated:: 2.0.0 + This function is deprecated and will be removed in a future release. + Please use :py:meth:`sentry_sdk.Scope.start_session` instead. + + Starts a new session. + """ + get_isolation_scope().start_session( session_mode=session_mode, ) - def end_session(self): - # type: (...) -> None - """Ends the current session if there is one.""" - client, scope = self._stack[-1] - session = scope._session - self.scope._session = None + def end_session(self) -> None: + """ + .. deprecated:: 2.0.0 + This function is deprecated and will be removed in a future release. + Please use :py:meth:`sentry_sdk.Scope.end_session` instead. + + Ends the current session if there is one. + """ + get_isolation_scope().end_session() - if session is not None: - session.close() - if client is not None: - client.capture_session(session) + def stop_auto_session_tracking(self) -> None: + """ + .. deprecated:: 2.0.0 + This function is deprecated and will be removed in a future release. + Please use :py:meth:`sentry_sdk.Scope.stop_auto_session_tracking` instead. - def stop_auto_session_tracking(self): - # type: (...) -> None - """Stops automatic session tracking. + Stops automatic session tracking. This temporarily session tracking for the current scope when called. To resume session tracking call `resume_auto_session_tracking`. """ - self.end_session() - client, scope = self._stack[-1] - scope._force_auto_session_tracking = False + get_isolation_scope().stop_auto_session_tracking() + + def resume_auto_session_tracking(self) -> None: + """ + .. deprecated:: 2.0.0 + This function is deprecated and will be removed in a future release. + Please use :py:meth:`sentry_sdk.Scope.resume_auto_session_tracking` instead. - def resume_auto_session_tracking(self): - # type: (...) -> None - """Resumes automatic session tracking for the current scope if + Resumes automatic session tracking for the current scope if disabled earlier. This requires that generally automatic session tracking is enabled. """ - client, scope = self._stack[-1] - scope._force_auto_session_tracking = None + get_isolation_scope().resume_auto_session_tracking() def flush( self, - timeout=None, # type: Optional[float] - callback=None, # type: Optional[Callable[[int, float], None]] - ): - # type: (...) -> None + timeout: "Optional[float]" = None, + callback: "Optional[Callable[[int, float], None]]" = None, + ) -> None: """ - Alias for :py:meth:`sentry_sdk.Client.flush` + .. deprecated:: 2.0.0 + This function is deprecated and will be removed in a future release. + Please use :py:meth:`sentry_sdk.client._Client.flush` instead. + + Alias for :py:meth:`sentry_sdk.client._Client.flush` """ - client, scope = self._stack[-1] - if client is not None: - return client.flush(timeout=timeout, callback=callback) + return get_client().flush(timeout=timeout, callback=callback) - def get_traceparent(self): - # type: () -> Optional[str] + def get_traceparent(self) -> "Optional[str]": """ + .. deprecated:: 2.0.0 + This function is deprecated and will be removed in a future release. + Please use :py:meth:`sentry_sdk.Scope.get_traceparent` instead. + Returns the traceparent either from the active span or from the scope. """ - if self.client is not None: - if has_tracing_enabled(self.client.options) and self.scope.span is not None: - return self.scope.span.to_traceparent() + current_scope = get_current_scope() + traceparent = current_scope.get_traceparent() - return self.scope.get_traceparent() + if traceparent is None: + isolation_scope = get_isolation_scope() + traceparent = isolation_scope.get_traceparent() - def get_baggage(self): - # type: () -> Optional[str] + return traceparent + + def get_baggage(self) -> "Optional[str]": """ + .. deprecated:: 2.0.0 + This function is deprecated and will be removed in a future release. + Please use :py:meth:`sentry_sdk.Scope.get_baggage` instead. + Returns Baggage either from the active span or from the scope. """ - if ( - self.client is not None - and has_tracing_enabled(self.client.options) - and self.scope.span is not None - ): - baggage = self.scope.span.to_baggage() - else: - baggage = self.scope.get_baggage() + current_scope = get_current_scope() + baggage = current_scope.get_baggage() + + if baggage is None: + isolation_scope = get_isolation_scope() + baggage = isolation_scope.get_baggage() if baggage is not None: return baggage.serialize() return None - def iter_trace_propagation_headers(self, span=None): - # type: (Optional[Span]) -> Generator[Tuple[str, str], None, None] + def iter_trace_propagation_headers( + self, span: "Optional[Span]" = None + ) -> "Generator[Tuple[str, str], None, None]": """ + .. deprecated:: 2.0.0 + This function is deprecated and will be removed in a future release. + Please use :py:meth:`sentry_sdk.Scope.iter_trace_propagation_headers` instead. + Return HTTP headers which allow propagation of trace data. Data taken from the span representing the request, if available, or the current span on the scope if not. """ - client = self._stack[-1][0] - propagate_traces = client and client.options["propagate_traces"] - if not propagate_traces: - return - - span = span or self.scope.span - - if client and has_tracing_enabled(client.options) and span is not None: - for header in span.iter_headers(): - yield header - else: - for header in self.scope.iter_headers(): - yield header + return get_current_scope().iter_trace_propagation_headers( + span=span, + ) - def trace_propagation_meta(self, span=None): - # type: (Optional[Span]) -> str + def trace_propagation_meta(self, span: "Optional[Span]" = None) -> str: """ + .. deprecated:: 2.0.0 + This function is deprecated and will be removed in a future release. + Please use :py:meth:`sentry_sdk.Scope.trace_propagation_meta` instead. + Return meta tags which should be injected into HTML templates to allow propagation of trace information. """ @@ -828,24 +725,17 @@ def trace_propagation_meta(self, span=None): "The parameter `span` in trace_propagation_meta() is deprecated and will be removed in the future." ) - meta = "" - - sentry_trace = self.get_traceparent() - if sentry_trace is not None: - meta += '' % ( - SENTRY_TRACE_HEADER_NAME, - sentry_trace, - ) + return get_current_scope().trace_propagation_meta( + span=span, + ) - baggage = self.get_baggage() - if baggage is not None: - meta += '' % ( - BAGGAGE_HEADER_NAME, - baggage, - ) - return meta +with _suppress_hub_deprecation_warning(): + # Suppress deprecation warning for the Hub here, since we still always + # import this module. + GLOBAL_HUB = Hub() +_local.set(GLOBAL_HUB) -GLOBAL_HUB = Hub() -_local.set(GLOBAL_HUB) +# Circular imports +from sentry_sdk import scope diff --git a/sentry_sdk/integrations/__init__.py b/sentry_sdk/integrations/__init__.py index 0fe958d217..5ab181df25 100644 --- a/sentry_sdk/integrations/__init__.py +++ b/sentry_sdk/integrations/__init__.py @@ -1,32 +1,33 @@ -from __future__ import absolute_import +from abc import ABC, abstractmethod from threading import Lock +from typing import TYPE_CHECKING -from sentry_sdk._compat import iteritems -from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.utils import logger - if TYPE_CHECKING: - from typing import Callable - from typing import Dict - from typing import Iterator - from typing import List - from typing import Set - from typing import Type + from collections.abc import Sequence + from typing import Any, Callable, Dict, Iterator, List, Optional, Set, Type, Union + + +_DEFAULT_FAILED_REQUEST_STATUS_CODES = frozenset(range(500, 600)) _installer_lock = Lock() -_installed_integrations = set() # type: Set[str] +# Set of all integration identifiers we have attempted to install +_processed_integrations: "Set[str]" = set() -def _generate_default_integrations_iterator( - integrations, # type: List[str] - auto_enabling_integrations, # type: List[str] -): - # type: (...) -> Callable[[bool], Iterator[Type[Integration]]] +# Set of all integration identifiers we have actually installed +_installed_integrations: "Set[str]" = set() - def iter_default_integrations(with_auto_enabling_integrations): - # type: (bool) -> Iterator[Type[Integration]] + +def _generate_default_integrations_iterator( + integrations: "List[str]", + auto_enabling_integrations: "List[str]", +) -> "Callable[[bool], Iterator[Type[Integration]]]": + def iter_default_integrations( + with_auto_enabling_integrations: bool, + ) -> "Iterator[Type[Integration]]": """Returns an iterator of the default integration classes:""" from importlib import import_module @@ -65,24 +66,46 @@ def iter_default_integrations(with_auto_enabling_integrations): _AUTO_ENABLING_INTEGRATIONS = [ "sentry_sdk.integrations.aiohttp.AioHttpIntegration", + "sentry_sdk.integrations.anthropic.AnthropicIntegration", + "sentry_sdk.integrations.ariadne.AriadneIntegration", + "sentry_sdk.integrations.arq.ArqIntegration", + "sentry_sdk.integrations.asyncpg.AsyncPGIntegration", "sentry_sdk.integrations.boto3.Boto3Integration", "sentry_sdk.integrations.bottle.BottleIntegration", "sentry_sdk.integrations.celery.CeleryIntegration", + "sentry_sdk.integrations.chalice.ChaliceIntegration", + "sentry_sdk.integrations.clickhouse_driver.ClickhouseDriverIntegration", + "sentry_sdk.integrations.cohere.CohereIntegration", "sentry_sdk.integrations.django.DjangoIntegration", "sentry_sdk.integrations.falcon.FalconIntegration", "sentry_sdk.integrations.fastapi.FastApiIntegration", "sentry_sdk.integrations.flask.FlaskIntegration", + "sentry_sdk.integrations.gql.GQLIntegration", + "sentry_sdk.integrations.graphene.GrapheneIntegration", "sentry_sdk.integrations.httpx.HttpxIntegration", + "sentry_sdk.integrations.huey.HueyIntegration", + "sentry_sdk.integrations.huggingface_hub.HuggingfaceHubIntegration", + "sentry_sdk.integrations.langchain.LangchainIntegration", + "sentry_sdk.integrations.langgraph.LanggraphIntegration", + "sentry_sdk.integrations.litestar.LitestarIntegration", + "sentry_sdk.integrations.loguru.LoguruIntegration", + "sentry_sdk.integrations.mcp.MCPIntegration", + "sentry_sdk.integrations.openai.OpenAIIntegration", + "sentry_sdk.integrations.openai_agents.OpenAIAgentsIntegration", + "sentry_sdk.integrations.pydantic_ai.PydanticAIIntegration", + "sentry_sdk.integrations.pymongo.PyMongoIntegration", "sentry_sdk.integrations.pyramid.PyramidIntegration", + "sentry_sdk.integrations.quart.QuartIntegration", "sentry_sdk.integrations.redis.RedisIntegration", "sentry_sdk.integrations.rq.RqIntegration", "sentry_sdk.integrations.sanic.SanicIntegration", "sentry_sdk.integrations.sqlalchemy.SqlalchemyIntegration", "sentry_sdk.integrations.starlette.StarletteIntegration", + "sentry_sdk.integrations.starlite.StarliteIntegration", + "sentry_sdk.integrations.strawberry.StrawberryIntegration", "sentry_sdk.integrations.tornado.TornadoIntegration", ] - iter_default_integrations = _generate_default_integrations_iterator( integrations=_DEFAULT_INTEGRATIONS, auto_enabling_integrations=_AUTO_ENABLING_INTEGRATIONS, @@ -91,15 +114,87 @@ def iter_default_integrations(with_auto_enabling_integrations): del _generate_default_integrations_iterator +_MIN_VERSIONS = { + "aiohttp": (3, 4), + "anthropic": (0, 16), + "ariadne": (0, 20), + "arq": (0, 23), + "asyncpg": (0, 23), + "beam": (2, 12), + "boto3": (1, 12), # botocore + "bottle": (0, 12), + "celery": (4, 4, 7), + "chalice": (1, 16, 0), + "clickhouse_driver": (0, 2, 0), + "cohere": (5, 4, 0), + "django": (1, 8), + "dramatiq": (1, 9), + "falcon": (1, 4), + "fastapi": (0, 79, 0), + "flask": (1, 1, 4), + "gql": (3, 4, 1), + "graphene": (3, 3), + "google_genai": (1, 29, 0), # google-genai + "grpc": (1, 32, 0), # grpcio + "httpx": (0, 16, 0), + "huggingface_hub": (0, 24, 7), + "langchain": (0, 1, 0), + "langgraph": (0, 6, 6), + "launchdarkly": (9, 8, 0), + "litellm": (1, 77, 5), + "loguru": (0, 7, 0), + "mcp": (1, 15, 0), + "openai": (1, 0, 0), + "openai_agents": (0, 0, 19), + "openfeature": (0, 7, 1), + "pydantic_ai": (1, 0, 0), + "quart": (0, 16, 0), + "ray": (2, 7, 0), + "requests": (2, 0, 0), + "rq": (0, 6), + "sanic": (0, 8), + "sqlalchemy": (1, 2), + "starlette": (0, 16), + "starlite": (1, 48), + "statsig": (0, 55, 3), + "strawberry": (0, 209, 5), + "tornado": (6, 0), + "typer": (0, 15), + "unleash": (6, 0, 1), +} + + +_INTEGRATION_DEACTIVATES = { + "langchain": {"openai", "anthropic"}, + "openai_agents": {"openai"}, + "pydantic_ai": {"openai", "anthropic"}, +} + + def setup_integrations( - integrations, with_defaults=True, with_auto_enabling_integrations=False -): - # type: (List[Integration], bool, bool) -> Dict[str, Integration] + integrations: "Sequence[Integration]", + with_defaults: bool = True, + with_auto_enabling_integrations: bool = False, + disabled_integrations: "Optional[Sequence[Union[type[Integration], Integration]]]" = None, + options: "Optional[Dict[str, Any]]" = None, +) -> "Dict[str, Integration]": """ Given a list of integration instances, this installs them all. When `with_defaults` is set to `True` all default integrations are added unless they were already provided before. + + `disabled_integrations` takes precedence over `with_defaults` and + `with_auto_enabling_integrations`. + + Some integrations are designed to automatically deactivate other integrations + in order to avoid conflicts and prevent duplicate telemetry from being collected. + For example, enabling the `langchain` integration will auto-deactivate both the + `openai` and `anthropic` integrations. + + Users can override this behavior by: + - Explicitly providing an integration in the `integrations=[]` list, or + - Disabling the higher-level integration via the `disabled_integrations` option. """ integrations = dict( (integration.identifier, integration) for integration in integrations or () @@ -107,6 +202,14 @@ def setup_integrations( logger.debug("Setting up integrations (with default = %s)", with_defaults) + user_provided_integrations = set(integrations.keys()) + + # Integrations that will not be enabled + disabled_integrations = [ + integration if isinstance(integration, type) else type(integration) + for integration in disabled_integrations or [] + ] + # Integrations that are not explicitly set up by the user. used_as_default_integration = set() @@ -119,33 +222,56 @@ def setup_integrations( integrations[instance.identifier] = instance used_as_default_integration.add(instance.identifier) - for identifier, integration in iteritems(integrations): + disabled_integration_identifiers = { + integration.identifier for integration in disabled_integrations + } + + for integration, targets_to_deactivate in _INTEGRATION_DEACTIVATES.items(): + if ( + integration in integrations + and integration not in disabled_integration_identifiers + ): + for target in targets_to_deactivate: + if target not in user_provided_integrations: + for cls in iter_default_integrations(True): + if cls.identifier == target: + if cls not in disabled_integrations: + disabled_integrations.append(cls) + logger.debug( + "Auto-deactivating %s integration because %s integration is active", + target, + integration, + ) + + for identifier, integration in integrations.items(): with _installer_lock: - if identifier not in _installed_integrations: - logger.debug( - "Setting up previously not enabled integration %s", identifier - ) - try: - type(integration).setup_once() - except NotImplementedError: - if getattr(integration, "install", None) is not None: - logger.warning( - "Integration %s: The install method is " - "deprecated. Use `setup_once`.", - identifier, + if identifier not in _processed_integrations: + if type(integration) in disabled_integrations: + logger.debug("Ignoring integration %s", identifier) + else: + logger.debug( + "Setting up previously not enabled integration %s", identifier + ) + try: + type(integration).setup_once() + integration.setup_once_with_options(options) + except DidNotEnable as e: + if identifier not in used_as_default_integration: + raise + + logger.debug( + "Did not enable default integration %s: %s", identifier, e ) - integration.install() else: - raise - except DidNotEnable as e: - if identifier not in used_as_default_integration: - raise + _installed_integrations.add(identifier) - logger.debug( - "Did not enable default integration %s: %s", identifier, e - ) + _processed_integrations.add(identifier) - _installed_integrations.add(identifier) + integrations = { + identifier: integration + for identifier, integration in integrations.items() + if identifier in _installed_integrations + } for identifier in integrations: logger.debug("Enabling integration %s", identifier) @@ -153,6 +279,26 @@ def setup_integrations( return integrations +def _check_minimum_version( + integration: "type[Integration]", + version: "Optional[tuple[int, ...]]", + package: "Optional[str]" = None, +) -> None: + package = package or integration.identifier + + if version is None: + raise DidNotEnable(f"Unparsable {package} version.") + + min_version = _MIN_VERSIONS.get(integration.identifier) + if min_version is None: + return + + if version < min_version: + raise DidNotEnable( + f"Integration only supports {package} {'.'.join(map(str, min_version))} or newer." + ) + + class DidNotEnable(Exception): # noqa: N818 """ The integration could not be enabled due to a trivial user error like @@ -163,7 +309,7 @@ class DidNotEnable(Exception): # noqa: N818 """ -class Integration(object): +class Integration(ABC): """Baseclass for all integrations. To accept options for an integration, implement your own constructor that @@ -173,12 +319,12 @@ class Integration(object): install = None """Legacy method, do not implement.""" - identifier = None # type: str + identifier: "str" = None # type: ignore[assignment] """String unique ID of integration type""" @staticmethod - def setup_once(): - # type: () -> None + @abstractmethod + def setup_once() -> None: """ Initialize the integration. @@ -189,4 +335,12 @@ def setup_once(): Inside those hooks `Integration.current` can be used to access the instance again. """ - raise NotImplementedError() + pass + + def setup_once_with_options( + self, options: "Optional[Dict[str, Any]]" = None + ) -> None: + """ + Called after setup_once in rare cases on the instance and with options since we don't have those available above. + """ + pass diff --git a/sentry_sdk/integrations/_asgi_common.py b/sentry_sdk/integrations/_asgi_common.py index 41946cc7c2..a8022c6bb1 100644 --- a/sentry_sdk/integrations/_asgi_common.py +++ b/sentry_sdk/integrations/_asgi_common.py @@ -1,22 +1,25 @@ import urllib -from sentry_sdk.hub import _should_send_default_pii +from sentry_sdk.scope import should_send_default_pii from sentry_sdk.integrations._wsgi_common import _filter_headers -from sentry_sdk._types import TYPE_CHECKING + +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any from typing import Dict from typing import Optional + from typing import Union from typing_extensions import Literal + from sentry_sdk.utils import AnnotatedValue + -def _get_headers(asgi_scope): - # type: (Any) -> Dict[str, str] +def _get_headers(asgi_scope: "Any") -> "Dict[str, str]": """ Extract headers from the ASGI scope, in the format that the Sentry protocol expects. """ - headers = {} # type: Dict[str, str] + headers: "Dict[str, str]" = {} for raw_key, raw_value in asgi_scope["headers"]: key = raw_key.decode("latin-1") value = raw_value.decode("latin-1") @@ -28,8 +31,11 @@ def _get_headers(asgi_scope): return headers -def _get_url(asgi_scope, default_scheme, host): - # type: (Dict[str, Any], Literal["ws", "http"], Optional[str]) -> str +def _get_url( + asgi_scope: "Dict[str, Any]", + default_scheme: "Literal['ws', 'http']", + host: "Optional[Union[AnnotatedValue, str]]", +) -> str: """ Extract URL from the ASGI scope, without also including the querystring. """ @@ -50,8 +56,7 @@ def _get_url(asgi_scope, default_scheme, host): return path -def _get_query(asgi_scope): - # type: (Any) -> Any +def _get_query(asgi_scope: "Any") -> "Any": """ Extract querystring from the ASGI scope, in the format that the Sentry protocol expects. """ @@ -61,8 +66,7 @@ def _get_query(asgi_scope): return urllib.parse.unquote(qs.decode("latin-1")) -def _get_ip(asgi_scope): - # type: (Any) -> str +def _get_ip(asgi_scope: "Any") -> str: """ Extract IP Address from the ASGI scope based on request headers with fallback to scope client. """ @@ -80,12 +84,11 @@ def _get_ip(asgi_scope): return asgi_scope.get("client")[0] -def _get_request_data(asgi_scope): - # type: (Any) -> Dict[str, Any] +def _get_request_data(asgi_scope: "Any") -> "Dict[str, Any]": """ Returns data related to the HTTP request from the ASGI scope. """ - request_data = {} # type: Dict[str, Any] + request_data: "Dict[str, Any]" = {} ty = asgi_scope["type"] if ty in ("http", "websocket"): request_data["method"] = asgi_scope.get("method") @@ -98,7 +101,7 @@ def _get_request_data(asgi_scope): ) client = asgi_scope.get("client") - if client and _should_send_default_pii(): + if client and should_send_default_pii(): request_data["env"] = {"REMOTE_ADDR": _get_ip(asgi_scope)} return request_data diff --git a/sentry_sdk/integrations/_wsgi_common.py b/sentry_sdk/integrations/_wsgi_common.py index 585abe25de..688e965be4 100644 --- a/sentry_sdk/integrations/_wsgi_common.py +++ b/sentry_sdk/integrations/_wsgi_common.py @@ -1,19 +1,27 @@ +from contextlib import contextmanager import json from copy import deepcopy -from sentry_sdk.hub import Hub, _should_send_default_pii -from sentry_sdk.utils import AnnotatedValue -from sentry_sdk._compat import text_type, iteritems +import sentry_sdk +from sentry_sdk.scope import should_send_default_pii +from sentry_sdk.utils import AnnotatedValue, logger -from sentry_sdk._types import TYPE_CHECKING +try: + from django.http.request import RawPostDataException +except ImportError: + RawPostDataException = None -if TYPE_CHECKING: - import sentry_sdk +from typing import TYPE_CHECKING +if TYPE_CHECKING: from typing import Any from typing import Dict + from typing import Iterator + from typing import Mapping + from typing import MutableMapping from typing import Optional from typing import Union + from sentry_sdk._types import Event, HttpStatusCodeRange SENSITIVE_ENV_KEYS = ( @@ -31,9 +39,28 @@ x[len("HTTP_") :] for x in SENSITIVE_ENV_KEYS if x.startswith("HTTP_") ) +DEFAULT_HTTP_METHODS_TO_CAPTURE = ( + "CONNECT", + "DELETE", + "GET", + # "HEAD", # do not capture HEAD requests by default + # "OPTIONS", # do not capture OPTIONS requests by default + "PATCH", + "POST", + "PUT", + "TRACE", +) + + +# This noop context manager can be replaced with "from contextlib import nullcontext" when we drop Python 3.6 support +@contextmanager +def nullcontext() -> "Iterator[None]": + yield + -def request_body_within_bounds(client, content_length): - # type: (Optional[sentry_sdk.Client], int) -> bool +def request_body_within_bounds( + client: "Optional[sentry_sdk.client.BaseClient]", content_length: int +) -> bool: if client is None: return False @@ -45,32 +72,51 @@ def request_body_within_bounds(client, content_length): ) -class RequestExtractor(object): - def __init__(self, request): - # type: (Any) -> None +class RequestExtractor: + """ + Base class for request extraction. + """ + + # It does not make sense to make this class an ABC because it is not used + # for typing, only so that child classes can inherit common methods from + # it. Only some child classes implement all methods that raise + # NotImplementedError in this class. + + def __init__(self, request: "Any") -> None: self.request = request - def extract_into_event(self, event): - # type: (Dict[str, Any]) -> None - client = Hub.current.client - if client is None: + def extract_into_event(self, event: "Event") -> None: + client = sentry_sdk.get_client() + if not client.is_active(): return - data = None # type: Optional[Union[AnnotatedValue, Dict[str, Any]]] + data: "Optional[Union[AnnotatedValue, Dict[str, Any]]]" = None content_length = self.content_length() request_info = event.get("request", {}) - if _should_send_default_pii(): + if should_send_default_pii(): request_info["cookies"] = dict(self.cookies()) if not request_body_within_bounds(client, content_length): data = AnnotatedValue.removed_because_over_size_limit() else: + # First read the raw body data + # It is important to read this first because if it is Django + # it will cache the body and then we can read the cached version + # again in parsed_body() (or json() or wherever). + raw_data = None + try: + raw_data = self.raw_data() + except (RawPostDataException, ValueError): + # If DjangoRestFramework is used it already read the body for us + # so reading it here will fail. We can ignore this. + pass + parsed_body = self.parsed_body() if parsed_body is not None: data = parsed_body - elif self.raw_data(): + elif raw_data: data = AnnotatedValue.removed_because_raw_data() else: data = None @@ -80,53 +126,62 @@ def extract_into_event(self, event): event["request"] = deepcopy(request_info) - def content_length(self): - # type: () -> int + def content_length(self) -> int: try: return int(self.env().get("CONTENT_LENGTH", 0)) except ValueError: return 0 - def cookies(self): - # type: () -> Dict[str, Any] + def cookies(self) -> "MutableMapping[str, Any]": raise NotImplementedError() - def raw_data(self): - # type: () -> Optional[Union[str, bytes]] + def raw_data(self) -> "Optional[Union[str, bytes]]": raise NotImplementedError() - def form(self): - # type: () -> Optional[Dict[str, Any]] + def form(self) -> "Optional[Dict[str, Any]]": raise NotImplementedError() - def parsed_body(self): - # type: () -> Optional[Dict[str, Any]] - form = self.form() - files = self.files() + def parsed_body(self) -> "Optional[Dict[str, Any]]": + try: + form = self.form() + except Exception: + form = None + try: + files = self.files() + except Exception: + files = None + if form or files: - data = dict(iteritems(form)) - for key, _ in iteritems(files): - data[key] = AnnotatedValue.removed_because_raw_data() + data = {} + if form: + data = dict(form.items()) + if files: + for key in files.keys(): + data[key] = AnnotatedValue.removed_because_raw_data() return data return self.json() - def is_json(self): - # type: () -> bool + def is_json(self) -> bool: return _is_json_content_type(self.env().get("CONTENT_TYPE")) - def json(self): - # type: () -> Optional[Any] + def json(self) -> "Optional[Any]": try: if not self.is_json(): return None - raw_data = self.raw_data() + try: + raw_data = self.raw_data() + except (RawPostDataException, ValueError): + # The body might have already been read, in which case this will + # fail + raw_data = None + if raw_data is None: return None - if isinstance(raw_data, text_type): + if isinstance(raw_data, str): return json.loads(raw_data) else: return json.loads(raw_data.decode("utf-8")) @@ -135,21 +190,17 @@ def json(self): return None - def files(self): - # type: () -> Optional[Dict[str, Any]] + def files(self) -> "Optional[Dict[str, Any]]": raise NotImplementedError() - def size_of_file(self, file): - # type: (Any) -> int + def size_of_file(self, file: "Any") -> int: raise NotImplementedError() - def env(self): - # type: () -> Dict[str, Any] + def env(self) -> "Dict[str, Any]": raise NotImplementedError() -def _is_json_content_type(ct): - # type: (Optional[str]) -> bool +def _is_json_content_type(ct: "Optional[str]") -> bool: mt = (ct or "").split(";", 1)[0] return ( mt == "application/json" @@ -158,9 +209,10 @@ def _is_json_content_type(ct): ) -def _filter_headers(headers): - # type: (Dict[str, str]) -> Dict[str, str] - if _should_send_default_pii(): +def _filter_headers( + headers: "Mapping[str, str]", +) -> "Mapping[str, Union[AnnotatedValue, str]]": + if should_send_default_pii(): return headers return { @@ -169,5 +221,38 @@ def _filter_headers(headers): if k.upper().replace("-", "_") not in SENSITIVE_HEADERS else AnnotatedValue.removed_because_over_size_limit() ) - for k, v in iteritems(headers) + for k, v in headers.items() } + + +def _in_http_status_code_range( + code: object, code_ranges: "list[HttpStatusCodeRange]" +) -> bool: + for target in code_ranges: + if isinstance(target, int): + if code == target: + return True + continue + + try: + if code in target: + return True + except TypeError: + logger.warning( + "failed_request_status_codes has to be a list of integers or containers" + ) + + return False + + +class HttpCodeRangeContainer: + """ + Wrapper to make it possible to use list[HttpStatusCodeRange] as a Container[int]. + Used for backwards compatibility with the old `failed_request_status_codes` option. + """ + + def __init__(self, code_ranges: "list[HttpStatusCodeRange]") -> None: + self._code_ranges = code_ranges + + def __contains__(self, item: object) -> bool: + return _in_http_status_code_range(item, self._code_ranges) diff --git a/sentry_sdk/integrations/aiohttp.py b/sentry_sdk/integrations/aiohttp.py index d2d431aefd..46ee5f67b6 100644 --- a/sentry_sdk/integrations/aiohttp.py +++ b/sentry_sdk/integrations/aiohttp.py @@ -1,13 +1,18 @@ import sys import weakref +from functools import wraps +import sentry_sdk from sentry_sdk.api import continue_trace -from sentry_sdk._compat import reraise -from sentry_sdk.consts import OP, SPANDATA -from sentry_sdk.hub import Hub -from sentry_sdk.integrations import Integration, DidNotEnable +from sentry_sdk.consts import OP, SPANSTATUS, SPANDATA +from sentry_sdk.integrations import ( + _DEFAULT_FAILED_REQUEST_STATUS_CODES, + _check_minimum_version, + Integration, + DidNotEnable, +) from sentry_sdk.integrations.logging import ignore_logger -from sentry_sdk.sessions import auto_session_tracking +from sentry_sdk.sessions import track_session from sentry_sdk.integrations._wsgi_common import ( _filter_headers, request_body_within_bounds, @@ -15,15 +20,17 @@ from sentry_sdk.tracing import ( BAGGAGE_HEADER_NAME, SOURCE_FOR_STYLE, - TRANSACTION_SOURCE_ROUTE, + TransactionSource, ) -from sentry_sdk.tracing_utils import should_propagate_trace +from sentry_sdk.tracing_utils import should_propagate_trace, add_http_request_source from sentry_sdk.utils import ( capture_internal_exceptions, + ensure_integration_enabled, event_from_exception, logger, parse_url, parse_version, + reraise, transaction_from_function, HAS_REAL_CONTEXTVARS, CONTEXTVARS_ERROR_MESSAGE, @@ -40,22 +47,22 @@ except ImportError: raise DidNotEnable("AIOHTTP not installed") -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from aiohttp.web_request import Request - from aiohttp.abc import AbstractMatchInfo + from aiohttp.web_urldispatcher import UrlMappingMatchInfo from aiohttp import TraceRequestStartParams, TraceRequestEndParams + + from collections.abc import Set from types import SimpleNamespace from typing import Any - from typing import Dict from typing import Optional from typing import Tuple - from typing import Callable from typing import Union from sentry_sdk.utils import ExcInfo - from sentry_sdk._types import EventProcessor + from sentry_sdk._types import Event, EventProcessor TRANSACTION_STYLE_VALUES = ("handler_name", "method_and_path_pattern") @@ -63,27 +70,26 @@ class AioHttpIntegration(Integration): identifier = "aiohttp" - - def __init__(self, transaction_style="handler_name"): - # type: (str) -> None + origin = f"auto.http.{identifier}" + + def __init__( + self, + transaction_style: str = "handler_name", + *, + failed_request_status_codes: "Set[int]" = _DEFAULT_FAILED_REQUEST_STATUS_CODES, + ) -> None: if transaction_style not in TRANSACTION_STYLE_VALUES: raise ValueError( "Invalid value for transaction_style: %s (must be in %s)" % (transaction_style, TRANSACTION_STYLE_VALUES) ) self.transaction_style = transaction_style + self._failed_request_status_codes = failed_request_status_codes @staticmethod - def setup_once(): - # type: () -> None - + def setup_once() -> None: version = parse_version(AIOHTTP_VERSION) - - if version is None: - raise DidNotEnable("Unparsable AIOHTTP version: {}".format(AIOHTTP_VERSION)) - - if version < (3, 4): - raise DidNotEnable("AIOHTTP 3.4 or newer required.") + _check_minimum_version(AioHttpIntegration, version) if not HAS_REAL_CONTEXTVARS: # We better have contextvars or we're going to leak state between @@ -97,31 +103,34 @@ def setup_once(): old_handle = Application._handle - async def sentry_app_handle(self, request, *args, **kwargs): - # type: (Any, Request, *Any, **Any) -> Any - hub = Hub.current - if hub.get_integration(AioHttpIntegration) is None: + async def sentry_app_handle( + self: "Any", request: "Request", *args: "Any", **kwargs: "Any" + ) -> "Any": + integration = sentry_sdk.get_client().get_integration(AioHttpIntegration) + if integration is None: return await old_handle(self, request, *args, **kwargs) weak_request = weakref.ref(request) - with Hub(hub) as hub: - with auto_session_tracking(hub, session_mode="request"): + with sentry_sdk.isolation_scope() as scope: + with track_session(scope, session_mode="request"): # Scope data will not leak between requests because aiohttp # create a task to wrap each request. - with hub.configure_scope() as scope: - scope.clear_breadcrumbs() - scope.add_event_processor(_make_request_processor(weak_request)) + scope.generate_propagation_context() + scope.clear_breadcrumbs() + scope.add_event_processor(_make_request_processor(weak_request)) + headers = dict(request.headers) transaction = continue_trace( - request.headers, + headers, op=OP.HTTP_SERVER, # If this transaction name makes it to the UI, AIOHTTP's # URL resolver did not find a route or died trying. name="generic AIOHTTP request", - source=TRANSACTION_SOURCE_ROUTE, + source=TransactionSource.ROUTE, + origin=AioHttpIntegration.origin, ) - with hub.start_transaction( + with sentry_sdk.start_transaction( transaction, custom_sampling_context={"aiohttp_request": request}, ): @@ -129,28 +138,48 @@ async def sentry_app_handle(self, request, *args, **kwargs): response = await old_handle(self, request) except HTTPException as e: transaction.set_http_status(e.status_code) + + if ( + e.status_code + in integration._failed_request_status_codes + ): + _capture_exception() + raise except (asyncio.CancelledError, ConnectionResetError): - transaction.set_status("cancelled") + transaction.set_status(SPANSTATUS.CANCELLED) raise except Exception: # This will probably map to a 500 but seems like we # have no way to tell. Do not set span status. - reraise(*_capture_exception(hub)) + reraise(*_capture_exception()) + + try: + # A valid response handler will return a valid response with a status. But, if the handler + # returns an invalid response (e.g. None), the line below will raise an AttributeError. + # Even though this is likely invalid, we need to handle this case to ensure we don't break + # the application. + response_status = response.status + except AttributeError: + pass + else: + transaction.set_http_status(response_status) - transaction.set_http_status(response.status) return response Application._handle = sentry_app_handle old_urldispatcher_resolve = UrlDispatcher.resolve - async def sentry_urldispatcher_resolve(self, request): - # type: (UrlDispatcher, Request) -> AbstractMatchInfo + @wraps(old_urldispatcher_resolve) + async def sentry_urldispatcher_resolve( + self: "UrlDispatcher", request: "Request" + ) -> "UrlMappingMatchInfo": rv = await old_urldispatcher_resolve(self, request) - hub = Hub.current - integration = hub.get_integration(AioHttpIntegration) + integration = sentry_sdk.get_client().get_integration(AioHttpIntegration) + if integration is None: + return rv name = None @@ -165,11 +194,10 @@ async def sentry_urldispatcher_resolve(self, request): pass if name is not None: - with Hub.current.configure_scope() as scope: - scope.set_transaction_name( - name, - source=SOURCE_FOR_STYLE[integration.transaction_style], - ) + sentry_sdk.get_current_scope().set_transaction_name( + name, + source=SOURCE_FOR_STYLE[integration.transaction_style], + ) return rv @@ -177,12 +205,8 @@ async def sentry_urldispatcher_resolve(self, request): old_client_session_init = ClientSession.__init__ - def init(*args, **kwargs): - # type: (Any, Any) -> ClientSession - hub = Hub.current - if hub.get_integration(AioHttpIntegration) is None: - return old_client_session_init(*args, **kwargs) - + @ensure_integration_enabled(AioHttpIntegration, old_client_session_init) + def init(*args: "Any", **kwargs: "Any") -> None: client_trace_configs = list(kwargs.get("trace_configs") or ()) trace_config = create_trace_config() client_trace_configs.append(trace_config) @@ -193,12 +217,13 @@ def init(*args, **kwargs): ClientSession.__init__ = init -def create_trace_config(): - # type: () -> TraceConfig - async def on_request_start(session, trace_config_ctx, params): - # type: (ClientSession, SimpleNamespace, TraceRequestStartParams) -> None - hub = Hub.current - if hub.get_integration(AioHttpIntegration) is None: +def create_trace_config() -> "TraceConfig": + async def on_request_start( + session: "ClientSession", + trace_config_ctx: "SimpleNamespace", + params: "TraceRequestStartParams", + ) -> None: + if sentry_sdk.get_client().get_integration(AioHttpIntegration) is None: return method = params.method.upper() @@ -207,18 +232,27 @@ async def on_request_start(session, trace_config_ctx, params): with capture_internal_exceptions(): parsed_url = parse_url(str(params.url), sanitize=False) - span = hub.start_span( + span = sentry_sdk.start_span( op=OP.HTTP_CLIENT, - description="%s %s" + name="%s %s" % (method, parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE), + origin=AioHttpIntegration.origin, ) span.set_data(SPANDATA.HTTP_METHOD, method) - span.set_data("url", parsed_url.url) - span.set_data(SPANDATA.HTTP_QUERY, parsed_url.query) - span.set_data(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment) - - if should_propagate_trace(hub, str(params.url)): - for key, value in hub.iter_trace_propagation_headers(span): + if parsed_url is not None: + span.set_data("url", parsed_url.url) + span.set_data(SPANDATA.HTTP_QUERY, parsed_url.query) + span.set_data(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment) + + client = sentry_sdk.get_client() + + if should_propagate_trace(client, str(params.url)): + for ( + key, + value, + ) in sentry_sdk.get_current_scope().iter_trace_propagation_headers( + span=span + ): logger.debug( "[Tracing] Adding `{key}` header {value} to outgoing request to {url}.".format( key=key, value=value, url=params.url @@ -234,8 +268,11 @@ async def on_request_start(session, trace_config_ctx, params): trace_config_ctx.span = span - async def on_request_end(session, trace_config_ctx, params): - # type: (ClientSession, SimpleNamespace, TraceRequestEndParams) -> None + async def on_request_end( + session: "ClientSession", + trace_config_ctx: "SimpleNamespace", + params: "TraceRequestEndParams", + ) -> None: if trace_config_ctx.span is None: return @@ -244,6 +281,9 @@ async def on_request_end(session, trace_config_ctx, params): span.set_data("reason", params.response.reason) span.finish() + with capture_internal_exceptions(): + add_http_request_source(span) + trace_config = TraceConfig() trace_config.on_request_start.append(on_request_start) @@ -252,13 +292,13 @@ async def on_request_end(session, trace_config_ctx, params): return trace_config -def _make_request_processor(weak_request): - # type: (Callable[[], Request]) -> EventProcessor +def _make_request_processor( + weak_request: "weakref.ReferenceType[Request]", +) -> "EventProcessor": def aiohttp_processor( - event, # type: Dict[str, Any] - hint, # type: Dict[str, Tuple[type, BaseException, Any]] - ): - # type: (...) -> Dict[str, Any] + event: "Event", + hint: "dict[str, Tuple[type, BaseException, Any]]", + ) -> "Event": request = weak_request() if request is None: return event @@ -275,42 +315,40 @@ def aiohttp_processor( request_info["query_string"] = request.query_string request_info["method"] = request.method request_info["env"] = {"REMOTE_ADDR": request.remote} - - hub = Hub.current request_info["headers"] = _filter_headers(dict(request.headers)) # Just attach raw data here if it is within bounds, if available. # Unfortunately there's no way to get structured data from aiohttp # without awaiting on some coroutine. - request_info["data"] = get_aiohttp_request_data(hub, request) + request_info["data"] = get_aiohttp_request_data(request) return event return aiohttp_processor -def _capture_exception(hub): - # type: (Hub) -> ExcInfo +def _capture_exception() -> "ExcInfo": exc_info = sys.exc_info() event, hint = event_from_exception( exc_info, - client_options=hub.client.options, # type: ignore + client_options=sentry_sdk.get_client().options, mechanism={"type": "aiohttp", "handled": False}, ) - hub.capture_event(event, hint=hint) + sentry_sdk.capture_event(event, hint=hint) return exc_info BODY_NOT_READ_MESSAGE = "[Can't show request body due to implementation details.]" -def get_aiohttp_request_data(hub, request): - # type: (Hub, Request) -> Union[Optional[str], AnnotatedValue] +def get_aiohttp_request_data( + request: "Request", +) -> "Union[Optional[str], AnnotatedValue]": bytes_body = request._read_bytes if bytes_body is not None: # we have body to show - if not request_body_within_bounds(hub.client, len(bytes_body)): + if not request_body_within_bounds(sentry_sdk.get_client(), len(bytes_body)): return AnnotatedValue.removed_because_over_size_limit() encoding = request.charset or "utf-8" diff --git a/sentry_sdk/integrations/anthropic.py b/sentry_sdk/integrations/anthropic.py new file mode 100644 index 0000000000..5257e3bf60 --- /dev/null +++ b/sentry_sdk/integrations/anthropic.py @@ -0,0 +1,456 @@ +from collections.abc import Iterable +from functools import wraps +from typing import TYPE_CHECKING + +import sentry_sdk +from sentry_sdk.ai.monitoring import record_token_usage +from sentry_sdk.ai.utils import ( + GEN_AI_ALLOWED_MESSAGE_ROLES, + set_data_normalized, + normalize_message_roles, + truncate_and_annotate_messages, + get_start_span_function, +) +from sentry_sdk.consts import OP, SPANDATA, SPANSTATUS +from sentry_sdk.integrations import _check_minimum_version, DidNotEnable, Integration +from sentry_sdk.scope import should_send_default_pii +from sentry_sdk.tracing_utils import set_span_errored +from sentry_sdk.utils import ( + capture_internal_exceptions, + event_from_exception, + package_version, + safe_serialize, +) + +try: + try: + from anthropic import NotGiven + except ImportError: + NotGiven = None + + try: + from anthropic import Omit + except ImportError: + Omit = None + + from anthropic.resources import AsyncMessages, Messages + + if TYPE_CHECKING: + from anthropic.types import MessageStreamEvent +except ImportError: + raise DidNotEnable("Anthropic not installed") + +if TYPE_CHECKING: + from typing import Any, AsyncIterator, Iterator, List, Optional, Union + from sentry_sdk.tracing import Span + + +class AnthropicIntegration(Integration): + identifier = "anthropic" + origin = f"auto.ai.{identifier}" + + def __init__(self: "AnthropicIntegration", include_prompts: bool = True) -> None: + self.include_prompts = include_prompts + + @staticmethod + def setup_once() -> None: + version = package_version("anthropic") + _check_minimum_version(AnthropicIntegration, version) + + Messages.create = _wrap_message_create(Messages.create) + AsyncMessages.create = _wrap_message_create_async(AsyncMessages.create) + + +def _capture_exception(exc: "Any") -> None: + set_span_errored() + + event, hint = event_from_exception( + exc, + client_options=sentry_sdk.get_client().options, + mechanism={"type": "anthropic", "handled": False}, + ) + sentry_sdk.capture_event(event, hint=hint) + + +def _get_token_usage(result: "Messages") -> "tuple[int, int]": + """ + Get token usage from the Anthropic response. + """ + input_tokens = 0 + output_tokens = 0 + if hasattr(result, "usage"): + usage = result.usage + if hasattr(usage, "input_tokens") and isinstance(usage.input_tokens, int): + input_tokens = usage.input_tokens + if hasattr(usage, "output_tokens") and isinstance(usage.output_tokens, int): + output_tokens = usage.output_tokens + + return input_tokens, output_tokens + + +def _collect_ai_data( + event: "MessageStreamEvent", + model: "str | None", + input_tokens: int, + output_tokens: int, + content_blocks: "list[str]", +) -> "tuple[str | None, int, int, list[str]]": + """ + Collect model information, token usage, and collect content blocks from the AI streaming response. + """ + with capture_internal_exceptions(): + if hasattr(event, "type"): + if event.type == "message_start": + usage = event.message.usage + input_tokens += usage.input_tokens + output_tokens += usage.output_tokens + model = event.message.model or model + elif event.type == "content_block_start": + pass + elif event.type == "content_block_delta": + if hasattr(event.delta, "text"): + content_blocks.append(event.delta.text) + elif hasattr(event.delta, "partial_json"): + content_blocks.append(event.delta.partial_json) + elif event.type == "content_block_stop": + pass + elif event.type == "message_delta": + output_tokens += event.usage.output_tokens + + return model, input_tokens, output_tokens, content_blocks + + +def _set_input_data( + span: "Span", kwargs: "dict[str, Any]", integration: "AnthropicIntegration" +) -> None: + """ + Set input data for the span based on the provided keyword arguments for the anthropic message creation. + """ + set_data_normalized(span, SPANDATA.GEN_AI_OPERATION_NAME, "chat") + system_prompt = kwargs.get("system") + messages = kwargs.get("messages") + if ( + messages is not None + and len(messages) > 0 + and should_send_default_pii() + and integration.include_prompts + ): + normalized_messages = [] + if system_prompt: + system_prompt_content: "Optional[Union[str, List[dict[str, Any]]]]" = None + if isinstance(system_prompt, str): + system_prompt_content = system_prompt + elif isinstance(system_prompt, Iterable): + system_prompt_content = [] + for item in system_prompt: + if ( + isinstance(item, dict) + and item.get("type") == "text" + and item.get("text") + ): + system_prompt_content.append(item.copy()) + + if system_prompt_content: + normalized_messages.append( + { + "role": GEN_AI_ALLOWED_MESSAGE_ROLES.SYSTEM, + "content": system_prompt_content, + } + ) + + for message in messages: + if ( + message.get("role") == GEN_AI_ALLOWED_MESSAGE_ROLES.USER + and "content" in message + and isinstance(message["content"], (list, tuple)) + ): + for item in message["content"]: + if item.get("type") == "tool_result": + normalized_messages.append( + { + "role": GEN_AI_ALLOWED_MESSAGE_ROLES.TOOL, + "content": { # type: ignore[dict-item] + "tool_use_id": item.get("tool_use_id"), + "output": item.get("content"), + }, + } + ) + else: + normalized_messages.append(message) + + role_normalized_messages = normalize_message_roles(normalized_messages) + scope = sentry_sdk.get_current_scope() + messages_data = truncate_and_annotate_messages( + role_normalized_messages, span, scope + ) + if messages_data is not None: + set_data_normalized( + span, SPANDATA.GEN_AI_REQUEST_MESSAGES, messages_data, unpack=False + ) + + set_data_normalized( + span, SPANDATA.GEN_AI_RESPONSE_STREAMING, kwargs.get("stream", False) + ) + + kwargs_keys_to_attributes = { + "max_tokens": SPANDATA.GEN_AI_REQUEST_MAX_TOKENS, + "model": SPANDATA.GEN_AI_REQUEST_MODEL, + "temperature": SPANDATA.GEN_AI_REQUEST_TEMPERATURE, + "top_k": SPANDATA.GEN_AI_REQUEST_TOP_K, + "top_p": SPANDATA.GEN_AI_REQUEST_TOP_P, + } + for key, attribute in kwargs_keys_to_attributes.items(): + value = kwargs.get(key) + + if value is not None and _is_given(value): + set_data_normalized(span, attribute, value) + + # Input attributes: Tools + tools = kwargs.get("tools") + if tools is not None and _is_given(tools) and len(tools) > 0: + set_data_normalized( + span, SPANDATA.GEN_AI_REQUEST_AVAILABLE_TOOLS, safe_serialize(tools) + ) + + +def _set_output_data( + span: "Span", + integration: "AnthropicIntegration", + model: "str | None", + input_tokens: "int | None", + output_tokens: "int | None", + content_blocks: "list[Any]", + finish_span: bool = False, +) -> None: + """ + Set output data for the span based on the AI response.""" + span.set_data(SPANDATA.GEN_AI_RESPONSE_MODEL, model) + if should_send_default_pii() and integration.include_prompts: + output_messages: "dict[str, list[Any]]" = { + "response": [], + "tool": [], + } + + for output in content_blocks: + if output["type"] == "text": + output_messages["response"].append(output["text"]) + elif output["type"] == "tool_use": + output_messages["tool"].append(output) + + if len(output_messages["tool"]) > 0: + set_data_normalized( + span, + SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS, + output_messages["tool"], + unpack=False, + ) + + if len(output_messages["response"]) > 0: + set_data_normalized( + span, SPANDATA.GEN_AI_RESPONSE_TEXT, output_messages["response"] + ) + + record_token_usage( + span, + input_tokens=input_tokens, + output_tokens=output_tokens, + ) + + if finish_span: + span.__exit__(None, None, None) + + +def _sentry_patched_create_common(f: "Any", *args: "Any", **kwargs: "Any") -> "Any": + integration = kwargs.pop("integration") + if integration is None: + return f(*args, **kwargs) + + if "messages" not in kwargs: + return f(*args, **kwargs) + + try: + iter(kwargs["messages"]) + except TypeError: + return f(*args, **kwargs) + + model = kwargs.get("model", "") + + span = get_start_span_function()( + op=OP.GEN_AI_CHAT, + name=f"chat {model}".strip(), + origin=AnthropicIntegration.origin, + ) + span.__enter__() + + _set_input_data(span, kwargs, integration) + + result = yield f, args, kwargs + + with capture_internal_exceptions(): + if hasattr(result, "content"): + input_tokens, output_tokens = _get_token_usage(result) + + content_blocks = [] + for content_block in result.content: + if hasattr(content_block, "to_dict"): + content_blocks.append(content_block.to_dict()) + elif hasattr(content_block, "model_dump"): + content_blocks.append(content_block.model_dump()) + elif hasattr(content_block, "text"): + content_blocks.append({"type": "text", "text": content_block.text}) + + _set_output_data( + span=span, + integration=integration, + model=getattr(result, "model", None), + input_tokens=input_tokens, + output_tokens=output_tokens, + content_blocks=content_blocks, + finish_span=True, + ) + + # Streaming response + elif hasattr(result, "_iterator"): + old_iterator = result._iterator + + def new_iterator() -> "Iterator[MessageStreamEvent]": + model = None + input_tokens = 0 + output_tokens = 0 + content_blocks: "list[str]" = [] + + for event in old_iterator: + model, input_tokens, output_tokens, content_blocks = ( + _collect_ai_data( + event, model, input_tokens, output_tokens, content_blocks + ) + ) + yield event + + _set_output_data( + span=span, + integration=integration, + model=model, + input_tokens=input_tokens, + output_tokens=output_tokens, + content_blocks=[{"text": "".join(content_blocks), "type": "text"}], + finish_span=True, + ) + + async def new_iterator_async() -> "AsyncIterator[MessageStreamEvent]": + model = None + input_tokens = 0 + output_tokens = 0 + content_blocks: "list[str]" = [] + + async for event in old_iterator: + model, input_tokens, output_tokens, content_blocks = ( + _collect_ai_data( + event, model, input_tokens, output_tokens, content_blocks + ) + ) + yield event + + _set_output_data( + span=span, + integration=integration, + model=model, + input_tokens=input_tokens, + output_tokens=output_tokens, + content_blocks=[{"text": "".join(content_blocks), "type": "text"}], + finish_span=True, + ) + + if str(type(result._iterator)) == "": + result._iterator = new_iterator_async() + else: + result._iterator = new_iterator() + + else: + span.set_data("unknown_response", True) + span.__exit__(None, None, None) + + return result + + +def _wrap_message_create(f: "Any") -> "Any": + def _execute_sync(f: "Any", *args: "Any", **kwargs: "Any") -> "Any": + gen = _sentry_patched_create_common(f, *args, **kwargs) + + try: + f, args, kwargs = next(gen) + except StopIteration as e: + return e.value + + try: + try: + result = f(*args, **kwargs) + except Exception as exc: + _capture_exception(exc) + raise exc from None + + return gen.send(result) + except StopIteration as e: + return e.value + + @wraps(f) + def _sentry_patched_create_sync(*args: "Any", **kwargs: "Any") -> "Any": + integration = sentry_sdk.get_client().get_integration(AnthropicIntegration) + kwargs["integration"] = integration + + try: + return _execute_sync(f, *args, **kwargs) + finally: + span = sentry_sdk.get_current_span() + if span is not None and span.status == SPANSTATUS.INTERNAL_ERROR: + with capture_internal_exceptions(): + span.__exit__(None, None, None) + + return _sentry_patched_create_sync + + +def _wrap_message_create_async(f: "Any") -> "Any": + async def _execute_async(f: "Any", *args: "Any", **kwargs: "Any") -> "Any": + gen = _sentry_patched_create_common(f, *args, **kwargs) + + try: + f, args, kwargs = next(gen) + except StopIteration as e: + return await e.value + + try: + try: + result = await f(*args, **kwargs) + except Exception as exc: + _capture_exception(exc) + raise exc from None + + return gen.send(result) + except StopIteration as e: + return e.value + + @wraps(f) + async def _sentry_patched_create_async(*args: "Any", **kwargs: "Any") -> "Any": + integration = sentry_sdk.get_client().get_integration(AnthropicIntegration) + kwargs["integration"] = integration + + try: + return await _execute_async(f, *args, **kwargs) + finally: + span = sentry_sdk.get_current_span() + if span is not None and span.status == SPANSTATUS.INTERNAL_ERROR: + with capture_internal_exceptions(): + span.__exit__(None, None, None) + + return _sentry_patched_create_async + + +def _is_given(obj: "Any") -> bool: + """ + Check for givenness safely across different anthropic versions. + """ + if NotGiven is not None and isinstance(obj, NotGiven): + return False + if Omit is not None and isinstance(obj, Omit): + return False + return True diff --git a/sentry_sdk/integrations/argv.py b/sentry_sdk/integrations/argv.py index fea08619d5..b5b867c297 100644 --- a/sentry_sdk/integrations/argv.py +++ b/sentry_sdk/integrations/argv.py @@ -1,12 +1,10 @@ -from __future__ import absolute_import - import sys -from sentry_sdk.hub import Hub +import sentry_sdk from sentry_sdk.integrations import Integration from sentry_sdk.scope import add_global_event_processor -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Optional @@ -18,12 +16,10 @@ class ArgvIntegration(Integration): identifier = "argv" @staticmethod - def setup_once(): - # type: () -> None + def setup_once() -> None: @add_global_event_processor - def processor(event, hint): - # type: (Event, Optional[Hint]) -> Optional[Event] - if Hub.current.get_integration(ArgvIntegration) is not None: + def processor(event: "Event", hint: "Optional[Hint]") -> "Optional[Event]": + if sentry_sdk.get_client().get_integration(ArgvIntegration) is not None: extra = event.setdefault("extra", {}) # If some event processor decided to set extra to e.g. an # `int`, don't crash. Not here. diff --git a/sentry_sdk/integrations/ariadne.py b/sentry_sdk/integrations/ariadne.py index 8025860a6f..d353b62bea 100644 --- a/sentry_sdk/integrations/ariadne.py +++ b/sentry_sdk/integrations/ariadne.py @@ -1,16 +1,17 @@ from importlib import import_module -from sentry_sdk.hub import Hub, _should_send_default_pii -from sentry_sdk.integrations import DidNotEnable, Integration +import sentry_sdk +from sentry_sdk import get_client, capture_event +from sentry_sdk.integrations import _check_minimum_version, DidNotEnable, Integration from sentry_sdk.integrations.logging import ignore_logger -from sentry_sdk.integrations.modules import _get_installed_modules from sentry_sdk.integrations._wsgi_common import request_body_within_bounds +from sentry_sdk.scope import should_send_default_pii from sentry_sdk.utils import ( capture_internal_exceptions, + ensure_integration_enabled, event_from_exception, - parse_version, + package_version, ) -from sentry_sdk._types import TYPE_CHECKING try: # importing like this is necessary due to name shadowing in ariadne @@ -19,107 +20,92 @@ except ImportError: raise DidNotEnable("ariadne is not installed") +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any, Dict, List, Optional from ariadne.types import GraphQLError, GraphQLResult, GraphQLSchema, QueryParser # type: ignore - from graphql.language.ast import DocumentNode # type: ignore - from sentry_sdk._types import EventProcessor + from graphql.language.ast import DocumentNode + from sentry_sdk._types import Event, EventProcessor class AriadneIntegration(Integration): identifier = "ariadne" @staticmethod - def setup_once(): - # type: () -> None - installed_packages = _get_installed_modules() - version = parse_version(installed_packages["ariadne"]) - - if version is None: - raise DidNotEnable("Unparsable ariadne version: {}".format(version)) - - if version < (0, 20): - raise DidNotEnable("ariadne 0.20 or newer required.") + def setup_once() -> None: + version = package_version("ariadne") + _check_minimum_version(AriadneIntegration, version) ignore_logger("ariadne") _patch_graphql() -def _patch_graphql(): - # type: () -> None +def _patch_graphql() -> None: old_parse_query = ariadne_graphql.parse_query old_handle_errors = ariadne_graphql.handle_graphql_errors old_handle_query_result = ariadne_graphql.handle_query_result - def _sentry_patched_parse_query(context_value, query_parser, data): - # type: (Optional[Any], Optional[QueryParser], Any) -> DocumentNode - hub = Hub.current - integration = hub.get_integration(AriadneIntegration) - if integration is None: - return old_parse_query(context_value, query_parser, data) - - with hub.configure_scope() as scope: - event_processor = _make_request_event_processor(data) - scope.add_event_processor(event_processor) + @ensure_integration_enabled(AriadneIntegration, old_parse_query) + def _sentry_patched_parse_query( + context_value: "Optional[Any]", + query_parser: "Optional[QueryParser]", + data: "Any", + ) -> "DocumentNode": + event_processor = _make_request_event_processor(data) + sentry_sdk.get_isolation_scope().add_event_processor(event_processor) result = old_parse_query(context_value, query_parser, data) return result - def _sentry_patched_handle_graphql_errors(errors, *args, **kwargs): - # type: (List[GraphQLError], Any, Any) -> GraphQLResult - hub = Hub.current - integration = hub.get_integration(AriadneIntegration) - if integration is None: - return old_handle_errors(errors, *args, **kwargs) - + @ensure_integration_enabled(AriadneIntegration, old_handle_errors) + def _sentry_patched_handle_graphql_errors( + errors: "List[GraphQLError]", *args: "Any", **kwargs: "Any" + ) -> "GraphQLResult": result = old_handle_errors(errors, *args, **kwargs) - with hub.configure_scope() as scope: - event_processor = _make_response_event_processor(result[1]) - scope.add_event_processor(event_processor) + event_processor = _make_response_event_processor(result[1]) + sentry_sdk.get_isolation_scope().add_event_processor(event_processor) - if hub.client: + client = get_client() + if client.is_active(): with capture_internal_exceptions(): for error in errors: event, hint = event_from_exception( error, - client_options=hub.client.options, + client_options=client.options, mechanism={ - "type": integration.identifier, + "type": AriadneIntegration.identifier, "handled": False, }, ) - hub.capture_event(event, hint=hint) + capture_event(event, hint=hint) return result - def _sentry_patched_handle_query_result(result, *args, **kwargs): - # type: (Any, Any, Any) -> GraphQLResult - hub = Hub.current - integration = hub.get_integration(AriadneIntegration) - if integration is None: - return old_handle_query_result(result, *args, **kwargs) - + @ensure_integration_enabled(AriadneIntegration, old_handle_query_result) + def _sentry_patched_handle_query_result( + result: "Any", *args: "Any", **kwargs: "Any" + ) -> "GraphQLResult": query_result = old_handle_query_result(result, *args, **kwargs) - with hub.configure_scope() as scope: - event_processor = _make_response_event_processor(query_result[1]) - scope.add_event_processor(event_processor) + event_processor = _make_response_event_processor(query_result[1]) + sentry_sdk.get_isolation_scope().add_event_processor(event_processor) - if hub.client: + client = get_client() + if client.is_active(): with capture_internal_exceptions(): for error in result.errors or []: event, hint = event_from_exception( error, - client_options=hub.client.options, + client_options=client.options, mechanism={ - "type": integration.identifier, + "type": AriadneIntegration.identifier, "handled": False, }, ) - hub.capture_event(event, hint=hint) + capture_event(event, hint=hint) return query_result @@ -128,12 +114,10 @@ def _sentry_patched_handle_query_result(result, *args, **kwargs): ariadne_graphql.handle_query_result = _sentry_patched_handle_query_result # type: ignore -def _make_request_event_processor(data): - # type: (GraphQLSchema) -> EventProcessor +def _make_request_event_processor(data: "GraphQLSchema") -> "EventProcessor": """Add request data and api_target to events.""" - def inner(event, hint): - # type: (Dict[str, Any], Dict[str, Any]) -> Dict[str, Any] + def inner(event: "Event", hint: "dict[str, Any]") -> "Event": if not isinstance(data, dict): return event @@ -145,8 +129,8 @@ def inner(event, hint): except (TypeError, ValueError): return event - if _should_send_default_pii() and request_body_within_bounds( - Hub.current.client, content_length + if should_send_default_pii() and request_body_within_bounds( + get_client(), content_length ): request_info = event.setdefault("request", {}) request_info["api_target"] = "graphql" @@ -160,14 +144,12 @@ def inner(event, hint): return inner -def _make_response_event_processor(response): - # type: (Dict[str, Any]) -> EventProcessor +def _make_response_event_processor(response: "Dict[str, Any]") -> "EventProcessor": """Add response data to the event's response context.""" - def inner(event, hint): - # type: (Dict[str, Any], Dict[str, Any]) -> Dict[str, Any] + def inner(event: "Event", hint: "dict[str, Any]") -> "Event": with capture_internal_exceptions(): - if _should_send_default_pii() and response.get("errors"): + if should_send_default_pii() and response.get("errors"): contexts = event.setdefault("contexts", {}) contexts["response"] = { "data": response, diff --git a/sentry_sdk/integrations/arq.py b/sentry_sdk/integrations/arq.py index 9997f4cac6..ee8aa393cf 100644 --- a/sentry_sdk/integrations/arq.py +++ b/sentry_sdk/integrations/arq.py @@ -1,20 +1,18 @@ -from __future__ import absolute_import - import sys -from sentry_sdk._compat import reraise -from sentry_sdk._types import TYPE_CHECKING -from sentry_sdk import Hub -from sentry_sdk.consts import OP -from sentry_sdk.hub import _should_send_default_pii -from sentry_sdk.integrations import DidNotEnable, Integration +import sentry_sdk +from sentry_sdk.consts import OP, SPANSTATUS +from sentry_sdk.integrations import _check_minimum_version, DidNotEnable, Integration from sentry_sdk.integrations.logging import ignore_logger -from sentry_sdk.tracing import Transaction, TRANSACTION_SOURCE_TASK +from sentry_sdk.scope import should_send_default_pii +from sentry_sdk.tracing import Transaction, TransactionSource from sentry_sdk.utils import ( capture_internal_exceptions, + ensure_integration_enabled, event_from_exception, SENSITIVE_DATA_SUBSTITUTE, parse_version, + reraise, ) try: @@ -25,6 +23,8 @@ except ImportError: raise DidNotEnable("Arq is not installed") +from typing import TYPE_CHECKING + if TYPE_CHECKING: from typing import Any, Dict, Optional, Union @@ -40,11 +40,10 @@ class ArqIntegration(Integration): identifier = "arq" + origin = f"auto.queue.{identifier}" @staticmethod - def setup_once(): - # type: () -> None - + def setup_once() -> None: try: if isinstance(ARQ_VERSION, str): version = parse_version(ARQ_VERSION) @@ -54,11 +53,7 @@ def setup_once(): except (TypeError, ValueError): version = None - if version is None: - raise DidNotEnable("Unparsable arq version: {}".format(ARQ_VERSION)) - - if version < (0, 23): - raise DidNotEnable("arq 0.23 or newer required.") + _check_minimum_version(ArqIntegration, version) patch_enqueue_job() patch_run_job() @@ -67,35 +62,35 @@ def setup_once(): ignore_logger("arq.worker") -def patch_enqueue_job(): - # type: () -> None +def patch_enqueue_job() -> None: old_enqueue_job = ArqRedis.enqueue_job + original_kwdefaults = old_enqueue_job.__kwdefaults__ - async def _sentry_enqueue_job(self, function, *args, **kwargs): - # type: (ArqRedis, str, *Any, **Any) -> Optional[Job] - hub = Hub.current - - if hub.get_integration(ArqIntegration) is None: + async def _sentry_enqueue_job( + self: "ArqRedis", function: str, *args: "Any", **kwargs: "Any" + ) -> "Optional[Job]": + integration = sentry_sdk.get_client().get_integration(ArqIntegration) + if integration is None: return await old_enqueue_job(self, function, *args, **kwargs) - with hub.start_span(op=OP.QUEUE_SUBMIT_ARQ, description=function): + with sentry_sdk.start_span( + op=OP.QUEUE_SUBMIT_ARQ, name=function, origin=ArqIntegration.origin + ): return await old_enqueue_job(self, function, *args, **kwargs) + _sentry_enqueue_job.__kwdefaults__ = original_kwdefaults ArqRedis.enqueue_job = _sentry_enqueue_job -def patch_run_job(): - # type: () -> None +def patch_run_job() -> None: old_run_job = Worker.run_job - async def _sentry_run_job(self, job_id, score): - # type: (Worker, str, int) -> None - hub = Hub(Hub.current) - - if hub.get_integration(ArqIntegration) is None: + async def _sentry_run_job(self: "Worker", job_id: str, score: int) -> None: + integration = sentry_sdk.get_client().get_integration(ArqIntegration) + if integration is None: return await old_run_job(self, job_id, score) - with hub.push_scope() as scope: + with sentry_sdk.isolation_scope() as scope: scope._name = "arq" scope.clear_breadcrumbs() @@ -103,44 +98,42 @@ async def _sentry_run_job(self, job_id, score): name="unknown arq task", status="ok", op=OP.QUEUE_TASK_ARQ, - source=TRANSACTION_SOURCE_TASK, + source=TransactionSource.TASK, + origin=ArqIntegration.origin, ) - with hub.start_transaction(transaction): + with sentry_sdk.start_transaction(transaction): return await old_run_job(self, job_id, score) Worker.run_job = _sentry_run_job -def _capture_exception(exc_info): - # type: (ExcInfo) -> None - hub = Hub.current +def _capture_exception(exc_info: "ExcInfo") -> None: + scope = sentry_sdk.get_current_scope() - if hub.scope.transaction is not None: + if scope.transaction is not None: if exc_info[0] in ARQ_CONTROL_FLOW_EXCEPTIONS: - hub.scope.transaction.set_status("aborted") + scope.transaction.set_status(SPANSTATUS.ABORTED) return - hub.scope.transaction.set_status("internal_error") + scope.transaction.set_status(SPANSTATUS.INTERNAL_ERROR) event, hint = event_from_exception( exc_info, - client_options=hub.client.options if hub.client else None, + client_options=sentry_sdk.get_client().options, mechanism={"type": ArqIntegration.identifier, "handled": False}, ) - hub.capture_event(event, hint=hint) + sentry_sdk.capture_event(event, hint=hint) -def _make_event_processor(ctx, *args, **kwargs): - # type: (Dict[Any, Any], *Any, **Any) -> EventProcessor - def event_processor(event, hint): - # type: (Event, Hint) -> Optional[Event] - - hub = Hub.current - +def _make_event_processor( + ctx: "Dict[Any, Any]", *args: "Any", **kwargs: "Any" +) -> "EventProcessor": + def event_processor(event: "Event", hint: "Hint") -> "Optional[Event]": with capture_internal_exceptions(): - if hub.scope.transaction is not None: - hub.scope.transaction.name = ctx["job_name"] + scope = sentry_sdk.get_current_scope() + if scope.transaction is not None: + scope.transaction.name = ctx["job_name"] event["transaction"] = ctx["job_name"] tags = event.setdefault("tags", {}) @@ -149,12 +142,12 @@ def event_processor(event, hint): extra = event.setdefault("extra", {}) extra["arq-job"] = { "task": ctx["job_name"], - "args": args - if _should_send_default_pii() - else SENSITIVE_DATA_SUBSTITUTE, - "kwargs": kwargs - if _should_send_default_pii() - else SENSITIVE_DATA_SUBSTITUTE, + "args": ( + args if should_send_default_pii() else SENSITIVE_DATA_SUBSTITUTE + ), + "kwargs": ( + kwargs if should_send_default_pii() else SENSITIVE_DATA_SUBSTITUTE + ), "retry": ctx["job_try"], } @@ -163,15 +156,15 @@ def event_processor(event, hint): return event_processor -def _wrap_coroutine(name, coroutine): - # type: (str, WorkerCoroutine) -> WorkerCoroutine - async def _sentry_coroutine(ctx, *args, **kwargs): - # type: (Dict[Any, Any], *Any, **Any) -> Any - hub = Hub.current - if hub.get_integration(ArqIntegration) is None: - return await coroutine(*args, **kwargs) +def _wrap_coroutine(name: str, coroutine: "WorkerCoroutine") -> "WorkerCoroutine": + async def _sentry_coroutine( + ctx: "Dict[Any, Any]", *args: "Any", **kwargs: "Any" + ) -> "Any": + integration = sentry_sdk.get_client().get_integration(ArqIntegration) + if integration is None: + return await coroutine(ctx, *args, **kwargs) - hub.scope.add_event_processor( + sentry_sdk.get_isolation_scope().add_event_processor( _make_event_processor({**ctx, "job_name": name}, *args, **kwargs) ) @@ -187,35 +180,42 @@ async def _sentry_coroutine(ctx, *args, **kwargs): return _sentry_coroutine -def patch_create_worker(): - # type: () -> None +def patch_create_worker() -> None: old_create_worker = arq.worker.create_worker - def _sentry_create_worker(*args, **kwargs): - # type: (*Any, **Any) -> Worker - hub = Hub.current - - if hub.get_integration(ArqIntegration) is None: - return old_create_worker(*args, **kwargs) - + @ensure_integration_enabled(ArqIntegration, old_create_worker) + def _sentry_create_worker(*args: "Any", **kwargs: "Any") -> "Worker": settings_cls = args[0] + if isinstance(settings_cls, dict): + if "functions" in settings_cls: + settings_cls["functions"] = [ + _get_arq_function(func) + for func in settings_cls.get("functions", []) + ] + if "cron_jobs" in settings_cls: + settings_cls["cron_jobs"] = [ + _get_arq_cron_job(cron_job) + for cron_job in settings_cls.get("cron_jobs", []) + ] + if hasattr(settings_cls, "functions"): settings_cls.functions = [ _get_arq_function(func) for func in settings_cls.functions ] if hasattr(settings_cls, "cron_jobs"): settings_cls.cron_jobs = [ - _get_arq_cron_job(cron_job) for cron_job in settings_cls.cron_jobs + _get_arq_cron_job(cron_job) + for cron_job in (settings_cls.cron_jobs or []) ] if "functions" in kwargs: kwargs["functions"] = [ - _get_arq_function(func) for func in kwargs["functions"] + _get_arq_function(func) for func in kwargs.get("functions", []) ] if "cron_jobs" in kwargs: kwargs["cron_jobs"] = [ - _get_arq_cron_job(cron_job) for cron_job in kwargs["cron_jobs"] + _get_arq_cron_job(cron_job) for cron_job in kwargs.get("cron_jobs", []) ] return old_create_worker(*args, **kwargs) @@ -223,16 +223,14 @@ def _sentry_create_worker(*args, **kwargs): arq.worker.create_worker = _sentry_create_worker -def _get_arq_function(func): - # type: (Union[str, Function, WorkerCoroutine]) -> Function +def _get_arq_function(func: "Union[str, Function, WorkerCoroutine]") -> "Function": arq_func = arq.worker.func(func) arq_func.coroutine = _wrap_coroutine(arq_func.name, arq_func.coroutine) return arq_func -def _get_arq_cron_job(cron_job): - # type: (CronJob) -> CronJob +def _get_arq_cron_job(cron_job: "CronJob") -> "CronJob": cron_job.coroutine = _wrap_coroutine(cron_job.name, cron_job.coroutine) return cron_job diff --git a/sentry_sdk/integrations/asgi.py b/sentry_sdk/integrations/asgi.py index 2cecdf9a81..6983af89ed 100644 --- a/sentry_sdk/integrations/asgi.py +++ b/sentry_sdk/integrations/asgi.py @@ -7,25 +7,24 @@ import asyncio import inspect from copy import deepcopy +from functools import partial -from sentry_sdk._functools import partial -from sentry_sdk._types import TYPE_CHECKING +import sentry_sdk from sentry_sdk.api import continue_trace from sentry_sdk.consts import OP -from sentry_sdk.hub import Hub - from sentry_sdk.integrations._asgi_common import ( _get_headers, _get_request_data, _get_url, ) -from sentry_sdk.integrations.modules import _get_installed_modules -from sentry_sdk.sessions import auto_session_tracking +from sentry_sdk.integrations._wsgi_common import ( + DEFAULT_HTTP_METHODS_TO_CAPTURE, + nullcontext, +) +from sentry_sdk.sessions import track_session from sentry_sdk.tracing import ( SOURCE_FOR_STYLE, - TRANSACTION_SOURCE_ROUTE, - TRANSACTION_SOURCE_URL, - TRANSACTION_SOURCE_COMPONENT, + TransactionSource, ) from sentry_sdk.utils import ( ContextVar, @@ -34,12 +33,14 @@ CONTEXTVARS_ERROR_MESSAGE, logger, transaction_from_function, + _get_installed_modules, ) from sentry_sdk.tracing import Transaction +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 typing import Tuple @@ -54,21 +55,16 @@ TRANSACTION_STYLE_VALUES = ("endpoint", "url") -def _capture_exception(hub, exc, mechanism_type="asgi"): - # type: (Hub, Any, str) -> None - - # Check client here as it might have been unset while streaming response - if hub.client is not None: - event, hint = event_from_exception( - exc, - client_options=hub.client.options, - mechanism={"type": mechanism_type, "handled": False}, - ) - hub.capture_event(event, hint=hint) +def _capture_exception(exc: "Any", mechanism_type: str = "asgi") -> None: + event, hint = event_from_exception( + exc, + client_options=sentry_sdk.get_client().options, + mechanism={"type": mechanism_type, "handled": False}, + ) + sentry_sdk.capture_event(event, hint=hint) -def _looks_like_asgi3(app): - # type: (Any) -> bool +def _looks_like_asgi3(app: "Any") -> bool: """ Try to figure out if an application object supports ASGI3. @@ -84,16 +80,25 @@ def _looks_like_asgi3(app): class SentryAsgiMiddleware: - __slots__ = ("app", "__call__", "transaction_style", "mechanism_type") + __slots__ = ( + "app", + "__call__", + "transaction_style", + "mechanism_type", + "span_origin", + "http_methods_to_capture", + ) def __init__( self, - app, - unsafe_context_data=False, - transaction_style="endpoint", - mechanism_type="asgi", - ): - # type: (Any, bool, str, str) -> None + app: "Any", + unsafe_context_data: bool = False, + transaction_style: str = "endpoint", + mechanism_type: str = "asgi", + span_origin: str = "manual", + http_methods_to_capture: "Tuple[str, ...]" = DEFAULT_HTTP_METHODS_TO_CAPTURE, + asgi_version: "Optional[int]" = None, + ) -> None: """ Instrument an ASGI application with Sentry. Provides HTTP/websocket data to sent events and basic handling for exceptions bubbling up @@ -126,27 +131,47 @@ def __init__( self.transaction_style = transaction_style self.mechanism_type = mechanism_type + self.span_origin = span_origin self.app = app + self.http_methods_to_capture = http_methods_to_capture + + if asgi_version is None: + if _looks_like_asgi3(app): + asgi_version = 3 + else: + asgi_version = 2 + + if asgi_version == 3: + self.__call__ = self._run_asgi3 + elif asgi_version == 2: + self.__call__ = self._run_asgi2 # type: ignore - if _looks_like_asgi3(app): - self.__call__ = self._run_asgi3 # type: Callable[..., Any] - else: - self.__call__ = self._run_asgi2 + def _capture_lifespan_exception(self, exc: Exception) -> None: + """Capture exceptions raise in application lifespan handlers. - def _run_asgi2(self, scope): - # type: (Any) -> Any - async def inner(receive, send): - # type: (Any, Any) -> Any + The separate function is needed to support overriding in derived integrations that use different catching mechanisms. + """ + return _capture_exception(exc=exc, mechanism_type=self.mechanism_type) + + def _capture_request_exception(self, exc: Exception) -> None: + """Capture exceptions raised in incoming request handlers. + + The separate function is needed to support overriding in derived integrations that use different catching mechanisms. + """ + return _capture_exception(exc=exc, mechanism_type=self.mechanism_type) + + def _run_asgi2(self, scope: "Any") -> "Any": + async def inner(receive: "Any", send: "Any") -> "Any": return await self._run_app(scope, receive, send, asgi_version=2) return inner - async def _run_asgi3(self, scope, receive, send): - # type: (Any, Any, Any) -> Any + async def _run_asgi3(self, scope: "Any", receive: "Any", send: "Any") -> "Any": return await self._run_app(scope, receive, send, asgi_version=3) - async def _run_app(self, scope, receive, send, asgi_version): - # type: (Any, Any, Any, Any, int) -> Any + async def _run_app( + self, scope: "Any", receive: "Any", send: "Any", asgi_version: int + ) -> "Any": is_recursive_asgi_middleware = _asgi_middleware_applied.get(False) is_lifespan = scope["type"] == "lifespan" if is_recursive_asgi_middleware or is_lifespan: @@ -157,19 +182,17 @@ async def _run_app(self, scope, receive, send, asgi_version): return await self.app(scope, receive, send) except Exception as exc: - _capture_exception(Hub.current, exc, mechanism_type=self.mechanism_type) + self._capture_lifespan_exception(exc) raise exc from None _asgi_middleware_applied.set(True) try: - hub = Hub(Hub.current) - with auto_session_tracking(hub, session_mode="request"): - with hub: - with hub.configure_scope() as sentry_scope: - sentry_scope.clear_breadcrumbs() - sentry_scope._name = "asgi" - processor = partial(self.event_processor, asgi_scope=scope) - sentry_scope.add_event_processor(processor) + with sentry_sdk.isolation_scope() as sentry_scope: + with track_session(sentry_scope, session_mode="request"): + sentry_scope.clear_breadcrumbs() + sentry_scope._name = "asgi" + processor = partial(self.event_processor, asgi_scope=scope) + sentry_scope.add_event_processor(processor) ty = scope["type"] ( @@ -180,49 +203,49 @@ async def _run_app(self, scope, receive, send, asgi_version): scope, ) + method = scope.get("method", "").upper() + transaction = None if ty in ("http", "websocket"): - transaction = continue_trace( - _get_headers(scope), - op="{}.server".format(ty), - name=transaction_name, - source=transaction_source, - ) - logger.debug( - "[ASGI] Created transaction (continuing trace): %s", - transaction, - ) + if ty == "websocket" or method in self.http_methods_to_capture: + transaction = continue_trace( + _get_headers(scope), + op="{}.server".format(ty), + name=transaction_name, + source=transaction_source, + origin=self.span_origin, + ) else: transaction = Transaction( op=OP.HTTP_SERVER, name=transaction_name, source=transaction_source, - ) - logger.debug( - "[ASGI] Created transaction (new): %s", transaction + origin=self.span_origin, ) - transaction.set_tag("asgi.type", ty) - logger.debug( - "[ASGI] Set transaction name and source on transaction: '%s' / '%s'", - transaction.name, - transaction.source, - ) + if transaction: + transaction.set_tag("asgi.type", ty) - with hub.start_transaction( - transaction, custom_sampling_context={"asgi_scope": scope} - ): - logger.debug("[ASGI] Started transaction: %s", transaction) + transaction_context = ( + sentry_sdk.start_transaction( + transaction, + custom_sampling_context={"asgi_scope": scope}, + ) + if transaction is not None + else nullcontext() + ) + with transaction_context: try: - async def _sentry_wrapped_send(event): - # type: (Dict[str, Any]) -> Any - is_http_response = ( - event.get("type") == "http.response.start" - and transaction is not None - and "status" in event - ) - if is_http_response: - transaction.set_http_status(event["status"]) + async def _sentry_wrapped_send( + event: "Dict[str, Any]", + ) -> "Any": + if transaction is not None: + is_http_response = ( + event.get("type") == "http.response.start" + and "status" in event + ) + if is_http_response: + transaction.set_http_status(event["status"]) return await send(event) @@ -235,26 +258,31 @@ async def _sentry_wrapped_send(event): scope, receive, _sentry_wrapped_send ) except Exception as exc: - _capture_exception( - hub, exc, mechanism_type=self.mechanism_type - ) + self._capture_request_exception(exc) raise exc from None finally: _asgi_middleware_applied.set(False) - def event_processor(self, event, hint, asgi_scope): - # type: (Event, Hint, Any) -> Optional[Event] + def event_processor( + self, event: "Event", hint: "Hint", asgi_scope: "Any" + ) -> "Optional[Event]": request_data = event.get("request", {}) request_data.update(_get_request_data(asgi_scope)) event["request"] = deepcopy(request_data) # Only set transaction name if not already set by Starlette or FastAPI (or other frameworks) - already_set = event["transaction"] != _DEFAULT_TRANSACTION_NAME and event[ - "transaction_info" - ].get("source") in [ - TRANSACTION_SOURCE_COMPONENT, - TRANSACTION_SOURCE_ROUTE, - ] + transaction = event.get("transaction") + transaction_source = (event.get("transaction_info") or {}).get("source") + already_set = ( + transaction is not None + and transaction != _DEFAULT_TRANSACTION_NAME + and transaction_source + in [ + TransactionSource.COMPONENT, + TransactionSource.ROUTE, + TransactionSource.CUSTOM, + ] + ) if not already_set: name, source = self._get_transaction_name_and_source( self.transaction_style, asgi_scope @@ -262,12 +290,6 @@ def event_processor(self, event, hint, asgi_scope): event["transaction"] = name event["transaction_info"] = {"source": source} - logger.debug( - "[ASGI] Set transaction name and source in event_processor: '%s' / '%s'", - event["transaction"], - event["transaction_info"]["source"], - ) - return event # Helper functions. @@ -276,8 +298,9 @@ def event_processor(self, event, hint, asgi_scope): # data to your liking it's recommended to use the `before_send` callback # for that. - def _get_transaction_name_and_source(self, transaction_style, asgi_scope): - # type: (SentryAsgiMiddleware, str, Any) -> Tuple[str, str] + def _get_transaction_name_and_source( + self: "SentryAsgiMiddleware", transaction_style: str, asgi_scope: "Any" + ) -> "Tuple[str, str]": name = None source = SOURCE_FOR_STYLE[transaction_style] ty = asgi_scope.get("type") @@ -291,7 +314,7 @@ def _get_transaction_name_and_source(self, transaction_style, asgi_scope): name = transaction_from_function(endpoint) or "" else: name = _get_url(asgi_scope, "http" if ty == "http" else "ws", host=None) - source = TRANSACTION_SOURCE_URL + source = TransactionSource.URL elif transaction_style == "url": # FastAPI includes the route object in the scope to let Sentry extract the @@ -303,11 +326,11 @@ def _get_transaction_name_and_source(self, transaction_style, asgi_scope): name = path else: name = _get_url(asgi_scope, "http" if ty == "http" else "ws", host=None) - source = TRANSACTION_SOURCE_URL + source = TransactionSource.URL if name is None: name = _DEFAULT_TRANSACTION_NAME - source = TRANSACTION_SOURCE_ROUTE + source = TransactionSource.ROUTE return name, source return name, source diff --git a/sentry_sdk/integrations/asyncio.py b/sentry_sdk/integrations/asyncio.py index 7f9b5b0c6d..39c7e3f879 100644 --- a/sentry_sdk/integrations/asyncio.py +++ b/sentry_sdk/integrations/asyncio.py @@ -1,12 +1,10 @@ -from __future__ import absolute_import import sys +import functools -from sentry_sdk._compat import reraise +import sentry_sdk from sentry_sdk.consts import OP -from sentry_sdk.hub import Hub from sentry_sdk.integrations import Integration, DidNotEnable -from sentry_sdk._types import TYPE_CHECKING -from sentry_sdk.utils import event_from_exception +from sentry_sdk.utils import event_from_exception, logger, reraise try: import asyncio @@ -14,16 +12,18 @@ except ImportError: raise DidNotEnable("asyncio not available") +from typing import cast, TYPE_CHECKING if TYPE_CHECKING: - from typing import Any + from typing import Any, Callable, TypeVar from collections.abc import Coroutine from sentry_sdk._types import ExcInfo + T = TypeVar("T", bound=Callable[..., Any]) -def get_name(coro): - # type: (Any) -> str + +def get_name(coro: "Any") -> str: return ( getattr(coro, "__qualname__", None) or getattr(coro, "__name__", None) @@ -31,76 +31,110 @@ def get_name(coro): ) -def patch_asyncio(): - # type: () -> None +def _wrap_coroutine(wrapped: "Coroutine[Any, Any, Any]") -> "Callable[[T], T]": + # Only __name__ and __qualname__ are copied from function to coroutine in CPython + return functools.partial( + functools.update_wrapper, + wrapped=wrapped, # type: ignore + assigned=("__name__", "__qualname__"), + updated=(), + ) + + +def patch_asyncio() -> None: orig_task_factory = None try: loop = asyncio.get_running_loop() orig_task_factory = loop.get_task_factory() - def _sentry_task_factory(loop, coro, **kwargs): - # type: (asyncio.AbstractEventLoop, Coroutine[Any, Any, Any], Any) -> asyncio.Future[Any] - - async def _coro_creating_hub_and_span(): - # type: () -> Any - hub = Hub(Hub.current) + def _sentry_task_factory( + loop: "asyncio.AbstractEventLoop", + coro: "Coroutine[Any, Any, Any]", + **kwargs: "Any", + ) -> "asyncio.Future[Any]": + @_wrap_coroutine(coro) + async def _task_with_sentry_span_creation() -> "Any": result = None - with hub: - with hub.start_span(op=OP.FUNCTION, description=get_name(coro)): + with sentry_sdk.isolation_scope(): + with sentry_sdk.start_span( + op=OP.FUNCTION, + name=get_name(coro), + origin=AsyncioIntegration.origin, + ): try: result = await coro + except StopAsyncIteration as e: + raise e from None except Exception: - reraise(*_capture_exception(hub)) + reraise(*_capture_exception()) return result + task = None + # Trying to use user set task factory (if there is one) if orig_task_factory: - return orig_task_factory(loop, _coro_creating_hub_and_span(), **kwargs) - - # The default task factory in `asyncio` does not have its own function - # but is just a couple of lines in `asyncio.base_events.create_task()` - # Those lines are copied here. - - # WARNING: - # If the default behavior of the task creation in asyncio changes, - # this will break! - task = Task(_coro_creating_hub_and_span(), loop=loop, **kwargs) - if task._source_traceback: # type: ignore - del task._source_traceback[-1] # type: ignore + task = orig_task_factory( + loop, _task_with_sentry_span_creation(), **kwargs + ) + + if task is None: + # The default task factory in `asyncio` does not have its own function + # but is just a couple of lines in `asyncio.base_events.create_task()` + # Those lines are copied here. + + # WARNING: + # If the default behavior of the task creation in asyncio changes, + # this will break! + task = Task(_task_with_sentry_span_creation(), loop=loop, **kwargs) + if task._source_traceback: # type: ignore + del task._source_traceback[-1] # type: ignore + + # Set the task name to include the original coroutine's name + try: + cast("asyncio.Task[Any]", task).set_name( + f"{get_name(coro)} (Sentry-wrapped)" + ) + except AttributeError: + # set_name might not be available in all Python versions + pass return task loop.set_task_factory(_sentry_task_factory) # type: ignore + except RuntimeError: # When there is no running loop, we have nothing to patch. - pass + logger.warning( + "There is no running asyncio loop so there is nothing Sentry can patch. " + "Please make sure you call sentry_sdk.init() within a running " + "asyncio loop for the AsyncioIntegration to work. " + "See https://docs.sentry.io/platforms/python/integrations/asyncio/" + ) -def _capture_exception(hub): - # type: (Hub) -> ExcInfo +def _capture_exception() -> "ExcInfo": exc_info = sys.exc_info() - integration = hub.get_integration(AsyncioIntegration) - if integration is not None: - # If an integration is there, a client has to be there. - client = hub.client # type: Any + client = sentry_sdk.get_client() + integration = client.get_integration(AsyncioIntegration) + if integration is not None: event, hint = event_from_exception( exc_info, client_options=client.options, mechanism={"type": "asyncio", "handled": False}, ) - hub.capture_event(event, hint=hint) + sentry_sdk.capture_event(event, hint=hint) return exc_info class AsyncioIntegration(Integration): identifier = "asyncio" + origin = f"auto.function.{identifier}" @staticmethod - def setup_once(): - # type: () -> None + def setup_once() -> None: patch_asyncio() diff --git a/sentry_sdk/integrations/asyncpg.py b/sentry_sdk/integrations/asyncpg.py index f74b874e35..7f3591154a 100644 --- a/sentry_sdk/integrations/asyncpg.py +++ b/sentry_sdk/integrations/asyncpg.py @@ -2,30 +2,28 @@ import contextlib from typing import Any, TypeVar, Callable, Awaitable, Iterator -from asyncpg.cursor import BaseCursor # type: ignore - -from sentry_sdk import Hub +import sentry_sdk from sentry_sdk.consts import OP, SPANDATA -from sentry_sdk.integrations import Integration, DidNotEnable +from sentry_sdk.integrations import _check_minimum_version, Integration, DidNotEnable from sentry_sdk.tracing import Span -from sentry_sdk.tracing_utils import record_sql_queries -from sentry_sdk.utils import parse_version, capture_internal_exceptions +from sentry_sdk.tracing_utils import add_query_source, record_sql_queries +from sentry_sdk.utils import ( + ensure_integration_enabled, + parse_version, + capture_internal_exceptions, +) try: import asyncpg # type: ignore[import-not-found] + from asyncpg.cursor import BaseCursor # type: ignore except ImportError: raise DidNotEnable("asyncpg not installed.") -# asyncpg.__version__ is a string containing the semantic version in the form of ".." -asyncpg_version = parse_version(asyncpg.__version__) - -if asyncpg_version is not None and asyncpg_version < (0, 23, 0): - raise DidNotEnable("asyncpg >= 0.23.0 required") - class AsyncPGIntegration(Integration): identifier = "asyncpg" + origin = f"auto.db.{identifier}" _record_params = False def __init__(self, *, record_params: bool = False): @@ -33,6 +31,10 @@ def __init__(self, *, record_params: bool = False): @staticmethod def setup_once() -> None: + # asyncpg.__version__ is a string containing the semantic version in the form of ".." + asyncpg_version = parse_version(asyncpg.__version__) + _check_minimum_version(AsyncPGIntegration, asyncpg_version) + asyncpg.Connection.execute = _wrap_execute( asyncpg.Connection.execute, ) @@ -53,21 +55,32 @@ def setup_once() -> None: T = TypeVar("T") -def _wrap_execute(f: Callable[..., Awaitable[T]]) -> Callable[..., Awaitable[T]]: - async def _inner(*args: Any, **kwargs: Any) -> T: - hub = Hub.current - integration = hub.get_integration(AsyncPGIntegration) +def _wrap_execute(f: "Callable[..., Awaitable[T]]") -> "Callable[..., Awaitable[T]]": + async def _inner(*args: "Any", **kwargs: "Any") -> "T": + if sentry_sdk.get_client().get_integration(AsyncPGIntegration) is None: + return await f(*args, **kwargs) # Avoid recording calls to _execute twice. # Calls to Connection.execute with args also call # Connection._execute, which is recorded separately # args[0] = the connection object, args[1] is the query - if integration is None or len(args) > 2: + if len(args) > 2: return await f(*args, **kwargs) query = args[1] - with record_sql_queries(hub, None, query, None, None, executemany=False): + with record_sql_queries( + cursor=None, + query=query, + params_list=None, + paramstyle=None, + executemany=False, + span_origin=AsyncPGIntegration.origin, + ) as span: res = await f(*args, **kwargs) + + with capture_internal_exceptions(): + add_query_source(span) + return res return _inner @@ -78,64 +91,54 @@ async def _inner(*args: Any, **kwargs: Any) -> T: @contextlib.contextmanager def _record( - hub: Hub, - cursor: SubCursor | None, + cursor: "SubCursor | None", query: str, - params_list: tuple[Any, ...] | None, + params_list: "tuple[Any, ...] | None", *, executemany: bool = False, -) -> Iterator[Span]: - integration = hub.get_integration(AsyncPGIntegration) - if not integration._record_params: +) -> "Iterator[Span]": + integration = sentry_sdk.get_client().get_integration(AsyncPGIntegration) + if integration is not None and not integration._record_params: params_list = None param_style = "pyformat" if params_list else None with record_sql_queries( - hub, - cursor, - query, - params_list, - param_style, + cursor=cursor, + query=query, + params_list=params_list, + paramstyle=param_style, executemany=executemany, record_cursor_repr=cursor is not None, + span_origin=AsyncPGIntegration.origin, ) as span: yield span def _wrap_connection_method( - f: Callable[..., Awaitable[T]], *, executemany: bool = False -) -> Callable[..., Awaitable[T]]: - async def _inner(*args: Any, **kwargs: Any) -> T: - hub = Hub.current - integration = hub.get_integration(AsyncPGIntegration) - - if integration is None: + f: "Callable[..., Awaitable[T]]", *, executemany: bool = False +) -> "Callable[..., Awaitable[T]]": + async def _inner(*args: "Any", **kwargs: "Any") -> "T": + if sentry_sdk.get_client().get_integration(AsyncPGIntegration) is None: return await f(*args, **kwargs) - query = args[1] params_list = args[2] if len(args) > 2 else None - with _record(hub, None, query, params_list, executemany=executemany) as span: + with _record(None, query, params_list, executemany=executemany) as span: _set_db_data(span, args[0]) res = await f(*args, **kwargs) + return res return _inner -def _wrap_cursor_creation(f: Callable[..., T]) -> Callable[..., T]: - def _inner(*args: Any, **kwargs: Any) -> T: # noqa: N807 - hub = Hub.current - integration = hub.get_integration(AsyncPGIntegration) - - if integration is None: - return f(*args, **kwargs) - +def _wrap_cursor_creation(f: "Callable[..., T]") -> "Callable[..., T]": + @ensure_integration_enabled(AsyncPGIntegration, f) + def _inner(*args: "Any", **kwargs: "Any") -> "T": # noqa: N807 query = args[1] params_list = args[2] if len(args) > 2 else None with _record( - hub, None, query, params_list, @@ -150,18 +153,21 @@ def _inner(*args: Any, **kwargs: Any) -> T: # noqa: N807 return _inner -def _wrap_connect_addr(f: Callable[..., Awaitable[T]]) -> Callable[..., Awaitable[T]]: - async def _inner(*args: Any, **kwargs: Any) -> T: - hub = Hub.current - integration = hub.get_integration(AsyncPGIntegration) - - if integration is None: +def _wrap_connect_addr( + f: "Callable[..., Awaitable[T]]", +) -> "Callable[..., Awaitable[T]]": + async def _inner(*args: "Any", **kwargs: "Any") -> "T": + if sentry_sdk.get_client().get_integration(AsyncPGIntegration) is None: return await f(*args, **kwargs) user = kwargs["params"].user database = kwargs["params"].database - with hub.start_span(op=OP.DB, description="connect") as span: + with sentry_sdk.start_span( + op=OP.DB, + name="connect", + origin=AsyncPGIntegration.origin, + ) as span: span.set_data(SPANDATA.DB_SYSTEM, "postgresql") addr = kwargs.get("addr") if addr: @@ -174,7 +180,9 @@ async def _inner(*args: Any, **kwargs: Any) -> T: span.set_data(SPANDATA.DB_USER, user) with capture_internal_exceptions(): - hub.add_breadcrumb(message="connect", category="query", data=span._data) + sentry_sdk.add_breadcrumb( + message="connect", category="query", data=span._data + ) res = await f(*args, **kwargs) return res @@ -182,7 +190,7 @@ async def _inner(*args: Any, **kwargs: Any) -> T: return _inner -def _set_db_data(span: Span, conn: Any) -> None: +def _set_db_data(span: "Span", conn: "Any") -> None: span.set_data(SPANDATA.DB_SYSTEM, "postgresql") addr = conn._addr diff --git a/sentry_sdk/integrations/atexit.py b/sentry_sdk/integrations/atexit.py index af70dd9fc9..efa4c74af0 100644 --- a/sentry_sdk/integrations/atexit.py +++ b/sentry_sdk/integrations/atexit.py @@ -1,29 +1,24 @@ -from __future__ import absolute_import - import os import sys import atexit -from sentry_sdk.hub import Hub +import sentry_sdk from sentry_sdk.utils import logger from sentry_sdk.integrations import Integration - -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any from typing import Optional -def default_callback(pending, timeout): - # type: (int, int) -> None +def default_callback(pending: int, timeout: int) -> None: """This is the default shutdown callback that is set on the options. It prints out a message to stderr that informs the user that some events are still pending and the process is waiting for them to flush out. """ - def echo(msg): - # type: (str) -> None + def echo(msg: str) -> None: sys.stderr.write(msg + "\n") echo("Sentry is attempting to send %i pending events" % pending) @@ -35,27 +30,23 @@ def echo(msg): class AtexitIntegration(Integration): identifier = "atexit" - def __init__(self, callback=None): - # type: (Optional[Any]) -> None + def __init__(self, callback: "Optional[Any]" = None) -> None: if callback is None: callback = default_callback self.callback = callback @staticmethod - def setup_once(): - # type: () -> None + def setup_once() -> None: @atexit.register - def _shutdown(): - # type: () -> None - logger.debug("atexit: got shutdown signal") - hub = Hub.main - integration = hub.get_integration(AtexitIntegration) - if integration is not None: - logger.debug("atexit: shutting down client") + def _shutdown() -> None: + client = sentry_sdk.get_client() + integration = client.get_integration(AtexitIntegration) - # If there is a session on the hub, close it now. - hub.end_session() + if integration is None: + return + + logger.debug("atexit: got shutdown signal") + logger.debug("atexit: shutting down client") + sentry_sdk.get_isolation_scope().end_session() - # If an integration is there, a client has to be there. - client = hub.client # type: Any - client.close(callback=integration.callback) + client.close(callback=integration.callback) diff --git a/sentry_sdk/integrations/aws_lambda.py b/sentry_sdk/integrations/aws_lambda.py index a6d32d9a59..22893313ae 100644 --- a/sentry_sdk/integrations/aws_lambda.py +++ b/sentry_sdk/integrations/aws_lambda.py @@ -1,26 +1,31 @@ +import functools +import json +import re import sys from copy import deepcopy -from datetime import timedelta +from datetime import datetime, timedelta, timezone from os import environ +import sentry_sdk from sentry_sdk.api import continue_trace from sentry_sdk.consts import OP -from sentry_sdk.hub import Hub, _should_send_default_pii -from sentry_sdk.tracing import TRANSACTION_SOURCE_COMPONENT +from sentry_sdk.scope import should_send_default_pii +from sentry_sdk.tracing import TransactionSource from sentry_sdk.utils import ( AnnotatedValue, capture_internal_exceptions, + ensure_integration_enabled, event_from_exception, logger, TimeoutThread, + reraise, ) from sentry_sdk.integrations import Integration from sentry_sdk.integrations._wsgi_common import _filter_headers -from sentry_sdk._compat import datetime_utcnow, reraise -from sentry_sdk._types import TYPE_CHECKING + +from typing import TYPE_CHECKING if TYPE_CHECKING: - from datetime import datetime from typing import Any from typing import TypeVar from typing import Callable @@ -35,22 +40,13 @@ MILLIS_TO_SECONDS = 1000.0 -def _wrap_init_error(init_error): - # type: (F) -> F - def sentry_init_error(*args, **kwargs): - # type: (*Any, **Any) -> Any - - hub = Hub.current - integration = hub.get_integration(AwsLambdaIntegration) - if integration is None: - return init_error(*args, **kwargs) - - # If an integration is there, a client has to be there. - client = hub.client # type: Any +def _wrap_init_error(init_error: "F") -> "F": + @ensure_integration_enabled(AwsLambdaIntegration, init_error) + def sentry_init_error(*args: "Any", **kwargs: "Any") -> "Any": + client = sentry_sdk.get_client() with capture_internal_exceptions(): - with hub.configure_scope() as scope: - scope.clear_breadcrumbs() + sentry_sdk.get_isolation_scope().clear_breadcrumbs() exc_info = sys.exc_info() if exc_info and all(exc_info): @@ -59,18 +55,26 @@ def sentry_init_error(*args, **kwargs): client_options=client.options, mechanism={"type": "aws_lambda", "handled": False}, ) - hub.capture_event(sentry_event, hint=hint) + sentry_sdk.capture_event(sentry_event, hint=hint) + + else: + # Fall back to AWS lambdas JSON representation of the error + error_info = args[1] + if isinstance(error_info, str): + error_info = json.loads(error_info) + sentry_event = _event_from_error_json(error_info) + sentry_sdk.capture_event(sentry_event) return init_error(*args, **kwargs) return sentry_init_error # type: ignore -def _wrap_handler(handler): - # type: (F) -> F - def sentry_handler(aws_event, aws_context, *args, **kwargs): - # type: (Any, Any, *Any, **Any) -> Any - +def _wrap_handler(handler: "F") -> "F": + @functools.wraps(handler) + def sentry_handler( + aws_event: "Any", aws_context: "Any", *args: "Any", **kwargs: "Any" + ) -> "Any": # Per https://docs.aws.amazon.com/lambda/latest/dg/python-handler.html, # `event` here is *likely* a dictionary, but also might be a number of # other types (str, int, float, None). @@ -81,7 +85,13 @@ def sentry_handler(aws_event, aws_context, *args, **kwargs): # will be the same for all events in the list, since they're all hitting # the lambda in the same request.) - if isinstance(aws_event, list): + client = sentry_sdk.get_client() + integration = client.get_integration(AwsLambdaIntegration) + + if integration is None: + return handler(aws_event, aws_context, *args, **kwargs) + + if isinstance(aws_event, list) and len(aws_event) >= 1: request_data = aws_event[0] batch_size = len(aws_event) else: @@ -94,16 +104,9 @@ def sentry_handler(aws_event, aws_context, *args, **kwargs): # this is empty request_data = {} - hub = Hub.current - integration = hub.get_integration(AwsLambdaIntegration) - if integration is None: - return handler(aws_event, aws_context, *args, **kwargs) - - # If an integration is there, a client has to be there. - client = hub.client # type: Any configured_time = aws_context.get_remaining_time_in_millis() - with hub.push_scope() as scope: + with sentry_sdk.isolation_scope() as scope: timeout_thread = None with capture_internal_exceptions(): scope.clear_breadcrumbs() @@ -132,23 +135,27 @@ def sentry_handler(aws_event, aws_context, *args, **kwargs): timeout_thread = TimeoutThread( waiting_time, configured_time / MILLIS_TO_SECONDS, + isolation_scope=scope, + current_scope=sentry_sdk.get_current_scope(), ) # Starting the thread to raise timeout warning exception timeout_thread.start() - headers = request_data.get("headers") - # AWS Service may set an explicit `{headers: None}`, we can't rely on `.get()`'s default. - if headers is None: + headers = request_data.get("headers", {}) + # Some AWS Services (ie. EventBridge) set headers as a list + # or None, so we must ensure it is a dict + if not isinstance(headers, dict): headers = {} transaction = continue_trace( headers, op=OP.FUNCTION_AWS, name=aws_context.function_name, - source=TRANSACTION_SOURCE_COMPONENT, + source=TransactionSource.COMPONENT, + origin=AwsLambdaIntegration.origin, ) - with hub.start_transaction( + with sentry_sdk.start_transaction( transaction, custom_sampling_context={ "aws_event": aws_event, @@ -164,7 +171,7 @@ def sentry_handler(aws_event, aws_context, *args, **kwargs): client_options=client.options, mechanism={"type": "aws_lambda", "handled": False}, ) - hub.capture_event(sentry_event, hint=hint) + sentry_sdk.capture_event(sentry_event, hint=hint) reraise(*exc_info) finally: if timeout_thread: @@ -173,28 +180,25 @@ def sentry_handler(aws_event, aws_context, *args, **kwargs): return sentry_handler # type: ignore -def _drain_queue(): - # type: () -> None +def _drain_queue() -> None: with capture_internal_exceptions(): - hub = Hub.current - integration = hub.get_integration(AwsLambdaIntegration) + client = sentry_sdk.get_client() + integration = client.get_integration(AwsLambdaIntegration) if integration is not None: # Flush out the event queue before AWS kills the # process. - hub.flush() + client.flush() class AwsLambdaIntegration(Integration): identifier = "aws_lambda" + origin = f"auto.function.{identifier}" - def __init__(self, timeout_warning=False): - # type: (bool) -> None + def __init__(self, timeout_warning: bool = False) -> None: self.timeout_warning = timeout_warning @staticmethod - def setup_once(): - # type: () -> None - + def setup_once() -> None: lambda_bootstrap = get_lambda_bootstrap() if not lambda_bootstrap: logger.warning( @@ -210,13 +214,14 @@ def setup_once(): ) return - pre_37 = hasattr(lambda_bootstrap, "handle_http_request") # Python 3.6 or 2.7 + pre_37 = hasattr(lambda_bootstrap, "handle_http_request") # Python 3.6 if pre_37: old_handle_event_request = lambda_bootstrap.handle_event_request - def sentry_handle_event_request(request_handler, *args, **kwargs): - # type: (Any, *Any, **Any) -> Any + def sentry_handle_event_request( + request_handler: "Any", *args: "Any", **kwargs: "Any" + ) -> "Any": request_handler = _wrap_handler(request_handler) return old_handle_event_request(request_handler, *args, **kwargs) @@ -224,8 +229,9 @@ def sentry_handle_event_request(request_handler, *args, **kwargs): old_handle_http_request = lambda_bootstrap.handle_http_request - def sentry_handle_http_request(request_handler, *args, **kwargs): - # type: (Any, *Any, **Any) -> Any + def sentry_handle_http_request( + request_handler: "Any", *args: "Any", **kwargs: "Any" + ) -> "Any": request_handler = _wrap_handler(request_handler) return old_handle_http_request(request_handler, *args, **kwargs) @@ -236,8 +242,7 @@ def sentry_handle_http_request(request_handler, *args, **kwargs): old_to_json = lambda_bootstrap.to_json - def sentry_to_json(*args, **kwargs): - # type: (*Any, **Any) -> Any + def sentry_to_json(*args: "Any", **kwargs: "Any") -> "Any": _drain_queue() return old_to_json(*args, **kwargs) @@ -262,10 +267,8 @@ def sentry_handle_event_request( # type: ignore # Patch the runtime client to drain the queue. This should work # even when the SDK is initialized inside of the handler - def _wrap_post_function(f): - # type: (F) -> F - def inner(*args, **kwargs): - # type: (*Any, **Any) -> Any + def _wrap_post_function(f: "F") -> "F": + def inner(*args: "Any", **kwargs: "Any") -> "Any": _drain_queue() return f(*args, **kwargs) @@ -283,11 +286,7 @@ def inner(*args, **kwargs): ) -def get_lambda_bootstrap(): - # type: () -> Optional[Any] - - # Python 2.7: Everything is in `__main__`. - # +def get_lambda_bootstrap() -> "Optional[Any]": # Python 3.7: If the bootstrap module is *already imported*, it is the # one we actually want to use (no idea what's in __main__) # @@ -322,12 +321,14 @@ def get_lambda_bootstrap(): return None -def _make_request_event_processor(aws_event, aws_context, configured_timeout): - # type: (Any, Any, Any) -> EventProcessor - start_time = datetime_utcnow() +def _make_request_event_processor( + aws_event: "Any", aws_context: "Any", configured_timeout: "Any" +) -> "EventProcessor": + start_time = datetime.now(timezone.utc) - def event_processor(sentry_event, hint, start_time=start_time): - # type: (Event, Hint, datetime) -> Optional[Event] + def event_processor( + sentry_event: "Event", hint: "Hint", start_time: "datetime" = start_time + ) -> "Optional[Event]": remaining_time_in_milis = aws_context.get_remaining_time_in_millis() exec_duration = configured_timeout - remaining_time_in_milis @@ -360,7 +361,7 @@ def event_processor(sentry_event, hint, start_time=start_time): if "headers" in aws_event: request["headers"] = _filter_headers(aws_event["headers"]) - if _should_send_default_pii(): + if should_send_default_pii(): user_info = sentry_event.setdefault("user", {}) identity = aws_event.get("identity") @@ -390,8 +391,7 @@ def event_processor(sentry_event, hint, start_time=start_time): return event_processor -def _get_url(aws_event, aws_context): - # type: (Any, Any) -> str +def _get_url(aws_event: "Any", aws_context: "Any") -> str: path = aws_event.get("path", None) headers = aws_event.get("headers") @@ -405,8 +405,7 @@ def _get_url(aws_event, aws_context): return "awslambda:///{}".format(aws_context.function_name) -def _get_cloudwatch_logs_url(aws_context, start_time): - # type: (Any, datetime) -> str +def _get_cloudwatch_logs_url(aws_context: "Any", start_time: "datetime") -> str: """ Generates a CloudWatchLogs console URL based on the context object @@ -429,7 +428,62 @@ def _get_cloudwatch_logs_url(aws_context, start_time): log_group=aws_context.log_group_name, log_stream=aws_context.log_stream_name, start_time=(start_time - timedelta(seconds=1)).strftime(formatstring), - end_time=(datetime_utcnow() + timedelta(seconds=2)).strftime(formatstring), + end_time=(datetime.now(timezone.utc) + timedelta(seconds=2)).strftime( + formatstring + ), ) return url + + +def _parse_formatted_traceback(formatted_tb: "list[str]") -> "list[dict[str, Any]]": + frames = [] + for frame in formatted_tb: + match = re.match(r'File "(.+)", line (\d+), in (.+)', frame.strip()) + if match: + file_name, line_number, func_name = match.groups() + line_number = int(line_number) + frames.append( + { + "filename": file_name, + "function": func_name, + "lineno": line_number, + "vars": None, + "pre_context": None, + "context_line": None, + "post_context": None, + } + ) + return frames + + +def _event_from_error_json(error_json: "dict[str, Any]") -> "Event": + """ + Converts the error JSON from AWS Lambda into a Sentry error event. + This is not a full fletched event, but better than nothing. + + This is an example of where AWS creates the error JSON: + https://github.com/aws/aws-lambda-python-runtime-interface-client/blob/2.2.1/awslambdaric/bootstrap.py#L479 + """ + event: "Event" = { + "level": "error", + "exception": { + "values": [ + { + "type": error_json.get("errorType"), + "value": error_json.get("errorMessage"), + "stacktrace": { + "frames": _parse_formatted_traceback( + error_json.get("stackTrace", []) + ), + }, + "mechanism": { + "type": "aws_lambda", + "handled": False, + }, + } + ], + }, + } + + return event diff --git a/sentry_sdk/integrations/beam.py b/sentry_sdk/integrations/beam.py index ea45087d05..6496e6293d 100644 --- a/sentry_sdk/integrations/beam.py +++ b/sentry_sdk/integrations/beam.py @@ -1,24 +1,25 @@ -from __future__ import absolute_import - import sys import types -from sentry_sdk._functools import wraps +from functools import wraps -from sentry_sdk.hub import Hub -from sentry_sdk._compat import reraise -from sentry_sdk.utils import capture_internal_exceptions, event_from_exception +import sentry_sdk from sentry_sdk.integrations import Integration from sentry_sdk.integrations.logging import ignore_logger -from sentry_sdk._types import TYPE_CHECKING +from sentry_sdk.utils import ( + capture_internal_exceptions, + ensure_integration_enabled, + event_from_exception, + reraise, +) + +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any from typing import Iterator from typing import TypeVar - from typing import Optional from typing import Callable - from sentry_sdk.client import Client from sentry_sdk._types import ExcInfo T = TypeVar("T") @@ -34,8 +35,7 @@ class BeamIntegration(Integration): identifier = "beam" @staticmethod - def setup_once(): - # type: () -> None + def setup_once() -> None: from apache_beam.transforms.core import DoFn, ParDo # type: ignore ignore_logger("root") @@ -51,8 +51,9 @@ def setup_once(): old_init = ParDo.__init__ - def sentry_init_pardo(self, fn, *args, **kwargs): - # type: (ParDo, Any, *Any, **Any) -> Any + def sentry_init_pardo( + self: "ParDo", fn: "Any", *args: "Any", **kwargs: "Any" + ) -> "Any": # Do not monkey patch init twice if not getattr(self, "_sentry_is_patched", False): for func_name in function_patches: @@ -78,14 +79,11 @@ def sentry_init_pardo(self, fn, *args, **kwargs): ParDo.__init__ = sentry_init_pardo -def _wrap_inspect_call(cls, func_name): - # type: (Any, Any) -> Any - +def _wrap_inspect_call(cls: "Any", func_name: "Any") -> "Any": if not hasattr(cls, func_name): return None - def _inspect(self): - # type: (Any) -> Any + def _inspect(self: "Any") -> "Any": """ Inspect function overrides the way Beam gets argspec. """ @@ -112,67 +110,52 @@ def _inspect(self): return _inspect -def _wrap_task_call(func): - # type: (F) -> F +def _wrap_task_call(func: "F") -> "F": """ Wrap task call with a try catch to get exceptions. - Pass the client on to raise_exception so it can get rebinded. """ - client = Hub.current.client @wraps(func) - def _inner(*args, **kwargs): - # type: (*Any, **Any) -> Any + def _inner(*args: "Any", **kwargs: "Any") -> "Any": try: gen = func(*args, **kwargs) except Exception: - raise_exception(client) + raise_exception() if not isinstance(gen, types.GeneratorType): return gen - return _wrap_generator_call(gen, client) + return _wrap_generator_call(gen) setattr(_inner, USED_FUNC, True) return _inner # type: ignore -def _capture_exception(exc_info, hub): - # type: (ExcInfo, Hub) -> None +@ensure_integration_enabled(BeamIntegration) +def _capture_exception(exc_info: "ExcInfo") -> None: """ Send Beam exception to Sentry. """ - integration = hub.get_integration(BeamIntegration) - if integration is None: - return - - client = hub.client - if client is None: - return + client = sentry_sdk.get_client() event, hint = event_from_exception( exc_info, client_options=client.options, mechanism={"type": "beam", "handled": False}, ) - hub.capture_event(event, hint=hint) + sentry_sdk.capture_event(event, hint=hint) -def raise_exception(client): - # type: (Optional[Client]) -> None +def raise_exception() -> None: """ - Raise an exception. If the client is not in the hub, rebind it. + Raise an exception. """ - hub = Hub.current - if hub.client is None: - hub.bind_client(client) exc_info = sys.exc_info() with capture_internal_exceptions(): - _capture_exception(exc_info, hub) + _capture_exception(exc_info) reraise(*exc_info) -def _wrap_generator_call(gen, client): - # type: (Iterator[T], Optional[Client]) -> Iterator[T] +def _wrap_generator_call(gen: "Iterator[T]") -> "Iterator[T]": """ Wrap the generator to handle any failures. """ @@ -182,4 +165,4 @@ def _wrap_generator_call(gen, client): except StopIteration: break except Exception: - raise_exception(client) + raise_exception() diff --git a/sentry_sdk/integrations/boto3.py b/sentry_sdk/integrations/boto3.py index a21772fc1a..b65e2c6b69 100644 --- a/sentry_sdk/integrations/boto3.py +++ b/sentry_sdk/integrations/boto3.py @@ -1,13 +1,17 @@ -from __future__ import absolute_import +from functools import partial -from sentry_sdk import Hub +import sentry_sdk from sentry_sdk.consts import OP, SPANDATA -from sentry_sdk.integrations import Integration, DidNotEnable +from sentry_sdk.integrations import _check_minimum_version, Integration, DidNotEnable from sentry_sdk.tracing import Span +from sentry_sdk.utils import ( + capture_internal_exceptions, + ensure_integration_enabled, + parse_url, + parse_version, +) -from sentry_sdk._functools import partial -from sentry_sdk._types import TYPE_CHECKING -from sentry_sdk.utils import capture_internal_exceptions, parse_url, parse_version +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any @@ -26,25 +30,18 @@ class Boto3Integration(Integration): identifier = "boto3" + origin = f"auto.http.{identifier}" @staticmethod - def setup_once(): - # type: () -> None - + def setup_once() -> None: version = parse_version(BOTOCORE_VERSION) - - if version is None: - raise DidNotEnable( - "Unparsable botocore version: {}".format(BOTOCORE_VERSION) - ) - - if version < (1, 12): - raise DidNotEnable("Botocore 1.12 or newer is required.") + _check_minimum_version(Boto3Integration, version, "botocore") orig_init = BaseClient.__init__ - def sentry_patched_init(self, *args, **kwargs): - # type: (Type[BaseClient], *Any, **Any) -> None + def sentry_patched_init( + self: "Type[BaseClient]", *args: "Any", **kwargs: "Any" + ) -> None: orig_init(self, *args, **kwargs) meta = self.meta service_id = meta.service_model.service_id.hyphenize() @@ -58,17 +55,15 @@ def sentry_patched_init(self, *args, **kwargs): BaseClient.__init__ = sentry_patched_init -def _sentry_request_created(service_id, request, operation_name, **kwargs): - # type: (str, AWSRequest, str, **Any) -> None - hub = Hub.current - if hub.get_integration(Boto3Integration) is None: - return - +@ensure_integration_enabled(Boto3Integration) +def _sentry_request_created( + service_id: str, request: "AWSRequest", operation_name: str, **kwargs: "Any" +) -> None: description = "aws.%s.%s" % (service_id, operation_name) - span = hub.start_span( - hub=hub, + span = sentry_sdk.start_span( op=OP.HTTP_CLIENT, - description=description, + name=description, + origin=Boto3Integration.origin, ) with capture_internal_exceptions(): @@ -90,9 +85,10 @@ def _sentry_request_created(service_id, request, operation_name, **kwargs): request.context["_sentrysdk_span"] = span -def _sentry_after_call(context, parsed, **kwargs): - # type: (Dict[str, Any], Dict[str, Any], **Any) -> None - span = context.pop("_sentrysdk_span", None) # type: Optional[Span] +def _sentry_after_call( + context: "Dict[str, Any]", parsed: "Dict[str, Any]", **kwargs: "Any" +) -> None: + span: "Optional[Span]" = context.pop("_sentrysdk_span", None) # Span could be absent if the integration is disabled. if span is None: @@ -105,14 +101,14 @@ def _sentry_after_call(context, parsed, **kwargs): streaming_span = span.start_child( op=OP.HTTP_CLIENT_STREAM, - description=span.description, + name=span.description, + origin=Boto3Integration.origin, ) orig_read = body.read orig_close = body.close - def sentry_streaming_body_read(*args, **kwargs): - # type: (*Any, **Any) -> bytes + def sentry_streaming_body_read(*args: "Any", **kwargs: "Any") -> bytes: try: ret = orig_read(*args, **kwargs) if not ret: @@ -124,17 +120,17 @@ def sentry_streaming_body_read(*args, **kwargs): body.read = sentry_streaming_body_read - def sentry_streaming_body_close(*args, **kwargs): - # type: (*Any, **Any) -> None + def sentry_streaming_body_close(*args: "Any", **kwargs: "Any") -> None: streaming_span.finish() orig_close(*args, **kwargs) body.close = sentry_streaming_body_close -def _sentry_after_call_error(context, exception, **kwargs): - # type: (Dict[str, Any], Type[BaseException], **Any) -> None - span = context.pop("_sentrysdk_span", None) # type: Optional[Span] +def _sentry_after_call_error( + context: "Dict[str, Any]", exception: "Type[BaseException]", **kwargs: "Any" +) -> None: + span: "Optional[Span]" = context.pop("_sentrysdk_span", None) # Span could be absent if the integration is disabled. if span is None: diff --git a/sentry_sdk/integrations/bottle.py b/sentry_sdk/integrations/bottle.py index cc6360daa3..29862c6d6c 100644 --- a/sentry_sdk/integrations/bottle.py +++ b/sentry_sdk/integrations/bottle.py @@ -1,20 +1,28 @@ -from __future__ import absolute_import +import functools -from sentry_sdk.hub import Hub +import sentry_sdk from sentry_sdk.tracing import SOURCE_FOR_STYLE from sentry_sdk.utils import ( capture_internal_exceptions, + ensure_integration_enabled, event_from_exception, parse_version, transaction_from_function, ) -from sentry_sdk.integrations import Integration, DidNotEnable +from sentry_sdk.integrations import ( + Integration, + DidNotEnable, + _DEFAULT_FAILED_REQUEST_STATUS_CODES, + _check_minimum_version, +) from sentry_sdk.integrations.wsgi import SentryWsgiMiddleware from sentry_sdk.integrations._wsgi_common import RequestExtractor -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: + from collections.abc import Set + from sentry_sdk.integrations.wsgi import _ScopedResponse from typing import Any from typing import Dict @@ -27,9 +35,9 @@ try: from bottle import ( Bottle, + HTTPResponse, Route, request as bottle_request, - HTTPResponse, __version__ as BOTTLE_VERSION, ) except ImportError: @@ -41,105 +49,88 @@ class BottleIntegration(Integration): identifier = "bottle" + origin = f"auto.http.{identifier}" transaction_style = "" - def __init__(self, transaction_style="endpoint"): - # type: (str) -> None - + def __init__( + self, + transaction_style: str = "endpoint", + *, + failed_request_status_codes: "Set[int]" = _DEFAULT_FAILED_REQUEST_STATUS_CODES, + ) -> None: if transaction_style not in TRANSACTION_STYLE_VALUES: raise ValueError( "Invalid value for transaction_style: %s (must be in %s)" % (transaction_style, TRANSACTION_STYLE_VALUES) ) self.transaction_style = transaction_style + self.failed_request_status_codes = failed_request_status_codes @staticmethod - def setup_once(): - # type: () -> None - + def setup_once() -> None: version = parse_version(BOTTLE_VERSION) + _check_minimum_version(BottleIntegration, version) - if version is None: - raise DidNotEnable("Unparsable Bottle version: {}".format(BOTTLE_VERSION)) - - if version < (0, 12): - raise DidNotEnable("Bottle 0.12 or newer required.") - - # monkey patch method Bottle.__call__ old_app = Bottle.__call__ - def sentry_patched_wsgi_app(self, environ, start_response): - # type: (Any, Dict[str, str], Callable[..., Any]) -> _ScopedResponse - - hub = Hub.current - integration = hub.get_integration(BottleIntegration) - if integration is None: - return old_app(self, environ, start_response) - - return SentryWsgiMiddleware(lambda *a, **kw: old_app(self, *a, **kw))( - environ, start_response + @ensure_integration_enabled(BottleIntegration, old_app) + def sentry_patched_wsgi_app( + self: "Any", environ: "Dict[str, str]", start_response: "Callable[..., Any]" + ) -> "_ScopedResponse": + middleware = SentryWsgiMiddleware( + lambda *a, **kw: old_app(self, *a, **kw), + span_origin=BottleIntegration.origin, ) + return middleware(environ, start_response) + Bottle.__call__ = sentry_patched_wsgi_app - # monkey patch method Bottle._handle old_handle = Bottle._handle - def _patched_handle(self, environ): - # type: (Bottle, Dict[str, Any]) -> Any - hub = Hub.current - integration = hub.get_integration(BottleIntegration) + @functools.wraps(old_handle) + def _patched_handle(self: "Bottle", environ: "Dict[str, Any]") -> "Any": + integration = sentry_sdk.get_client().get_integration(BottleIntegration) if integration is None: return old_handle(self, environ) - # create new scope - scope_manager = hub.push_scope() - - with scope_manager: - app = self - with hub.configure_scope() as scope: - scope._name = "bottle" - scope.add_event_processor( - _make_request_event_processor(app, bottle_request, integration) - ) - res = old_handle(self, environ) + scope = sentry_sdk.get_isolation_scope() + scope._name = "bottle" + scope.add_event_processor( + _make_request_event_processor(self, bottle_request, integration) + ) + res = old_handle(self, environ) - # scope cleanup return res Bottle._handle = _patched_handle - # monkey patch method Route._make_callback old_make_callback = Route._make_callback - def patched_make_callback(self, *args, **kwargs): - # type: (Route, *object, **object) -> Any - hub = Hub.current - integration = hub.get_integration(BottleIntegration) + @functools.wraps(old_make_callback) + def patched_make_callback( + self: "Route", *args: object, **kwargs: object + ) -> "Any": prepared_callback = old_make_callback(self, *args, **kwargs) + + integration = sentry_sdk.get_client().get_integration(BottleIntegration) if integration is None: return prepared_callback - # If an integration is there, a client has to be there. - client = hub.client # type: Any - - def wrapped_callback(*args, **kwargs): - # type: (*object, **object) -> Any - + def wrapped_callback(*args: object, **kwargs: object) -> "Any": try: res = prepared_callback(*args, **kwargs) - except HTTPResponse: - raise except Exception as exception: - event, hint = event_from_exception( - exception, - client_options=client.options, - mechanism={"type": "bottle", "handled": False}, - ) - hub.capture_event(event, hint=hint) + _capture_exception(exception, handled=False) raise exception + if ( + isinstance(res, HTTPResponse) + and res.status_code in integration.failed_request_status_codes + ): + _capture_exception(res, handled=True) + return res return wrapped_callback @@ -148,59 +139,59 @@ def wrapped_callback(*args, **kwargs): class BottleRequestExtractor(RequestExtractor): - def env(self): - # type: () -> Dict[str, str] + def env(self) -> "Dict[str, str]": return self.request.environ - def cookies(self): - # type: () -> Dict[str, str] + def cookies(self) -> "Dict[str, str]": return self.request.cookies - def raw_data(self): - # type: () -> bytes + def raw_data(self) -> bytes: return self.request.body.read() - def form(self): - # type: () -> FormsDict + def form(self) -> "FormsDict": if self.is_json(): return None return self.request.forms.decode() - def files(self): - # type: () -> Optional[Dict[str, str]] + def files(self) -> "Optional[Dict[str, str]]": if self.is_json(): return None return self.request.files - def size_of_file(self, file): - # type: (FileUpload) -> int + def size_of_file(self, file: "FileUpload") -> int: return file.content_length -def _set_transaction_name_and_source(event, transaction_style, request): - # type: (Event, str, Any) -> None +def _set_transaction_name_and_source( + event: "Event", transaction_style: str, request: "Any" +) -> None: name = "" if transaction_style == "url": - name = request.route.rule or "" + try: + name = request.route.rule or "" + except RuntimeError: + pass elif transaction_style == "endpoint": - name = ( - request.route.name - or transaction_from_function(request.route.callback) - or "" - ) + try: + name = ( + request.route.name + or transaction_from_function(request.route.callback) + or "" + ) + except RuntimeError: + pass event["transaction"] = name event["transaction_info"] = {"source": SOURCE_FOR_STYLE[transaction_style]} -def _make_request_event_processor(app, request, integration): - # type: (Bottle, LocalRequest, BottleIntegration) -> EventProcessor - - def event_processor(event, hint): - # type: (Dict[str, Any], Dict[str, Any]) -> Dict[str, Any] +def _make_request_event_processor( + app: "Bottle", request: "LocalRequest", integration: "BottleIntegration" +) -> "EventProcessor": + def event_processor(event: "Event", hint: "dict[str, Any]") -> "Event": _set_transaction_name_and_source(event, integration.transaction_style, request) with capture_internal_exceptions(): @@ -209,3 +200,12 @@ def event_processor(event, hint): return event return event_processor + + +def _capture_exception(exception: BaseException, handled: bool) -> None: + event, hint = event_from_exception( + exception, + client_options=sentry_sdk.get_client().options, + mechanism={"type": "bottle", "handled": handled}, + ) + sentry_sdk.capture_event(event, hint=hint) diff --git a/sentry_sdk/integrations/celery.py b/sentry_sdk/integrations/celery.py deleted file mode 100644 index a0c86ea982..0000000000 --- a/sentry_sdk/integrations/celery.py +++ /dev/null @@ -1,571 +0,0 @@ -from __future__ import absolute_import - -import sys -import time - -from sentry_sdk.api import continue_trace -from sentry_sdk.consts import OP -from sentry_sdk._compat import reraise -from sentry_sdk._functools import wraps -from sentry_sdk.crons import capture_checkin, MonitorStatus -from sentry_sdk.hub import Hub -from sentry_sdk.integrations import Integration, DidNotEnable -from sentry_sdk.integrations.logging import ignore_logger -from sentry_sdk.tracing import BAGGAGE_HEADER_NAME, TRANSACTION_SOURCE_TASK -from sentry_sdk._types import TYPE_CHECKING -from sentry_sdk.utils import ( - capture_internal_exceptions, - event_from_exception, - logger, - match_regex_list, -) - -if TYPE_CHECKING: - from typing import Any - from typing import Callable - from typing import Dict - from typing import List - from typing import Optional - from typing import Tuple - from typing import TypeVar - from typing import Union - - from sentry_sdk._types import EventProcessor, Event, Hint, ExcInfo - - F = TypeVar("F", bound=Callable[..., Any]) - - -try: - from celery import VERSION as CELERY_VERSION # type: ignore - from celery import Task, Celery - from celery.app.trace import task_has_custom - from celery.beat import Scheduler # type: ignore - from celery.exceptions import ( # type: ignore - Ignore, - Reject, - Retry, - SoftTimeLimitExceeded, - ) - from celery.schedules import crontab, schedule # type: ignore - from celery.signals import ( # type: ignore - task_failure, - task_success, - task_retry, - ) -except ImportError: - raise DidNotEnable("Celery not installed") - - -CELERY_CONTROL_FLOW_EXCEPTIONS = (Retry, Ignore, Reject) - - -class CeleryIntegration(Integration): - identifier = "celery" - - def __init__( - self, - propagate_traces=True, - monitor_beat_tasks=False, - exclude_beat_tasks=None, - ): - # type: (bool, bool, Optional[List[str]]) -> None - self.propagate_traces = propagate_traces - self.monitor_beat_tasks = monitor_beat_tasks - self.exclude_beat_tasks = exclude_beat_tasks - - if monitor_beat_tasks: - _patch_beat_apply_entry() - _setup_celery_beat_signals() - - @staticmethod - def setup_once(): - # type: () -> None - if CELERY_VERSION < (3,): - raise DidNotEnable("Celery 3 or newer required.") - - import celery.app.trace as trace # type: ignore - - old_build_tracer = trace.build_tracer - - def sentry_build_tracer(name, task, *args, **kwargs): - # type: (Any, Any, *Any, **Any) -> Any - if not getattr(task, "_sentry_is_patched", False): - # determine whether Celery will use __call__ or run and patch - # accordingly - if task_has_custom(task, "__call__"): - type(task).__call__ = _wrap_task_call(task, type(task).__call__) - else: - task.run = _wrap_task_call(task, task.run) - - # `build_tracer` is apparently called for every task - # invocation. Can't wrap every celery task for every invocation - # or we will get infinitely nested wrapper functions. - task._sentry_is_patched = True - - return _wrap_tracer(task, old_build_tracer(name, task, *args, **kwargs)) - - trace.build_tracer = sentry_build_tracer - - from celery.app.task import Task # type: ignore - - Task.apply_async = _wrap_apply_async(Task.apply_async) - - _patch_worker_exit() - - # This logger logs every status of every task that ran on the worker. - # Meaning that every task's breadcrumbs are full of stuff like "Task - # raised unexpected ". - ignore_logger("celery.worker.job") - ignore_logger("celery.app.trace") - - # This is stdout/err redirected to a logger, can't deal with this - # (need event_level=logging.WARN to reproduce) - ignore_logger("celery.redirected") - - -def _now_seconds_since_epoch(): - # type: () -> float - # We cannot use `time.perf_counter()` when dealing with the duration - # of a Celery task, because the start of a Celery task and - # the end are recorded in different processes. - # Start happens in the Celery Beat process, - # the end in a Celery Worker process. - return time.time() - - -def _wrap_apply_async(f): - # type: (F) -> F - @wraps(f) - def apply_async(*args, **kwargs): - # type: (*Any, **Any) -> Any - hub = Hub.current - integration = hub.get_integration(CeleryIntegration) - - if integration is None: - return f(*args, **kwargs) - - # Note: kwargs can contain headers=None, so no setdefault! - # Unsure which backend though. - kwarg_headers = kwargs.get("headers") or {} - propagate_traces = kwarg_headers.pop( - "sentry-propagate-traces", integration.propagate_traces - ) - - if not propagate_traces: - return f(*args, **kwargs) - - with hub.start_span( - op=OP.QUEUE_SUBMIT_CELERY, description=args[0].name - ) as span: - with capture_internal_exceptions(): - headers = dict(hub.iter_trace_propagation_headers(span)) - if integration.monitor_beat_tasks: - headers.update( - { - "sentry-monitor-start-timestamp-s": "%.9f" - % _now_seconds_since_epoch(), - } - ) - - if headers: - existing_baggage = kwarg_headers.get(BAGGAGE_HEADER_NAME) - sentry_baggage = headers.get(BAGGAGE_HEADER_NAME) - - combined_baggage = sentry_baggage or existing_baggage - if sentry_baggage and existing_baggage: - combined_baggage = "{},{}".format( - existing_baggage, - sentry_baggage, - ) - - kwarg_headers.update(headers) - if combined_baggage: - kwarg_headers[BAGGAGE_HEADER_NAME] = combined_baggage - - # https://github.com/celery/celery/issues/4875 - # - # Need to setdefault the inner headers too since other - # tracing tools (dd-trace-py) also employ this exact - # workaround and we don't want to break them. - kwarg_headers.setdefault("headers", {}).update(headers) - if combined_baggage: - kwarg_headers["headers"][BAGGAGE_HEADER_NAME] = combined_baggage - - # Add the Sentry options potentially added in `sentry_apply_entry` - # to the headers (done when auto-instrumenting Celery Beat tasks) - for key, value in kwarg_headers.items(): - if key.startswith("sentry-"): - kwarg_headers["headers"][key] = value - - kwargs["headers"] = kwarg_headers - - return f(*args, **kwargs) - - return apply_async # type: ignore - - -def _wrap_tracer(task, f): - # type: (Any, F) -> F - - # Need to wrap tracer for pushing the scope before prerun is sent, and - # popping it after postrun is sent. - # - # This is the reason we don't use signals for hooking in the first place. - # Also because in Celery 3, signal dispatch returns early if one handler - # crashes. - @wraps(f) - def _inner(*args, **kwargs): - # type: (*Any, **Any) -> Any - hub = Hub.current - if hub.get_integration(CeleryIntegration) is None: - return f(*args, **kwargs) - - with hub.push_scope() as scope: - scope._name = "celery" - scope.clear_breadcrumbs() - scope.add_event_processor(_make_event_processor(task, *args, **kwargs)) - - transaction = None - - # Celery task objects are not a thing to be trusted. Even - # something such as attribute access can fail. - with capture_internal_exceptions(): - transaction = continue_trace( - args[3].get("headers") or {}, - op=OP.QUEUE_TASK_CELERY, - name="unknown celery task", - source=TRANSACTION_SOURCE_TASK, - ) - transaction.name = task.name - transaction.set_status("ok") - - if transaction is None: - return f(*args, **kwargs) - - with hub.start_transaction( - transaction, - custom_sampling_context={ - "celery_job": { - "task": task.name, - # for some reason, args[1] is a list if non-empty but a - # tuple if empty - "args": list(args[1]), - "kwargs": args[2], - } - }, - ): - return f(*args, **kwargs) - - return _inner # type: ignore - - -def _wrap_task_call(task, f): - # type: (Any, F) -> F - - # Need to wrap task call because the exception is caught before we get to - # see it. Also celery's reported stacktrace is untrustworthy. - - # functools.wraps is important here because celery-once looks at this - # method's name. - # https://github.com/getsentry/sentry-python/issues/421 - @wraps(f) - def _inner(*args, **kwargs): - # type: (*Any, **Any) -> Any - try: - return f(*args, **kwargs) - except Exception: - exc_info = sys.exc_info() - with capture_internal_exceptions(): - _capture_exception(task, exc_info) - reraise(*exc_info) - - return _inner # type: ignore - - -def _make_event_processor(task, uuid, args, kwargs, request=None): - # type: (Any, Any, Any, Any, Optional[Any]) -> EventProcessor - def event_processor(event, hint): - # type: (Event, Hint) -> Optional[Event] - - with capture_internal_exceptions(): - tags = event.setdefault("tags", {}) - tags["celery_task_id"] = uuid - extra = event.setdefault("extra", {}) - extra["celery-job"] = { - "task_name": task.name, - "args": args, - "kwargs": kwargs, - } - - if "exc_info" in hint: - with capture_internal_exceptions(): - if issubclass(hint["exc_info"][0], SoftTimeLimitExceeded): - event["fingerprint"] = [ - "celery", - "SoftTimeLimitExceeded", - getattr(task, "name", task), - ] - - return event - - return event_processor - - -def _capture_exception(task, exc_info): - # type: (Any, ExcInfo) -> None - hub = Hub.current - - if hub.get_integration(CeleryIntegration) is None: - return - if isinstance(exc_info[1], CELERY_CONTROL_FLOW_EXCEPTIONS): - # ??? Doesn't map to anything - _set_status(hub, "aborted") - return - - _set_status(hub, "internal_error") - - if hasattr(task, "throws") and isinstance(exc_info[1], task.throws): - return - - # If an integration is there, a client has to be there. - client = hub.client # type: Any - - event, hint = event_from_exception( - exc_info, - client_options=client.options, - mechanism={"type": "celery", "handled": False}, - ) - - hub.capture_event(event, hint=hint) - - -def _set_status(hub, status): - # type: (Hub, str) -> None - with capture_internal_exceptions(): - with hub.configure_scope() as scope: - if scope.span is not None: - scope.span.set_status(status) - - -def _patch_worker_exit(): - # type: () -> None - - # Need to flush queue before worker shutdown because a crashing worker will - # call os._exit - from billiard.pool import Worker # type: ignore - - old_workloop = Worker.workloop - - def sentry_workloop(*args, **kwargs): - # type: (*Any, **Any) -> Any - try: - return old_workloop(*args, **kwargs) - finally: - with capture_internal_exceptions(): - hub = Hub.current - if hub.get_integration(CeleryIntegration) is not None: - hub.flush() - - Worker.workloop = sentry_workloop - - -def _get_headers(task): - # type: (Task) -> Dict[str, Any] - headers = task.request.get("headers") or {} - - # flatten nested headers - if "headers" in headers: - headers.update(headers["headers"]) - del headers["headers"] - - headers.update(task.request.get("properties") or {}) - - return headers - - -def _get_humanized_interval(seconds): - # type: (float) -> Tuple[int, str] - TIME_UNITS = ( # noqa: N806 - ("day", 60 * 60 * 24.0), - ("hour", 60 * 60.0), - ("minute", 60.0), - ) - - seconds = float(seconds) - for unit, divider in TIME_UNITS: - if seconds >= divider: - interval = int(seconds / divider) - return (interval, unit) - - return (int(seconds), "second") - - -def _get_monitor_config(celery_schedule, app, monitor_name): - # type: (Any, Celery, str) -> Dict[str, Any] - monitor_config = {} # type: Dict[str, Any] - schedule_type = None # type: Optional[str] - schedule_value = None # type: Optional[Union[str, int]] - schedule_unit = None # type: Optional[str] - - if isinstance(celery_schedule, crontab): - schedule_type = "crontab" - schedule_value = ( - "{0._orig_minute} " - "{0._orig_hour} " - "{0._orig_day_of_month} " - "{0._orig_month_of_year} " - "{0._orig_day_of_week}".format(celery_schedule) - ) - elif isinstance(celery_schedule, schedule): - schedule_type = "interval" - (schedule_value, schedule_unit) = _get_humanized_interval( - celery_schedule.seconds - ) - - if schedule_unit == "second": - logger.warning( - "Intervals shorter than one minute are not supported by Sentry Crons. Monitor '%s' has an interval of %s seconds. Use the `exclude_beat_tasks` option in the celery integration to exclude it.", - monitor_name, - schedule_value, - ) - return {} - - else: - logger.warning( - "Celery schedule type '%s' not supported by Sentry Crons.", - type(celery_schedule), - ) - return {} - - monitor_config["schedule"] = {} - monitor_config["schedule"]["type"] = schedule_type - monitor_config["schedule"]["value"] = schedule_value - - if schedule_unit is not None: - monitor_config["schedule"]["unit"] = schedule_unit - - monitor_config["timezone"] = app.conf.timezone or "UTC" - - return monitor_config - - -def _patch_beat_apply_entry(): - # type: () -> None - original_apply_entry = Scheduler.apply_entry - - def sentry_apply_entry(*args, **kwargs): - # type: (*Any, **Any) -> None - scheduler, schedule_entry = args - app = scheduler.app - - celery_schedule = schedule_entry.schedule - monitor_name = schedule_entry.name - - hub = Hub.current - integration = hub.get_integration(CeleryIntegration) - if integration is None: - return original_apply_entry(*args, **kwargs) - - if match_regex_list(monitor_name, integration.exclude_beat_tasks): - return original_apply_entry(*args, **kwargs) - - with hub.configure_scope() as scope: - # When tasks are started from Celery Beat, make sure each task has its own trace. - scope.set_new_propagation_context() - - monitor_config = _get_monitor_config(celery_schedule, app, monitor_name) - - is_supported_schedule = bool(monitor_config) - if is_supported_schedule: - headers = schedule_entry.options.pop("headers", {}) - headers.update( - { - "sentry-monitor-slug": monitor_name, - "sentry-monitor-config": monitor_config, - } - ) - - check_in_id = capture_checkin( - monitor_slug=monitor_name, - monitor_config=monitor_config, - status=MonitorStatus.IN_PROGRESS, - ) - headers.update({"sentry-monitor-check-in-id": check_in_id}) - - # Set the Sentry configuration in the options of the ScheduleEntry. - # Those will be picked up in `apply_async` and added to the headers. - schedule_entry.options["headers"] = headers - - return original_apply_entry(*args, **kwargs) - - Scheduler.apply_entry = sentry_apply_entry - - -def _setup_celery_beat_signals(): - # type: () -> None - task_success.connect(crons_task_success) - task_failure.connect(crons_task_failure) - task_retry.connect(crons_task_retry) - - -def crons_task_success(sender, **kwargs): - # type: (Task, Dict[Any, Any]) -> None - logger.debug("celery_task_success %s", sender) - headers = _get_headers(sender) - - if "sentry-monitor-slug" not in headers: - return - - monitor_config = headers.get("sentry-monitor-config", {}) - - start_timestamp_s = float(headers["sentry-monitor-start-timestamp-s"]) - - capture_checkin( - monitor_slug=headers["sentry-monitor-slug"], - monitor_config=monitor_config, - check_in_id=headers["sentry-monitor-check-in-id"], - duration=_now_seconds_since_epoch() - start_timestamp_s, - status=MonitorStatus.OK, - ) - - -def crons_task_failure(sender, **kwargs): - # type: (Task, Dict[Any, Any]) -> None - logger.debug("celery_task_failure %s", sender) - headers = _get_headers(sender) - - if "sentry-monitor-slug" not in headers: - return - - monitor_config = headers.get("sentry-monitor-config", {}) - - start_timestamp_s = float(headers["sentry-monitor-start-timestamp-s"]) - - capture_checkin( - monitor_slug=headers["sentry-monitor-slug"], - monitor_config=monitor_config, - check_in_id=headers["sentry-monitor-check-in-id"], - duration=_now_seconds_since_epoch() - start_timestamp_s, - status=MonitorStatus.ERROR, - ) - - -def crons_task_retry(sender, **kwargs): - # type: (Task, Dict[Any, Any]) -> None - logger.debug("celery_task_retry %s", sender) - headers = _get_headers(sender) - - if "sentry-monitor-slug" not in headers: - return - - monitor_config = headers.get("sentry-monitor-config", {}) - - start_timestamp_s = float(headers["sentry-monitor-start-timestamp-s"]) - - capture_checkin( - monitor_slug=headers["sentry-monitor-slug"], - monitor_config=monitor_config, - check_in_id=headers["sentry-monitor-check-in-id"], - duration=_now_seconds_since_epoch() - start_timestamp_s, - status=MonitorStatus.ERROR, - ) diff --git a/sentry_sdk/integrations/celery/__init__.py b/sentry_sdk/integrations/celery/__init__.py new file mode 100644 index 0000000000..2baf250ae3 --- /dev/null +++ b/sentry_sdk/integrations/celery/__init__.py @@ -0,0 +1,511 @@ +import sys +from collections.abc import Mapping +from functools import wraps + +import sentry_sdk +from sentry_sdk import isolation_scope +from sentry_sdk.api import continue_trace +from sentry_sdk.consts import OP, SPANSTATUS, SPANDATA +from sentry_sdk.integrations import _check_minimum_version, Integration, DidNotEnable +from sentry_sdk.integrations.celery.beat import ( + _patch_beat_apply_entry, + _patch_redbeat_apply_async, + _setup_celery_beat_signals, +) +from sentry_sdk.integrations.celery.utils import _now_seconds_since_epoch +from sentry_sdk.integrations.logging import ignore_logger +from sentry_sdk.tracing import BAGGAGE_HEADER_NAME, TransactionSource +from sentry_sdk.tracing_utils import Baggage +from sentry_sdk.utils import ( + capture_internal_exceptions, + ensure_integration_enabled, + event_from_exception, + reraise, +) + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Any + from typing import Callable + from typing import List + from typing import Optional + from typing import TypeVar + from typing import Union + + from sentry_sdk._types import EventProcessor, Event, Hint, ExcInfo + from sentry_sdk.tracing import Span + + F = TypeVar("F", bound=Callable[..., Any]) + + +try: + from celery import VERSION as CELERY_VERSION # type: ignore + from celery.app.task import Task # type: ignore + from celery.app.trace import task_has_custom + from celery.exceptions import ( # type: ignore + Ignore, + Reject, + Retry, + SoftTimeLimitExceeded, + ) + from kombu import Producer # type: ignore +except ImportError: + raise DidNotEnable("Celery not installed") + + +CELERY_CONTROL_FLOW_EXCEPTIONS = (Retry, Ignore, Reject) + + +class CeleryIntegration(Integration): + identifier = "celery" + origin = f"auto.queue.{identifier}" + + def __init__( + self, + propagate_traces: bool = True, + monitor_beat_tasks: bool = False, + exclude_beat_tasks: "Optional[List[str]]" = None, + ) -> None: + self.propagate_traces = propagate_traces + self.monitor_beat_tasks = monitor_beat_tasks + self.exclude_beat_tasks = exclude_beat_tasks + + _patch_beat_apply_entry() + _patch_redbeat_apply_async() + _setup_celery_beat_signals(monitor_beat_tasks) + + @staticmethod + def setup_once() -> None: + _check_minimum_version(CeleryIntegration, CELERY_VERSION) + + _patch_build_tracer() + _patch_task_apply_async() + _patch_celery_send_task() + _patch_worker_exit() + _patch_producer_publish() + + # This logger logs every status of every task that ran on the worker. + # Meaning that every task's breadcrumbs are full of stuff like "Task + # raised unexpected ". + ignore_logger("celery.worker.job") + ignore_logger("celery.app.trace") + + # This is stdout/err redirected to a logger, can't deal with this + # (need event_level=logging.WARN to reproduce) + ignore_logger("celery.redirected") + + +def _set_status(status: str) -> None: + with capture_internal_exceptions(): + scope = sentry_sdk.get_current_scope() + if scope.span is not None: + scope.span.set_status(status) + + +def _capture_exception(task: "Any", exc_info: "ExcInfo") -> None: + client = sentry_sdk.get_client() + if client.get_integration(CeleryIntegration) is None: + return + + if isinstance(exc_info[1], CELERY_CONTROL_FLOW_EXCEPTIONS): + # ??? Doesn't map to anything + _set_status("aborted") + return + + _set_status("internal_error") + + if hasattr(task, "throws") and isinstance(exc_info[1], task.throws): + return + + event, hint = event_from_exception( + exc_info, + client_options=client.options, + mechanism={"type": "celery", "handled": False}, + ) + + sentry_sdk.capture_event(event, hint=hint) + + +def _make_event_processor( + task: "Any", + uuid: "Any", + args: "Any", + kwargs: "Any", + request: "Optional[Any]" = None, +) -> "EventProcessor": + def event_processor(event: "Event", hint: "Hint") -> "Optional[Event]": + with capture_internal_exceptions(): + tags = event.setdefault("tags", {}) + tags["celery_task_id"] = uuid + extra = event.setdefault("extra", {}) + extra["celery-job"] = { + "task_name": task.name, + "args": args, + "kwargs": kwargs, + } + + if "exc_info" in hint: + with capture_internal_exceptions(): + if issubclass(hint["exc_info"][0], SoftTimeLimitExceeded): + event["fingerprint"] = [ + "celery", + "SoftTimeLimitExceeded", + getattr(task, "name", task), + ] + + return event + + return event_processor + + +def _update_celery_task_headers( + original_headers: "dict[str, Any]", span: "Optional[Span]", monitor_beat_tasks: bool +) -> "dict[str, Any]": + """ + Updates the headers of the Celery task with the tracing information + and eventually Sentry Crons monitoring information for beat tasks. + """ + updated_headers = original_headers.copy() + with capture_internal_exceptions(): + # if span is None (when the task was started by Celery Beat) + # this will return the trace headers from the scope. + headers = dict( + sentry_sdk.get_isolation_scope().iter_trace_propagation_headers(span=span) + ) + + if monitor_beat_tasks: + headers.update( + { + "sentry-monitor-start-timestamp-s": "%.9f" + % _now_seconds_since_epoch(), + } + ) + + # Add the time the task was enqueued to the headers + # This is used in the consumer to calculate the latency + updated_headers.update( + {"sentry-task-enqueued-time": _now_seconds_since_epoch()} + ) + + if headers: + existing_baggage = updated_headers.get(BAGGAGE_HEADER_NAME) + sentry_baggage = headers.get(BAGGAGE_HEADER_NAME) + + combined_baggage = sentry_baggage or existing_baggage + if sentry_baggage and existing_baggage: + # Merge incoming and sentry baggage, where the sentry trace information + # in the incoming baggage takes precedence and the third-party items + # are concatenated. + incoming = Baggage.from_incoming_header(existing_baggage) + combined = Baggage.from_incoming_header(sentry_baggage) + combined.sentry_items.update(incoming.sentry_items) + combined.third_party_items = ",".join( + [ + x + for x in [ + combined.third_party_items, + incoming.third_party_items, + ] + if x is not None and x != "" + ] + ) + combined_baggage = combined.serialize(include_third_party=True) + + updated_headers.update(headers) + if combined_baggage: + updated_headers[BAGGAGE_HEADER_NAME] = combined_baggage + + # https://github.com/celery/celery/issues/4875 + # + # Need to setdefault the inner headers too since other + # tracing tools (dd-trace-py) also employ this exact + # workaround and we don't want to break them. + updated_headers.setdefault("headers", {}).update(headers) + if combined_baggage: + updated_headers["headers"][BAGGAGE_HEADER_NAME] = combined_baggage + + # Add the Sentry options potentially added in `sentry_apply_entry` + # to the headers (done when auto-instrumenting Celery Beat tasks) + for key, value in updated_headers.items(): + if key.startswith("sentry-"): + updated_headers["headers"][key] = value + + return updated_headers + + +class NoOpMgr: + def __enter__(self) -> None: + return None + + def __exit__(self, exc_type: "Any", exc_value: "Any", traceback: "Any") -> None: + return None + + +def _wrap_task_run(f: "F") -> "F": + @wraps(f) + def apply_async(*args: "Any", **kwargs: "Any") -> "Any": + # Note: kwargs can contain headers=None, so no setdefault! + # Unsure which backend though. + integration = sentry_sdk.get_client().get_integration(CeleryIntegration) + if integration is None: + return f(*args, **kwargs) + + kwarg_headers = kwargs.get("headers") or {} + propagate_traces = kwarg_headers.pop( + "sentry-propagate-traces", integration.propagate_traces + ) + + if not propagate_traces: + return f(*args, **kwargs) + + if isinstance(args[0], Task): + task_name: str = args[0].name + elif len(args) > 1 and isinstance(args[1], str): + task_name = args[1] + else: + task_name = "" + + task_started_from_beat = sentry_sdk.get_isolation_scope()._name == "celery-beat" + + span_mgr: "Union[Span, NoOpMgr]" = ( + sentry_sdk.start_span( + op=OP.QUEUE_SUBMIT_CELERY, + name=task_name, + origin=CeleryIntegration.origin, + ) + if not task_started_from_beat + else NoOpMgr() + ) + + with span_mgr as span: + kwargs["headers"] = _update_celery_task_headers( + kwarg_headers, span, integration.monitor_beat_tasks + ) + return f(*args, **kwargs) + + return apply_async # type: ignore + + +def _wrap_tracer(task: "Any", f: "F") -> "F": + # Need to wrap tracer for pushing the scope before prerun is sent, and + # popping it after postrun is sent. + # + # This is the reason we don't use signals for hooking in the first place. + # Also because in Celery 3, signal dispatch returns early if one handler + # crashes. + @wraps(f) + @ensure_integration_enabled(CeleryIntegration, f) + def _inner(*args: "Any", **kwargs: "Any") -> "Any": + with isolation_scope() as scope: + scope._name = "celery" + scope.clear_breadcrumbs() + scope.add_event_processor(_make_event_processor(task, *args, **kwargs)) + + transaction = None + + # Celery task objects are not a thing to be trusted. Even + # something such as attribute access can fail. + with capture_internal_exceptions(): + headers = args[3].get("headers") or {} + transaction = continue_trace( + headers, + op=OP.QUEUE_TASK_CELERY, + name="unknown celery task", + source=TransactionSource.TASK, + origin=CeleryIntegration.origin, + ) + transaction.name = task.name + transaction.set_status(SPANSTATUS.OK) + + if transaction is None: + return f(*args, **kwargs) + + with sentry_sdk.start_transaction( + transaction, + custom_sampling_context={ + "celery_job": { + "task": task.name, + # for some reason, args[1] is a list if non-empty but a + # tuple if empty + "args": list(args[1]), + "kwargs": args[2], + } + }, + ): + return f(*args, **kwargs) + + return _inner # type: ignore + + +def _set_messaging_destination_name(task: "Any", span: "Span") -> None: + """Set "messaging.destination.name" tag for span""" + with capture_internal_exceptions(): + delivery_info = task.request.delivery_info + if delivery_info: + routing_key = delivery_info.get("routing_key") + if delivery_info.get("exchange") == "" and routing_key is not None: + # Empty exchange indicates the default exchange, meaning the tasks + # are sent to the queue with the same name as the routing key. + span.set_data(SPANDATA.MESSAGING_DESTINATION_NAME, routing_key) + + +def _wrap_task_call(task: "Any", f: "F") -> "F": + # Need to wrap task call because the exception is caught before we get to + # see it. Also celery's reported stacktrace is untrustworthy. + + # functools.wraps is important here because celery-once looks at this + # method's name. @ensure_integration_enabled internally calls functools.wraps, + # but if we ever remove the @ensure_integration_enabled decorator, we need + # to add @functools.wraps(f) here. + # https://github.com/getsentry/sentry-python/issues/421 + @ensure_integration_enabled(CeleryIntegration, f) + def _inner(*args: "Any", **kwargs: "Any") -> "Any": + try: + with sentry_sdk.start_span( + op=OP.QUEUE_PROCESS, + name=task.name, + origin=CeleryIntegration.origin, + ) as span: + _set_messaging_destination_name(task, span) + + latency = None + with capture_internal_exceptions(): + if ( + task.request.headers is not None + and "sentry-task-enqueued-time" in task.request.headers + ): + latency = _now_seconds_since_epoch() - task.request.headers.pop( + "sentry-task-enqueued-time" + ) + + if latency is not None: + latency *= 1000 # milliseconds + span.set_data(SPANDATA.MESSAGING_MESSAGE_RECEIVE_LATENCY, latency) + + with capture_internal_exceptions(): + span.set_data(SPANDATA.MESSAGING_MESSAGE_ID, task.request.id) + + with capture_internal_exceptions(): + span.set_data( + SPANDATA.MESSAGING_MESSAGE_RETRY_COUNT, task.request.retries + ) + + with capture_internal_exceptions(): + span.set_data( + SPANDATA.MESSAGING_SYSTEM, + task.app.connection().transport.driver_type, + ) + + return f(*args, **kwargs) + except Exception: + exc_info = sys.exc_info() + with capture_internal_exceptions(): + _capture_exception(task, exc_info) + reraise(*exc_info) + + return _inner # type: ignore + + +def _patch_build_tracer() -> None: + import celery.app.trace as trace # type: ignore + + original_build_tracer = trace.build_tracer + + def sentry_build_tracer( + name: "Any", task: "Any", *args: "Any", **kwargs: "Any" + ) -> "Any": + if not getattr(task, "_sentry_is_patched", False): + # determine whether Celery will use __call__ or run and patch + # accordingly + if task_has_custom(task, "__call__"): + type(task).__call__ = _wrap_task_call(task, type(task).__call__) + else: + task.run = _wrap_task_call(task, task.run) + + # `build_tracer` is apparently called for every task + # invocation. Can't wrap every celery task for every invocation + # or we will get infinitely nested wrapper functions. + task._sentry_is_patched = True + + return _wrap_tracer(task, original_build_tracer(name, task, *args, **kwargs)) + + trace.build_tracer = sentry_build_tracer + + +def _patch_task_apply_async() -> None: + Task.apply_async = _wrap_task_run(Task.apply_async) + + +def _patch_celery_send_task() -> None: + from celery import Celery + + Celery.send_task = _wrap_task_run(Celery.send_task) + + +def _patch_worker_exit() -> None: + # Need to flush queue before worker shutdown because a crashing worker will + # call os._exit + from billiard.pool import Worker # type: ignore + + original_workloop = Worker.workloop + + def sentry_workloop(*args: "Any", **kwargs: "Any") -> "Any": + try: + return original_workloop(*args, **kwargs) + finally: + with capture_internal_exceptions(): + if ( + sentry_sdk.get_client().get_integration(CeleryIntegration) + is not None + ): + sentry_sdk.flush() + + Worker.workloop = sentry_workloop + + +def _patch_producer_publish() -> None: + original_publish = Producer.publish + + @ensure_integration_enabled(CeleryIntegration, original_publish) + def sentry_publish(self: "Producer", *args: "Any", **kwargs: "Any") -> "Any": + kwargs_headers = kwargs.get("headers", {}) + if not isinstance(kwargs_headers, Mapping): + # Ensure kwargs_headers is a Mapping, so we can safely call get(). + # We don't expect this to happen, but it's better to be safe. Even + # if it does happen, only our instrumentation breaks. This line + # does not overwrite kwargs["headers"], so the original publish + # method will still work. + kwargs_headers = {} + + task_name = kwargs_headers.get("task") + task_id = kwargs_headers.get("id") + retries = kwargs_headers.get("retries") + + routing_key = kwargs.get("routing_key") + exchange = kwargs.get("exchange") + + with sentry_sdk.start_span( + op=OP.QUEUE_PUBLISH, + name=task_name, + origin=CeleryIntegration.origin, + ) as span: + if task_id is not None: + span.set_data(SPANDATA.MESSAGING_MESSAGE_ID, task_id) + + if exchange == "" and routing_key is not None: + # Empty exchange indicates the default exchange, meaning messages are + # routed to the queue with the same name as the routing key. + span.set_data(SPANDATA.MESSAGING_DESTINATION_NAME, routing_key) + + if retries is not None: + span.set_data(SPANDATA.MESSAGING_MESSAGE_RETRY_COUNT, retries) + + with capture_internal_exceptions(): + span.set_data( + SPANDATA.MESSAGING_SYSTEM, self.connection.transport.driver_type + ) + + return original_publish(self, *args, **kwargs) + + Producer.publish = sentry_publish diff --git a/sentry_sdk/integrations/celery/beat.py b/sentry_sdk/integrations/celery/beat.py new file mode 100644 index 0000000000..a80092ae9c --- /dev/null +++ b/sentry_sdk/integrations/celery/beat.py @@ -0,0 +1,290 @@ +import sentry_sdk +from sentry_sdk.crons import capture_checkin, MonitorStatus +from sentry_sdk.integrations import DidNotEnable +from sentry_sdk.integrations.celery.utils import ( + _get_humanized_interval, + _now_seconds_since_epoch, +) +from sentry_sdk.utils import ( + logger, + match_regex_list, +) + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Callable + from typing import Any, Optional, TypeVar, Union + from sentry_sdk._types import ( + MonitorConfig, + MonitorConfigScheduleType, + MonitorConfigScheduleUnit, + ) + + F = TypeVar("F", bound=Callable[..., Any]) + + +try: + from celery import Task, Celery # type: ignore + from celery.beat import Scheduler # type: ignore + from celery.schedules import crontab, schedule # type: ignore + from celery.signals import ( # type: ignore + task_failure, + task_success, + task_retry, + ) +except ImportError: + raise DidNotEnable("Celery not installed") + +try: + from redbeat.schedulers import RedBeatScheduler # type: ignore +except ImportError: + RedBeatScheduler = None + + +def _get_headers(task: "Task") -> "dict[str, Any]": + headers = task.request.get("headers") or {} + + # flatten nested headers + if "headers" in headers: + headers.update(headers["headers"]) + del headers["headers"] + + headers.update(task.request.get("properties") or {}) + + return headers + + +def _get_monitor_config( + celery_schedule: "Any", app: "Celery", monitor_name: str +) -> "MonitorConfig": + monitor_config: "MonitorConfig" = {} + schedule_type: "Optional[MonitorConfigScheduleType]" = None + schedule_value: "Optional[Union[str, int]]" = None + schedule_unit: "Optional[MonitorConfigScheduleUnit]" = None + + if isinstance(celery_schedule, crontab): + schedule_type = "crontab" + schedule_value = ( + "{0._orig_minute} " + "{0._orig_hour} " + "{0._orig_day_of_month} " + "{0._orig_month_of_year} " + "{0._orig_day_of_week}".format(celery_schedule) + ) + elif isinstance(celery_schedule, schedule): + schedule_type = "interval" + (schedule_value, schedule_unit) = _get_humanized_interval( + celery_schedule.seconds + ) + + if schedule_unit == "second": + logger.warning( + "Intervals shorter than one minute are not supported by Sentry Crons. Monitor '%s' has an interval of %s seconds. Use the `exclude_beat_tasks` option in the celery integration to exclude it.", + monitor_name, + schedule_value, + ) + return {} + + else: + logger.warning( + "Celery schedule type '%s' not supported by Sentry Crons.", + type(celery_schedule), + ) + return {} + + monitor_config["schedule"] = {} + monitor_config["schedule"]["type"] = schedule_type + monitor_config["schedule"]["value"] = schedule_value + + if schedule_unit is not None: + monitor_config["schedule"]["unit"] = schedule_unit + + monitor_config["timezone"] = ( + ( + hasattr(celery_schedule, "tz") + and celery_schedule.tz is not None + and str(celery_schedule.tz) + ) + or app.timezone + or "UTC" + ) + + return monitor_config + + +def _apply_crons_data_to_schedule_entry( + scheduler: "Any", + schedule_entry: "Any", + integration: "sentry_sdk.integrations.celery.CeleryIntegration", +) -> None: + """ + Add Sentry Crons information to the schedule_entry headers. + """ + if not integration.monitor_beat_tasks: + return + + monitor_name = schedule_entry.name + + task_should_be_excluded = match_regex_list( + monitor_name, integration.exclude_beat_tasks + ) + if task_should_be_excluded: + return + + celery_schedule = schedule_entry.schedule + app = scheduler.app + + monitor_config = _get_monitor_config(celery_schedule, app, monitor_name) + + is_supported_schedule = bool(monitor_config) + if not is_supported_schedule: + return + + headers = schedule_entry.options.pop("headers", {}) + headers.update( + { + "sentry-monitor-slug": monitor_name, + "sentry-monitor-config": monitor_config, + } + ) + + check_in_id = capture_checkin( + monitor_slug=monitor_name, + monitor_config=monitor_config, + status=MonitorStatus.IN_PROGRESS, + ) + headers.update({"sentry-monitor-check-in-id": check_in_id}) + + # Set the Sentry configuration in the options of the ScheduleEntry. + # Those will be picked up in `apply_async` and added to the headers. + schedule_entry.options["headers"] = headers + + +def _wrap_beat_scheduler( + original_function: "Callable[..., Any]", +) -> "Callable[..., Any]": + """ + Makes sure that: + - a new Sentry trace is started for each task started by Celery Beat and + it is propagated to the task. + - the Sentry Crons information is set in the Celery Beat task's + headers so that is is monitored with Sentry Crons. + + After the patched function is called, + Celery Beat will call apply_async to put the task in the queue. + """ + # Patch only once + # Can't use __name__ here, because some of our tests mock original_apply_entry + already_patched = "sentry_patched_scheduler" in str(original_function) + if already_patched: + return original_function + + from sentry_sdk.integrations.celery import CeleryIntegration + + def sentry_patched_scheduler(*args: "Any", **kwargs: "Any") -> None: + integration = sentry_sdk.get_client().get_integration(CeleryIntegration) + if integration is None: + return original_function(*args, **kwargs) + + # Tasks started by Celery Beat start a new Trace + scope = sentry_sdk.get_isolation_scope() + scope.set_new_propagation_context() + scope._name = "celery-beat" + + scheduler, schedule_entry = args + _apply_crons_data_to_schedule_entry(scheduler, schedule_entry, integration) + + return original_function(*args, **kwargs) + + return sentry_patched_scheduler + + +def _patch_beat_apply_entry() -> None: + Scheduler.apply_entry = _wrap_beat_scheduler(Scheduler.apply_entry) + + +def _patch_redbeat_apply_async() -> None: + if RedBeatScheduler is None: + return + + RedBeatScheduler.apply_async = _wrap_beat_scheduler(RedBeatScheduler.apply_async) + + +def _setup_celery_beat_signals(monitor_beat_tasks: bool) -> None: + if monitor_beat_tasks: + task_success.connect(crons_task_success) + task_failure.connect(crons_task_failure) + task_retry.connect(crons_task_retry) + + +def crons_task_success(sender: "Task", **kwargs: "dict[Any, Any]") -> None: + logger.debug("celery_task_success %s", sender) + headers = _get_headers(sender) + + if "sentry-monitor-slug" not in headers: + return + + monitor_config = headers.get("sentry-monitor-config", {}) + + start_timestamp_s = headers.get("sentry-monitor-start-timestamp-s") + + capture_checkin( + monitor_slug=headers["sentry-monitor-slug"], + monitor_config=monitor_config, + check_in_id=headers["sentry-monitor-check-in-id"], + duration=( + _now_seconds_since_epoch() - float(start_timestamp_s) + if start_timestamp_s + else None + ), + status=MonitorStatus.OK, + ) + + +def crons_task_failure(sender: "Task", **kwargs: "dict[Any, Any]") -> None: + logger.debug("celery_task_failure %s", sender) + headers = _get_headers(sender) + + if "sentry-monitor-slug" not in headers: + return + + monitor_config = headers.get("sentry-monitor-config", {}) + + start_timestamp_s = headers.get("sentry-monitor-start-timestamp-s") + + capture_checkin( + monitor_slug=headers["sentry-monitor-slug"], + monitor_config=monitor_config, + check_in_id=headers["sentry-monitor-check-in-id"], + duration=( + _now_seconds_since_epoch() - float(start_timestamp_s) + if start_timestamp_s + else None + ), + status=MonitorStatus.ERROR, + ) + + +def crons_task_retry(sender: "Task", **kwargs: "dict[Any, Any]") -> None: + logger.debug("celery_task_retry %s", sender) + headers = _get_headers(sender) + + if "sentry-monitor-slug" not in headers: + return + + monitor_config = headers.get("sentry-monitor-config", {}) + + start_timestamp_s = headers.get("sentry-monitor-start-timestamp-s") + + capture_checkin( + monitor_slug=headers["sentry-monitor-slug"], + monitor_config=monitor_config, + check_in_id=headers["sentry-monitor-check-in-id"], + duration=( + _now_seconds_since_epoch() - float(start_timestamp_s) + if start_timestamp_s + else None + ), + status=MonitorStatus.ERROR, + ) diff --git a/sentry_sdk/integrations/celery/utils.py b/sentry_sdk/integrations/celery/utils.py new file mode 100644 index 0000000000..f9378558c1 --- /dev/null +++ b/sentry_sdk/integrations/celery/utils.py @@ -0,0 +1,39 @@ +import time +from typing import TYPE_CHECKING, cast + +if TYPE_CHECKING: + from typing import Any, Tuple + from sentry_sdk._types import MonitorConfigScheduleUnit + + +def _now_seconds_since_epoch() -> float: + # We cannot use `time.perf_counter()` when dealing with the duration + # of a Celery task, because the start of a Celery task and + # the end are recorded in different processes. + # Start happens in the Celery Beat process, + # the end in a Celery Worker process. + return time.time() + + +def _get_humanized_interval(seconds: float) -> "Tuple[int, MonitorConfigScheduleUnit]": + TIME_UNITS = ( # noqa: N806 + ("day", 60 * 60 * 24.0), + ("hour", 60 * 60.0), + ("minute", 60.0), + ) + + seconds = float(seconds) + for unit, divider in TIME_UNITS: + if seconds >= divider: + interval = int(seconds / divider) + return (interval, cast("MonitorConfigScheduleUnit", unit)) + + return (int(seconds), "second") + + +class NoOpMgr: + def __enter__(self) -> None: + return None + + def __exit__(self, exc_type: "Any", exc_value: "Any", traceback: "Any") -> None: + return None diff --git a/sentry_sdk/integrations/chalice.py b/sentry_sdk/integrations/chalice.py index 25d8b4ac52..89911dc1ab 100644 --- a/sentry_sdk/integrations/chalice.py +++ b/sentry_sdk/integrations/chalice.py @@ -1,21 +1,26 @@ import sys +from functools import wraps -from sentry_sdk._compat import reraise -from sentry_sdk.hub import Hub +import sentry_sdk from sentry_sdk.integrations import Integration, DidNotEnable from sentry_sdk.integrations.aws_lambda import _make_request_event_processor -from sentry_sdk.tracing import TRANSACTION_SOURCE_COMPONENT +from sentry_sdk.tracing import TransactionSource from sentry_sdk.utils import ( capture_internal_exceptions, event_from_exception, parse_version, + reraise, ) -from sentry_sdk._types import TYPE_CHECKING -from sentry_sdk._functools import wraps -import chalice # type: ignore -from chalice import Chalice, ChaliceViewError -from chalice.app import EventSourceHandler as ChaliceEventSourceHandler # type: ignore +try: + import chalice # type: ignore + from chalice import __version__ as CHALICE_VERSION + from chalice import Chalice, ChaliceViewError + from chalice.app import EventSourceHandler as ChaliceEventSourceHandler # type: ignore +except ImportError: + raise DidNotEnable("Chalice is not installed") + +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any @@ -25,19 +30,12 @@ F = TypeVar("F", bound=Callable[..., Any]) -try: - from chalice import __version__ as CHALICE_VERSION -except ImportError: - raise DidNotEnable("Chalice is not installed") - class EventSourceHandler(ChaliceEventSourceHandler): # type: ignore - def __call__(self, event, context): - # type: (Any, Any) -> Any - hub = Hub.current - client = hub.client # type: Any + def __call__(self, event: "Any", context: "Any") -> "Any": + client = sentry_sdk.get_client() - with hub.push_scope() as scope: + with sentry_sdk.isolation_scope() as scope: with capture_internal_exceptions(): configured_time = context.get_remaining_time_in_millis() scope.add_event_processor( @@ -52,24 +50,23 @@ def __call__(self, event, context): client_options=client.options, mechanism={"type": "chalice", "handled": False}, ) - hub.capture_event(event, hint=hint) - hub.flush() + sentry_sdk.capture_event(event, hint=hint) + client.flush() reraise(*exc_info) -def _get_view_function_response(app, view_function, function_args): - # type: (Any, F, Any) -> F +def _get_view_function_response( + app: "Any", view_function: "F", function_args: "Any" +) -> "F": @wraps(view_function) - def wrapped_view_function(**function_args): - # type: (**Any) -> Any - hub = Hub.current - client = hub.client # type: Any - with hub.push_scope() as scope: + def wrapped_view_function(**function_args: "Any") -> "Any": + client = sentry_sdk.get_client() + 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=TRANSACTION_SOURCE_COMPONENT, + source=TransactionSource.COMPONENT, ) scope.add_event_processor( @@ -90,8 +87,8 @@ def wrapped_view_function(**function_args): client_options=client.options, mechanism={"type": "chalice", "handled": False}, ) - hub.capture_event(event, hint=hint) - hub.flush() + sentry_sdk.capture_event(event, hint=hint) + client.flush() raise return wrapped_view_function # type: ignore @@ -101,9 +98,7 @@ class ChaliceIntegration(Integration): identifier = "chalice" @staticmethod - def setup_once(): - # type: () -> None - + def setup_once() -> None: version = parse_version(CHALICE_VERSION) if version is None: @@ -118,8 +113,9 @@ def setup_once(): RestAPIEventHandler._get_view_function_response ) - def sentry_event_response(app, view_function, function_args): - # type: (Any, F, Dict[str, Any]) -> Any + def sentry_event_response( + app: "Any", view_function: "F", function_args: "Dict[str, Any]" + ) -> "Any": wrapped_view_function = _get_view_function_response( app, view_function, function_args ) diff --git a/sentry_sdk/integrations/clickhouse_driver.py b/sentry_sdk/integrations/clickhouse_driver.py index f0955ff756..b4cc2860e7 100644 --- a/sentry_sdk/integrations/clickhouse_driver.py +++ b/sentry_sdk/integrations/clickhouse_driver.py @@ -1,18 +1,18 @@ -from sentry_sdk import Hub +import sentry_sdk from sentry_sdk.consts import OP, SPANDATA -from sentry_sdk.hub import _should_send_default_pii -from sentry_sdk.integrations import Integration, DidNotEnable +from sentry_sdk.integrations import _check_minimum_version, Integration, DidNotEnable from sentry_sdk.tracing import Span -from sentry_sdk._types import TYPE_CHECKING -from sentry_sdk.utils import capture_internal_exceptions +from sentry_sdk.scope import should_send_default_pii +from sentry_sdk.utils import capture_internal_exceptions, ensure_integration_enabled -from typing import TypeVar +from typing import TYPE_CHECKING, TypeVar # Hack to get new Python features working in older versions # without introducing a hard dependency on `typing_extensions` # from: https://stackoverflow.com/a/71944042/300572 if TYPE_CHECKING: - from typing import ParamSpec, Callable + from collections.abc import Iterator + from typing import Any, ParamSpec, Callable else: # Fake ParamSpec class ParamSpec: @@ -35,30 +35,33 @@ def __getitem__(self, _): except ImportError: raise DidNotEnable("clickhouse-driver not installed.") -if clickhouse_driver.VERSION < (0, 2, 0): - raise DidNotEnable("clickhouse-driver >= 0.2.0 required") - class ClickhouseDriverIntegration(Integration): identifier = "clickhouse_driver" + origin = f"auto.db.{identifier}" @staticmethod def setup_once() -> None: + _check_minimum_version(ClickhouseDriverIntegration, clickhouse_driver.VERSION) + # Every query is done using the Connection's `send_query` function clickhouse_driver.connection.Connection.send_query = _wrap_start( clickhouse_driver.connection.Connection.send_query ) # If the query contains parameters then the send_data function is used to send those parameters to clickhouse - clickhouse_driver.client.Client.send_data = _wrap_send_data( - clickhouse_driver.client.Client.send_data - ) + _wrap_send_data() # Every query ends either with the Client's `receive_end_of_query` (no result expected) # or its `receive_result` (result expected) clickhouse_driver.client.Client.receive_end_of_query = _wrap_end( clickhouse_driver.client.Client.receive_end_of_query ) + if hasattr(clickhouse_driver.client.Client, "receive_end_of_insert_query"): + # In 0.2.7, insert queries are handled separately via `receive_end_of_insert_query` + clickhouse_driver.client.Client.receive_end_of_insert_query = _wrap_end( + clickhouse_driver.client.Client.receive_end_of_insert_query + ) clickhouse_driver.client.Client.receive_result = _wrap_end( clickhouse_driver.client.Client.receive_result ) @@ -68,17 +71,19 @@ def setup_once() -> None: T = TypeVar("T") -def _wrap_start(f: Callable[P, T]) -> Callable[P, T]: - def _inner(*args: P.args, **kwargs: P.kwargs) -> T: - hub = Hub.current - if hub.get_integration(ClickhouseDriverIntegration) is None: - return f(*args, **kwargs) +def _wrap_start(f: "Callable[P, T]") -> "Callable[P, T]": + @ensure_integration_enabled(ClickhouseDriverIntegration, f) + def _inner(*args: "P.args", **kwargs: "P.kwargs") -> "T": connection = args[0] query = args[1] query_id = args[2] if len(args) > 2 else kwargs.get("query_id") params = args[3] if len(args) > 3 else kwargs.get("params") - span = hub.start_span(op=OP.DB, description=query) + span = sentry_sdk.start_span( + op=OP.DB, + name=query, + origin=ClickhouseDriverIntegration.origin, + ) connection._sentry_span = span # type: ignore[attr-defined] @@ -89,7 +94,7 @@ def _inner(*args: P.args, **kwargs: P.kwargs) -> T: if query_id: span.set_data("db.query_id", query_id) - if params and _should_send_default_pii(): + if params and should_send_default_pii(): span.set_data("db.params", params) # run the original code @@ -100,18 +105,18 @@ def _inner(*args: P.args, **kwargs: P.kwargs) -> T: return _inner -def _wrap_end(f: Callable[P, T]) -> Callable[P, T]: - def _inner_end(*args: P.args, **kwargs: P.kwargs) -> T: +def _wrap_end(f: "Callable[P, T]") -> "Callable[P, T]": + def _inner_end(*args: "P.args", **kwargs: "P.kwargs") -> "T": res = f(*args, **kwargs) instance = args[0] - span = instance.connection._sentry_span # type: ignore[attr-defined] + span = getattr(instance.connection, "_sentry_span", None) # type: ignore[attr-defined] if span is not None: - if res is not None and _should_send_default_pii(): + if res is not None and should_send_default_pii(): span.set_data("db.result", res) with capture_internal_exceptions(): - span.hub.add_breadcrumb( + span.scope.add_breadcrumb( message=span._data.pop("query"), category="query", data=span._data ) @@ -122,26 +127,48 @@ def _inner_end(*args: P.args, **kwargs: P.kwargs) -> T: return _inner_end -def _wrap_send_data(f: Callable[P, T]) -> Callable[P, T]: - def _inner_send_data(*args: P.args, **kwargs: P.kwargs) -> T: - instance = args[0] # type: clickhouse_driver.client.Client - data = args[2] - span = instance.connection._sentry_span +def _wrap_send_data() -> None: + original_send_data = clickhouse_driver.client.Client.send_data + + def _inner_send_data( # type: ignore[no-untyped-def] # clickhouse-driver does not type send_data + self, sample_block, data, types_check=False, columnar=False, *args, **kwargs + ): + span = getattr(self.connection, "_sentry_span", None) + + if span is not None: + _set_db_data(span, self.connection) + + if should_send_default_pii(): + db_params = span._data.get("db.params", []) + + if isinstance(data, (list, tuple)): + db_params.extend(data) - _set_db_data(span, instance.connection) + else: # data is a generic iterator + orig_data = data - if _should_send_default_pii(): - db_params = span._data.get("db.params", []) - db_params.extend(data) - span.set_data("db.params", db_params) + # Wrap the generator to add items to db.params as they are yielded. + # This allows us to send the params to Sentry without needing to allocate + # memory for the entire generator at once. + def wrapped_generator() -> "Iterator[Any]": + for item in orig_data: + db_params.append(item) + yield item - return f(*args, **kwargs) + # Replace the original iterator with the wrapped one. + data = wrapped_generator() + + span.set_data("db.params", db_params) + + return original_send_data( + self, sample_block, data, types_check, columnar, *args, **kwargs + ) - return _inner_send_data + clickhouse_driver.client.Client.send_data = _inner_send_data def _set_db_data( - span: Span, connection: clickhouse_driver.connection.Connection + span: "Span", connection: "clickhouse_driver.connection.Connection" ) -> None: span.set_data(SPANDATA.DB_SYSTEM, "clickhouse") span.set_data(SPANDATA.SERVER_ADDRESS, connection.host) diff --git a/sentry_sdk/integrations/cloud_resource_context.py b/sentry_sdk/integrations/cloud_resource_context.py index 695bf17d38..09d55ac119 100644 --- a/sentry_sdk/integrations/cloud_resource_context.py +++ b/sentry_sdk/integrations/cloud_resource_context.py @@ -5,7 +5,7 @@ from sentry_sdk.api import set_context from sentry_sdk.utils import logger -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Dict @@ -13,6 +13,8 @@ CONTEXT_TYPE = "cloud_resource" +HTTP_TIMEOUT = 2.0 + AWS_METADATA_HOST = "169.254.169.254" AWS_TOKEN_URL = "http://{}/latest/api/token".format(AWS_METADATA_HOST) AWS_METADATA_URL = "http://{}/latest/dynamic/instance-identity/document".format( @@ -59,17 +61,15 @@ class CloudResourceContextIntegration(Integration): cloud_provider = "" aws_token = "" - http = urllib3.PoolManager() + http = urllib3.PoolManager(timeout=HTTP_TIMEOUT) gcp_metadata = None - def __init__(self, cloud_provider=""): - # type: (str) -> None + def __init__(self, cloud_provider: str = "") -> None: CloudResourceContextIntegration.cloud_provider = cloud_provider @classmethod - def _is_aws(cls): - # type: () -> bool + def _is_aws(cls) -> bool: try: r = cls.http.request( "PUT", @@ -83,12 +83,17 @@ def _is_aws(cls): cls.aws_token = r.data.decode() return True - except Exception: + except urllib3.exceptions.TimeoutError: + logger.debug( + "AWS metadata service timed out after %s seconds", HTTP_TIMEOUT + ) + return False + except Exception as e: + logger.debug("Error checking AWS metadata service: %s", str(e)) return False @classmethod - def _get_aws_context(cls): - # type: () -> Dict[str, str] + def _get_aws_context(cls) -> "Dict[str, str]": ctx = { "cloud.provider": CLOUD_PROVIDER.AWS, "cloud.platform": CLOUD_PLATFORM.AWS_EC2, @@ -131,14 +136,17 @@ def _get_aws_context(cls): except Exception: pass - except Exception: - pass + except urllib3.exceptions.TimeoutError: + logger.debug( + "AWS metadata service timed out after %s seconds", HTTP_TIMEOUT + ) + except Exception as e: + logger.debug("Error fetching AWS metadata: %s", str(e)) return ctx @classmethod - def _is_gcp(cls): - # type: () -> bool + def _is_gcp(cls) -> bool: try: r = cls.http.request( "GET", @@ -152,12 +160,17 @@ def _is_gcp(cls): cls.gcp_metadata = json.loads(r.data.decode("utf-8")) return True - except Exception: + except urllib3.exceptions.TimeoutError: + logger.debug( + "GCP metadata service timed out after %s seconds", HTTP_TIMEOUT + ) + return False + except Exception as e: + logger.debug("Error checking GCP metadata service: %s", str(e)) return False @classmethod - def _get_gcp_context(cls): - # type: () -> Dict[str, str] + def _get_gcp_context(cls) -> "Dict[str, str]": ctx = { "cloud.provider": CLOUD_PROVIDER.GCP, "cloud.platform": CLOUD_PLATFORM.GCP_COMPUTE_ENGINE, @@ -201,14 +214,17 @@ def _get_gcp_context(cls): except Exception: pass - except Exception: - pass + except urllib3.exceptions.TimeoutError: + logger.debug( + "GCP metadata service timed out after %s seconds", HTTP_TIMEOUT + ) + except Exception as e: + logger.debug("Error fetching GCP metadata: %s", str(e)) return ctx @classmethod - def _get_cloud_provider(cls): - # type: () -> str + def _get_cloud_provider(cls) -> str: if cls._is_aws(): return CLOUD_PROVIDER.AWS @@ -218,8 +234,7 @@ def _get_cloud_provider(cls): return "" @classmethod - def _get_cloud_resource_context(cls): - # type: () -> Dict[str, str] + def _get_cloud_resource_context(cls) -> "Dict[str, str]": cloud_provider = ( cls.cloud_provider if cls.cloud_provider != "" @@ -231,8 +246,7 @@ def _get_cloud_resource_context(cls): return {} @staticmethod - def setup_once(): - # type: () -> None + def setup_once() -> None: cloud_provider = CloudResourceContextIntegration.cloud_provider unsupported_cloud_provider = ( cloud_provider != "" and cloud_provider not in context_getters.keys() diff --git a/sentry_sdk/integrations/cohere.py b/sentry_sdk/integrations/cohere.py new file mode 100644 index 0000000000..bac2ce5655 --- /dev/null +++ b/sentry_sdk/integrations/cohere.py @@ -0,0 +1,264 @@ +from functools import wraps + +from sentry_sdk import consts +from sentry_sdk.ai.monitoring import record_token_usage +from sentry_sdk.consts import SPANDATA +from sentry_sdk.ai.utils import set_data_normalized + +from typing import TYPE_CHECKING + +from sentry_sdk.tracing_utils import set_span_errored + +if TYPE_CHECKING: + from typing import Any, Callable, Iterator + from sentry_sdk.tracing import Span + +import sentry_sdk +from sentry_sdk.scope import should_send_default_pii +from sentry_sdk.integrations import DidNotEnable, Integration +from sentry_sdk.utils import capture_internal_exceptions, event_from_exception + +try: + from cohere.client import Client + from cohere.base_client import BaseCohere + from cohere import ( + ChatStreamEndEvent, + NonStreamedChatResponse, + ) + + if TYPE_CHECKING: + from cohere import StreamedChatResponse +except ImportError: + raise DidNotEnable("Cohere not installed") + +try: + # cohere 5.9.3+ + from cohere import StreamEndStreamedChatResponse +except ImportError: + from cohere import StreamedChatResponse_StreamEnd as StreamEndStreamedChatResponse + + +COLLECTED_CHAT_PARAMS = { + "model": SPANDATA.AI_MODEL_ID, + "k": SPANDATA.AI_TOP_K, + "p": SPANDATA.AI_TOP_P, + "seed": SPANDATA.AI_SEED, + "frequency_penalty": SPANDATA.AI_FREQUENCY_PENALTY, + "presence_penalty": SPANDATA.AI_PRESENCE_PENALTY, + "raw_prompting": SPANDATA.AI_RAW_PROMPTING, +} + +COLLECTED_PII_CHAT_PARAMS = { + "tools": SPANDATA.AI_TOOLS, + "preamble": SPANDATA.AI_PREAMBLE, +} + +COLLECTED_CHAT_RESP_ATTRS = { + "generation_id": SPANDATA.AI_GENERATION_ID, + "is_search_required": SPANDATA.AI_SEARCH_REQUIRED, + "finish_reason": SPANDATA.AI_FINISH_REASON, +} + +COLLECTED_PII_CHAT_RESP_ATTRS = { + "citations": SPANDATA.AI_CITATIONS, + "documents": SPANDATA.AI_DOCUMENTS, + "search_queries": SPANDATA.AI_SEARCH_QUERIES, + "search_results": SPANDATA.AI_SEARCH_RESULTS, + "tool_calls": SPANDATA.AI_TOOL_CALLS, +} + + +class CohereIntegration(Integration): + identifier = "cohere" + origin = f"auto.ai.{identifier}" + + def __init__(self: "CohereIntegration", include_prompts: bool = True) -> None: + self.include_prompts = include_prompts + + @staticmethod + def setup_once() -> None: + BaseCohere.chat = _wrap_chat(BaseCohere.chat, streaming=False) + Client.embed = _wrap_embed(Client.embed) + BaseCohere.chat_stream = _wrap_chat(BaseCohere.chat_stream, streaming=True) + + +def _capture_exception(exc: "Any") -> None: + set_span_errored() + + event, hint = event_from_exception( + exc, + client_options=sentry_sdk.get_client().options, + mechanism={"type": "cohere", "handled": False}, + ) + sentry_sdk.capture_event(event, hint=hint) + + +def _wrap_chat(f: "Callable[..., Any]", streaming: bool) -> "Callable[..., Any]": + def collect_chat_response_fields( + span: "Span", res: "NonStreamedChatResponse", include_pii: bool + ) -> None: + if include_pii: + if hasattr(res, "text"): + set_data_normalized( + span, + SPANDATA.AI_RESPONSES, + [res.text], + ) + for pii_attr in COLLECTED_PII_CHAT_RESP_ATTRS: + if hasattr(res, pii_attr): + set_data_normalized(span, "ai." + pii_attr, getattr(res, pii_attr)) + + for attr in COLLECTED_CHAT_RESP_ATTRS: + if hasattr(res, attr): + set_data_normalized(span, "ai." + attr, getattr(res, attr)) + + if hasattr(res, "meta"): + if hasattr(res.meta, "billed_units"): + record_token_usage( + span, + input_tokens=res.meta.billed_units.input_tokens, + output_tokens=res.meta.billed_units.output_tokens, + ) + elif hasattr(res.meta, "tokens"): + record_token_usage( + span, + input_tokens=res.meta.tokens.input_tokens, + output_tokens=res.meta.tokens.output_tokens, + ) + + if hasattr(res.meta, "warnings"): + set_data_normalized(span, SPANDATA.AI_WARNINGS, res.meta.warnings) + + @wraps(f) + def new_chat(*args: "Any", **kwargs: "Any") -> "Any": + integration = sentry_sdk.get_client().get_integration(CohereIntegration) + + if ( + integration is None + or "message" not in kwargs + or not isinstance(kwargs.get("message"), str) + ): + return f(*args, **kwargs) + + message = kwargs.get("message") + + span = sentry_sdk.start_span( + op=consts.OP.COHERE_CHAT_COMPLETIONS_CREATE, + name="cohere.client.Chat", + origin=CohereIntegration.origin, + ) + span.__enter__() + try: + res = f(*args, **kwargs) + except Exception as e: + _capture_exception(e) + span.__exit__(None, None, None) + raise e from None + + with capture_internal_exceptions(): + if should_send_default_pii() and integration.include_prompts: + set_data_normalized( + span, + SPANDATA.AI_INPUT_MESSAGES, + list( + map( + lambda x: { + "role": getattr(x, "role", "").lower(), + "content": getattr(x, "message", ""), + }, + kwargs.get("chat_history", []), + ) + ) + + [{"role": "user", "content": message}], + ) + for k, v in COLLECTED_PII_CHAT_PARAMS.items(): + if k in kwargs: + set_data_normalized(span, v, kwargs[k]) + + for k, v in COLLECTED_CHAT_PARAMS.items(): + if k in kwargs: + set_data_normalized(span, v, kwargs[k]) + set_data_normalized(span, SPANDATA.AI_STREAMING, False) + + if streaming: + old_iterator = res + + def new_iterator() -> "Iterator[StreamedChatResponse]": + with capture_internal_exceptions(): + for x in old_iterator: + if isinstance(x, ChatStreamEndEvent) or isinstance( + x, StreamEndStreamedChatResponse + ): + collect_chat_response_fields( + span, + x.response, + include_pii=should_send_default_pii() + and integration.include_prompts, + ) + yield x + + span.__exit__(None, None, None) + + return new_iterator() + elif isinstance(res, NonStreamedChatResponse): + collect_chat_response_fields( + span, + res, + include_pii=should_send_default_pii() + and integration.include_prompts, + ) + span.__exit__(None, None, None) + else: + set_data_normalized(span, "unknown_response", True) + span.__exit__(None, None, None) + return res + + return new_chat + + +def _wrap_embed(f: "Callable[..., Any]") -> "Callable[..., Any]": + @wraps(f) + def new_embed(*args: "Any", **kwargs: "Any") -> "Any": + integration = sentry_sdk.get_client().get_integration(CohereIntegration) + if integration is None: + return f(*args, **kwargs) + + with sentry_sdk.start_span( + op=consts.OP.COHERE_EMBEDDINGS_CREATE, + name="Cohere Embedding Creation", + origin=CohereIntegration.origin, + ) as span: + if "texts" in kwargs and ( + should_send_default_pii() and integration.include_prompts + ): + if isinstance(kwargs["texts"], str): + set_data_normalized(span, SPANDATA.AI_TEXTS, [kwargs["texts"]]) + elif ( + isinstance(kwargs["texts"], list) + and len(kwargs["texts"]) > 0 + and isinstance(kwargs["texts"][0], str) + ): + set_data_normalized( + span, SPANDATA.AI_INPUT_MESSAGES, kwargs["texts"] + ) + + if "model" in kwargs: + set_data_normalized(span, SPANDATA.AI_MODEL_ID, kwargs["model"]) + try: + res = f(*args, **kwargs) + except Exception as e: + _capture_exception(e) + raise e from None + if ( + hasattr(res, "meta") + and hasattr(res.meta, "billed_units") + and hasattr(res.meta.billed_units, "input_tokens") + ): + record_token_usage( + span, + input_tokens=res.meta.billed_units.input_tokens, + total_tokens=res.meta.billed_units.input_tokens, + ) + return res + + return new_embed diff --git a/sentry_sdk/integrations/dedupe.py b/sentry_sdk/integrations/dedupe.py index 04208f608a..09e60e4be6 100644 --- a/sentry_sdk/integrations/dedupe.py +++ b/sentry_sdk/integrations/dedupe.py @@ -1,9 +1,11 @@ -from sentry_sdk.hub import Hub -from sentry_sdk.utils import ContextVar +import weakref + +import sentry_sdk +from sentry_sdk.utils import ContextVar, logger from sentry_sdk.integrations import Integration from sentry_sdk.scope import add_global_event_processor -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Optional @@ -14,21 +16,17 @@ class DedupeIntegration(Integration): identifier = "dedupe" - def __init__(self): - # type: () -> None + def __init__(self) -> None: self._last_seen = ContextVar("last-seen") @staticmethod - def setup_once(): - # type: () -> None + def setup_once() -> None: @add_global_event_processor - def processor(event, hint): - # type: (Event, Optional[Hint]) -> Optional[Event] + def processor(event: "Event", hint: "Optional[Hint]") -> "Optional[Event]": if hint is None: return event - integration = Hub.current.get_integration(DedupeIntegration) - + integration = sentry_sdk.get_client().get_integration(DedupeIntegration) if integration is None: return event @@ -36,8 +34,30 @@ def processor(event, hint): if exc_info is None: return event + last_seen = integration._last_seen.get(None) + if last_seen is not None: + # last_seen is either a weakref or the original instance + last_seen = ( + last_seen() if isinstance(last_seen, weakref.ref) else last_seen + ) + exc = exc_info[1] - if integration._last_seen.get(None) is exc: + if last_seen is exc: + logger.info("DedupeIntegration dropped duplicated error event %s", exc) return None - integration._last_seen.set(exc) + + # we can only weakref non builtin types + try: + integration._last_seen.set(weakref.ref(exc)) + except TypeError: + integration._last_seen.set(exc) + return event + + @staticmethod + def reset_last_seen() -> None: + integration = sentry_sdk.get_client().get_integration(DedupeIntegration) + if integration is None: + return + + integration._last_seen.set(None) diff --git a/sentry_sdk/integrations/django/__init__.py b/sentry_sdk/integrations/django/__init__.py index 03d0545b1d..0cc4bc4d16 100644 --- a/sentry_sdk/integrations/django/__init__.py +++ b/sentry_sdk/integrations/django/__init__.py @@ -1,20 +1,15 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import - +import inspect import sys import threading import weakref from importlib import import_module -from sentry_sdk._compat import string_types, text_type -from sentry_sdk._types import TYPE_CHECKING -from sentry_sdk.consts import OP, SPANDATA -from sentry_sdk.db.explain_plan.django import attach_explain_plan_to_span -from sentry_sdk.hub import Hub, _should_send_default_pii -from sentry_sdk.scope import add_global_event_processor -from sentry_sdk.serializer import add_global_repr_processor -from sentry_sdk.tracing import SOURCE_FOR_STYLE, TRANSACTION_SOURCE_URL -from sentry_sdk.tracing_utils import record_sql_queries +import sentry_sdk +from sentry_sdk.consts import OP, SPANDATA, SPANNAME +from sentry_sdk.scope import add_global_event_processor, should_send_default_pii +from sentry_sdk.serializer import add_global_repr_processor, add_repr_sequence_type +from sentry_sdk.tracing import SOURCE_FOR_STYLE, TransactionSource +from sentry_sdk.tracing_utils import add_query_source, record_sql_queries from sentry_sdk.utils import ( AnnotatedValue, HAS_REAL_CONTEXTVARS, @@ -22,14 +17,18 @@ SENSITIVE_DATA_SUBSTITUTE, logger, capture_internal_exceptions, + ensure_integration_enabled, event_from_exception, transaction_from_function, walk_exception_chain, ) -from sentry_sdk.integrations import Integration, DidNotEnable +from sentry_sdk.integrations import _check_minimum_version, Integration, DidNotEnable from sentry_sdk.integrations.logging import ignore_logger from sentry_sdk.integrations.wsgi import SentryWsgiMiddleware -from sentry_sdk.integrations._wsgi_common import RequestExtractor +from sentry_sdk.integrations._wsgi_common import ( + DEFAULT_HTTP_METHODS_TO_CAPTURE, + RequestExtractor, +) try: from django import VERSION as DJANGO_VERSION @@ -46,6 +45,13 @@ from django.urls import Resolver404 except ImportError: from django.core.urlresolvers import Resolver404 + + # Only available in Django 3.0+ + try: + from django.core.handlers.asgi import ASGIRequest + except Exception: + ASGIRequest = None + except ImportError: raise DidNotEnable("Django not installed") @@ -56,6 +62,7 @@ ) from sentry_sdk.integrations.django.middleware import patch_django_middlewares from sentry_sdk.integrations.django.signals_handlers import patch_signals +from sentry_sdk.integrations.django.tasks import patch_tasks from sentry_sdk.integrations.django.views import patch_views if DJANGO_VERSION[:2] > (1, 8): @@ -63,6 +70,7 @@ else: patch_caching = None # type: ignore +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any @@ -78,21 +86,18 @@ from django.utils.datastructures import MultiValueDict from sentry_sdk.tracing import Span - from sentry_sdk.scope import Scope from sentry_sdk.integrations.wsgi import _ScopedResponse from sentry_sdk._types import Event, Hint, EventProcessor, NotImplementedType if DJANGO_VERSION < (1, 10): - def is_authenticated(request_user): - # type: (Any) -> bool + def is_authenticated(request_user: "Any") -> bool: return request_user.is_authenticated() else: - def is_authenticated(request_user): - # type: (Any) -> bool + def is_authenticated(request_user: "Any") -> bool: return request_user.is_authenticated @@ -100,21 +105,36 @@ def is_authenticated(request_user): class DjangoIntegration(Integration): + """ + Auto instrument a Django application. + + :param transaction_style: How to derive transaction names. Either `"function_name"` or `"url"`. Defaults to `"url"`. + :param middleware_spans: Whether to create spans for middleware. Defaults to `True`. + :param signals_spans: Whether to create spans for signals. Defaults to `True`. + :param signals_denylist: A list of signals to ignore when creating spans. + :param cache_spans: Whether to create spans for cache operations. Defaults to `False`. + """ + identifier = "django" + origin = f"auto.http.{identifier}" + origin_db = f"auto.db.{identifier}" transaction_style = "" middleware_spans = None signals_spans = None cache_spans = None + signals_denylist: "list[signals.Signal]" = [] def __init__( self, - transaction_style="url", - middleware_spans=True, - signals_spans=True, - cache_spans=False, - ): - # type: (str, bool, bool, bool) -> None + transaction_style: str = "url", + middleware_spans: bool = False, + signals_spans: bool = True, + cache_spans: bool = False, + db_transaction_spans: bool = False, + signals_denylist: "Optional[list[signals.Signal]]" = None, + http_methods_to_capture: "tuple[str, ...]" = DEFAULT_HTTP_METHODS_TO_CAPTURE, + ) -> None: if transaction_style not in TRANSACTION_STYLE_VALUES: raise ValueError( "Invalid value for transaction_style: %s (must be in %s)" @@ -122,15 +142,18 @@ def __init__( ) self.transaction_style = transaction_style self.middleware_spans = middleware_spans + self.signals_spans = signals_spans + self.signals_denylist = signals_denylist or [] + self.cache_spans = cache_spans + self.db_transaction_spans = db_transaction_spans - @staticmethod - def setup_once(): - # type: () -> None + self.http_methods_to_capture = tuple(map(str.upper, http_methods_to_capture)) - if DJANGO_VERSION < (1, 8): - raise DidNotEnable("Django 1.8 or newer is required.") + @staticmethod + def setup_once() -> None: + _check_minimum_version(DjangoIntegration, DJANGO_VERSION) install_sql_hook() # Patch in our custom middleware. @@ -143,20 +166,29 @@ def setup_once(): old_app = WSGIHandler.__call__ - def sentry_patched_wsgi_handler(self, environ, start_response): - # type: (Any, Dict[str, str], Callable[..., Any]) -> _ScopedResponse - if Hub.current.get_integration(DjangoIntegration) is None: - return old_app(self, environ, start_response) - + @ensure_integration_enabled(DjangoIntegration, old_app) + def sentry_patched_wsgi_handler( + self: "Any", environ: "Dict[str, str]", start_response: "Callable[..., Any]" + ) -> "_ScopedResponse": bound_old_app = old_app.__get__(self, WSGIHandler) from django.conf import settings use_x_forwarded_for = settings.USE_X_FORWARDED_HOST - return SentryWsgiMiddleware(bound_old_app, use_x_forwarded_for)( - environ, start_response + integration = sentry_sdk.get_client().get_integration(DjangoIntegration) + + middleware = SentryWsgiMiddleware( + bound_old_app, + use_x_forwarded_for, + span_origin=DjangoIntegration.origin, + http_methods_to_capture=( + integration.http_methods_to_capture + if integration + else DEFAULT_HTTP_METHODS_TO_CAPTURE + ), ) + return middleware(environ, start_response) WSGIHandler.__call__ = sentry_patched_wsgi_handler @@ -167,8 +199,9 @@ def sentry_patched_wsgi_handler(self, environ, start_response): signals.got_request_exception.connect(_got_request_exception) @add_global_event_processor - def process_django_templates(event, hint): - # type: (Event, Optional[Hint]) -> Optional[Event] + def process_django_templates( + event: "Event", hint: "Optional[Hint]" + ) -> "Optional[Event]": if hint is None: return event @@ -210,8 +243,9 @@ def process_django_templates(event, hint): return event @add_global_repr_processor - def _django_queryset_repr(value, hint): - # type: (Any, Dict[str, Any]) -> Union[NotImplementedType, str] + def _django_queryset_repr( + value: "Any", hint: "Dict[str, Any]" + ) -> "Union[NotImplementedType, str]": try: # Django 1.6 can fail to import `QuerySet` when Django settings # have not yet been initialized. @@ -226,11 +260,6 @@ def _django_queryset_repr(value, hint): if not isinstance(value, QuerySet) or value._result_cache: return NotImplemented - # Do not call Hub.get_integration here. It is intentional that - # running under a new hub does not suddenly start executing - # querysets. This might be surprising to the user but it's likely - # less annoying. - return "<%s from %s at 0x%x>" % ( value.__class__.__name__, value.__module__, @@ -242,6 +271,8 @@ def _django_queryset_repr(value, hint): patch_views() patch_templates() patch_signals() + patch_tasks() + add_template_context_repr_sequence() if patch_caching is not None: patch_caching() @@ -251,8 +282,7 @@ def _django_queryset_repr(value, hint): _DRF_PATCH_LOCK = threading.Lock() -def _patch_drf(): - # type: () -> None +def _patch_drf() -> None: """ Patch Django Rest Framework for more/better request data. DRF's request type is a wrapper around Django's request type. The attribute we're @@ -294,8 +324,9 @@ def _patch_drf(): else: old_drf_initial = APIView.initial - def sentry_patched_drf_initial(self, request, *args, **kwargs): - # type: (APIView, Any, *Any, **Any) -> Any + def sentry_patched_drf_initial( + self: "APIView", request: "Any", *args: "Any", **kwargs: "Any" + ) -> "Any": with capture_internal_exceptions(): request._request._sentry_drf_request_backref = weakref.ref( request @@ -306,8 +337,7 @@ def sentry_patched_drf_initial(self, request, *args, **kwargs): APIView.initial = sentry_patched_drf_initial -def _patch_channels(): - # type: () -> None +def _patch_channels() -> None: try: from channels.http import AsgiHandler # type: ignore except ImportError: @@ -331,8 +361,7 @@ def _patch_channels(): patch_channels_asgi_handler_impl(AsgiHandler) -def _patch_django_asgi_handler(): - # type: () -> None +def _patch_django_asgi_handler() -> None: try: from django.core.handlers.asgi import ASGIHandler except ImportError: @@ -353,8 +382,9 @@ def _patch_django_asgi_handler(): patch_django_asgi_handler_impl(ASGIHandler) -def _set_transaction_name_and_source(scope, transaction_style, request): - # type: (Scope, str, WSGIRequest) -> None +def _set_transaction_name_and_source( + scope: "sentry_sdk.Scope", transaction_style: str, request: "WSGIRequest" +) -> None: try: transaction_name = None if transaction_style == "function_name": @@ -371,7 +401,7 @@ def _set_transaction_name_and_source(scope, transaction_style, request): if transaction_name is None: transaction_name = request.path_info - source = TRANSACTION_SOURCE_URL + source = TransactionSource.URL else: source = SOURCE_FOR_STYLE[transaction_style] @@ -385,7 +415,7 @@ def _set_transaction_name_and_source(scope, transaction_style, request): # So we don't check here what style is configured if hasattr(urlconf, "handler404"): handler = urlconf.handler404 - if isinstance(handler, string_types): + if isinstance(handler, str): scope.transaction = handler else: scope.transaction = transaction_from_function( @@ -395,26 +425,25 @@ def _set_transaction_name_and_source(scope, transaction_style, request): pass -def _before_get_response(request): - # type: (WSGIRequest) -> None - hub = Hub.current - integration = hub.get_integration(DjangoIntegration) +def _before_get_response(request: "WSGIRequest") -> None: + integration = sentry_sdk.get_client().get_integration(DjangoIntegration) if integration is None: return _patch_drf() - with hub.configure_scope() as scope: - # Rely on WSGI middleware to start a trace - _set_transaction_name_and_source(scope, integration.transaction_style, request) + scope = sentry_sdk.get_current_scope() + # Rely on WSGI middleware to start a trace + _set_transaction_name_and_source(scope, integration.transaction_style, request) - scope.add_event_processor( - _make_event_processor(weakref.ref(request), integration) - ) + scope.add_event_processor( + _make_wsgi_request_event_processor(weakref.ref(request), integration) + ) -def _attempt_resolve_again(request, scope, transaction_style): - # type: (WSGIRequest, Scope, str) -> None +def _attempt_resolve_again( + request: "WSGIRequest", scope: "sentry_sdk.Scope", transaction_style: str +) -> None: """ Some django middlewares overwrite request.urlconf so we need to respect that contract, @@ -426,19 +455,16 @@ def _attempt_resolve_again(request, scope, transaction_style): _set_transaction_name_and_source(scope, transaction_style, request) -def _after_get_response(request): - # type: (WSGIRequest) -> None - hub = Hub.current - integration = hub.get_integration(DjangoIntegration) +def _after_get_response(request: "WSGIRequest") -> None: + integration = sentry_sdk.get_client().get_integration(DjangoIntegration) if integration is None or integration.transaction_style != "url": return - with hub.configure_scope() as scope: - _attempt_resolve_again(request, scope, integration.transaction_style) + scope = sentry_sdk.get_current_scope() + _attempt_resolve_again(request, scope, integration.transaction_style) -def _patch_get_response(): - # type: () -> None +def _patch_get_response() -> None: """ patch get_response, because at that point we have the Django request object """ @@ -446,8 +472,9 @@ def _patch_get_response(): old_get_response = BaseHandler.get_response - def sentry_patched_get_response(self, request): - # type: (Any, WSGIRequest) -> Union[HttpResponse, BaseException] + def sentry_patched_get_response( + self: "Any", request: "WSGIRequest" + ) -> "Union[HttpResponse, BaseException]": _before_get_response(request) rv = old_get_response(self, request) _after_get_response(request) @@ -461,10 +488,10 @@ def sentry_patched_get_response(self, request): patch_get_response_async(BaseHandler, _before_get_response) -def _make_event_processor(weak_request, integration): - # type: (Callable[[], WSGIRequest], DjangoIntegration) -> EventProcessor - def event_processor(event, hint): - # type: (Dict[str, Any], Dict[str, Any]) -> Dict[str, Any] +def _make_wsgi_request_event_processor( + weak_request: "Callable[[], WSGIRequest]", integration: "DjangoIntegration" +) -> "EventProcessor": + def wsgi_request_event_processor(event: "Event", hint: "dict[str, Any]") -> "Event": # if the request is gone we are fine not logging the data from # it. This might happen if the processor is pushed away to # another thread. @@ -472,58 +499,61 @@ def event_processor(event, hint): if request is None: return event - try: - drf_request = request._sentry_drf_request_backref() - if drf_request is not None: - request = drf_request - except AttributeError: - pass + django_3 = ASGIRequest is not None + if django_3 and type(request) == ASGIRequest: + # We have a `asgi_request_event_processor` for this. + return event with capture_internal_exceptions(): DjangoRequestExtractor(request).extract_into_event(event) - if _should_send_default_pii(): + if should_send_default_pii(): with capture_internal_exceptions(): _set_user_info(request, event) return event - return event_processor + return wsgi_request_event_processor -def _got_request_exception(request=None, **kwargs): - # type: (WSGIRequest, **Any) -> None - hub = Hub.current - integration = hub.get_integration(DjangoIntegration) - if integration is not None: - if request is not None and integration.transaction_style == "url": - with hub.configure_scope() as scope: - _attempt_resolve_again(request, scope, integration.transaction_style) +def _got_request_exception(request: "WSGIRequest" = None, **kwargs: "Any") -> None: + client = sentry_sdk.get_client() + integration = client.get_integration(DjangoIntegration) + if integration is None: + return - # If an integration is there, a client has to be there. - client = hub.client # type: Any + if request is not None and integration.transaction_style == "url": + scope = sentry_sdk.get_current_scope() + _attempt_resolve_again(request, scope, integration.transaction_style) - event, hint = event_from_exception( - sys.exc_info(), - client_options=client.options, - mechanism={"type": "django", "handled": False}, - ) - hub.capture_event(event, hint=hint) + event, hint = event_from_exception( + sys.exc_info(), + client_options=client.options, + mechanism={"type": "django", "handled": False}, + ) + sentry_sdk.capture_event(event, hint=hint) class DjangoRequestExtractor(RequestExtractor): - def env(self): - # type: () -> Dict[str, str] + def __init__(self, request: "Union[WSGIRequest, ASGIRequest]") -> None: + try: + drf_request = request._sentry_drf_request_backref() + if drf_request is not None: + request = drf_request + except AttributeError: + pass + self.request = request + + def env(self) -> "Dict[str, str]": return self.request.META - def cookies(self): - # type: () -> Dict[str, Union[str, AnnotatedValue]] + def cookies(self) -> "Dict[str, Union[str, AnnotatedValue]]": privacy_cookies = [ django_settings.CSRF_COOKIE_NAME, django_settings.SESSION_COOKIE_NAME, ] - clean_cookies = {} # type: Dict[str, Union[str, AnnotatedValue]] + clean_cookies: "Dict[str, Union[str, AnnotatedValue]]" = {} for key, val in self.request.COOKIES.items(): if key in privacy_cookies: clean_cookies[key] = SENSITIVE_DATA_SUBSTITUTE @@ -532,32 +562,26 @@ def cookies(self): return clean_cookies - def raw_data(self): - # type: () -> bytes + def raw_data(self) -> bytes: return self.request.body - def form(self): - # type: () -> QueryDict + def form(self) -> "QueryDict": return self.request.POST - def files(self): - # type: () -> MultiValueDict + def files(self) -> "MultiValueDict": return self.request.FILES - def size_of_file(self, file): - # type: (Any) -> int + def size_of_file(self, file: "Any") -> int: return file.size - def parsed_body(self): - # type: () -> Optional[Dict[str, Any]] + def parsed_body(self) -> "Optional[Dict[str, Any]]": try: return self.request.data - except AttributeError: + except Exception: return RequestExtractor.parsed_body(self) -def _set_user_info(request, event): - # type: (WSGIRequest, Dict[str, Any]) -> None +def _set_user_info(request: "WSGIRequest", event: "Event") -> None: user_info = event.setdefault("user", {}) user = getattr(request, "user", None) @@ -581,8 +605,7 @@ def _set_user_info(request, event): pass -def install_sql_hook(): - # type: () -> None +def install_sql_hook() -> None: """If installed this causes Django's queries to be captured.""" try: from django.db.backends.utils import CursorWrapper @@ -600,77 +623,141 @@ def install_sql_hook(): real_execute = CursorWrapper.execute real_executemany = CursorWrapper.executemany real_connect = BaseDatabaseWrapper.connect + real_commit = BaseDatabaseWrapper._commit + real_rollback = BaseDatabaseWrapper._rollback except AttributeError: # This won't work on Django versions < 1.6 return - def execute(self, sql, params=None): - # type: (CursorWrapper, Any, Optional[Any]) -> Any - hub = Hub.current - if hub.get_integration(DjangoIntegration) is None: - return real_execute(self, sql, params) - + @ensure_integration_enabled(DjangoIntegration, real_execute) + def execute( + self: "CursorWrapper", sql: "Any", params: "Optional[Any]" = None + ) -> "Any": with record_sql_queries( - hub, self.cursor, sql, params, paramstyle="format", executemany=False + cursor=self.cursor, + query=sql, + params_list=params, + paramstyle="format", + executemany=False, + span_origin=DjangoIntegration.origin_db, ) as span: _set_db_data(span, self) - if hub.client: - options = hub.client.options["_experiments"].get("attach_explain_plans") - if options is not None: - attach_explain_plan_to_span( - span, - self.cursor.connection, - sql, - params, - self.mogrify, - options, - ) - return real_execute(self, sql, params) - - def executemany(self, sql, param_list): - # type: (CursorWrapper, Any, List[Any]) -> Any - hub = Hub.current - if hub.get_integration(DjangoIntegration) is None: - return real_executemany(self, sql, param_list) + result = real_execute(self, sql, params) + + with capture_internal_exceptions(): + add_query_source(span) + return result + + @ensure_integration_enabled(DjangoIntegration, real_executemany) + def executemany( + self: "CursorWrapper", sql: "Any", param_list: "List[Any]" + ) -> "Any": with record_sql_queries( - hub, self.cursor, sql, param_list, paramstyle="format", executemany=True + cursor=self.cursor, + query=sql, + params_list=param_list, + paramstyle="format", + executemany=True, + span_origin=DjangoIntegration.origin_db, ) as span: _set_db_data(span, self) - return real_executemany(self, sql, param_list) - def connect(self): - # type: (BaseDatabaseWrapper) -> None - hub = Hub.current - if hub.get_integration(DjangoIntegration) is None: - return real_connect(self) + result = real_executemany(self, sql, param_list) + + with capture_internal_exceptions(): + add_query_source(span) + return result + + @ensure_integration_enabled(DjangoIntegration, real_connect) + def connect(self: "BaseDatabaseWrapper") -> None: with capture_internal_exceptions(): - hub.add_breadcrumb(message="connect", category="query") + sentry_sdk.add_breadcrumb(message="connect", category="query") - with hub.start_span(op=OP.DB, description="connect") as span: + with sentry_sdk.start_span( + op=OP.DB, + name="connect", + origin=DjangoIntegration.origin_db, + ) as span: _set_db_data(span, self) return real_connect(self) + def _commit(self: "BaseDatabaseWrapper") -> None: + integration = sentry_sdk.get_client().get_integration(DjangoIntegration) + + if integration is None or not integration.db_transaction_spans: + return real_commit(self) + + with sentry_sdk.start_span( + op=OP.DB, + name=SPANNAME.DB_COMMIT, + origin=DjangoIntegration.origin_db, + ) as span: + _set_db_data(span, self, SPANNAME.DB_COMMIT) + return real_commit(self) + + def _rollback(self: "BaseDatabaseWrapper") -> None: + integration = sentry_sdk.get_client().get_integration(DjangoIntegration) + + if integration is None or not integration.db_transaction_spans: + return real_rollback(self) + + with sentry_sdk.start_span( + op=OP.DB, + name=SPANNAME.DB_ROLLBACK, + origin=DjangoIntegration.origin_db, + ) as span: + _set_db_data(span, self, SPANNAME.DB_ROLLBACK) + return real_rollback(self) + CursorWrapper.execute = execute CursorWrapper.executemany = executemany BaseDatabaseWrapper.connect = connect + BaseDatabaseWrapper._commit = _commit + BaseDatabaseWrapper._rollback = _rollback ignore_logger("django.db.backends") -def _set_db_data(span, cursor_or_db): - # type: (Span, Any) -> None - +def _set_db_data( + span: "Span", cursor_or_db: "Any", db_operation: "Optional[str]" = None +) -> None: db = cursor_or_db.db if hasattr(cursor_or_db, "db") else cursor_or_db vendor = db.vendor span.set_data(SPANDATA.DB_SYSTEM, vendor) - connection_params = ( - cursor_or_db.connection.get_dsn_parameters() - if hasattr(cursor_or_db, "connection") + if db_operation is not None: + span.set_data(SPANDATA.DB_OPERATION, db_operation) + + # Some custom backends override `__getattr__`, making it look like `cursor_or_db` + # actually has a `connection` and the `connection` has a `get_dsn_parameters` + # attribute, only to throw an error once you actually want to call it. + # Hence the `inspect` check whether `get_dsn_parameters` is an actual callable + # function. + is_psycopg2 = ( + hasattr(cursor_or_db, "connection") and hasattr(cursor_or_db.connection, "get_dsn_parameters") - else db.get_connection_params() + and inspect.isroutine(cursor_or_db.connection.get_dsn_parameters) ) + if is_psycopg2: + connection_params = cursor_or_db.connection.get_dsn_parameters() + else: + try: + # psycopg3, only extract needed params as get_parameters + # can be slow because of the additional logic to filter out default + # values + connection_params = { + "dbname": cursor_or_db.connection.info.dbname, + "port": cursor_or_db.connection.info.port, + } + # PGhost returns host or base dir of UNIX socket as an absolute path + # starting with /, use it only when it contains host + pg_host = cursor_or_db.connection.info.host + if pg_host and not pg_host.startswith("/"): + connection_params["host"] = pg_host + except Exception: + connection_params = db.get_connection_params() + db_name = connection_params.get("dbname") or connection_params.get("database") if db_name is not None: span.set_data(SPANDATA.DB_NAME, db_name) @@ -681,8 +768,17 @@ def _set_db_data(span, cursor_or_db): server_port = connection_params.get("port") if server_port is not None: - span.set_data(SPANDATA.SERVER_PORT, text_type(server_port)) + span.set_data(SPANDATA.SERVER_PORT, str(server_port)) server_socket_address = connection_params.get("unix_socket") if server_socket_address is not None: span.set_data(SPANDATA.SERVER_SOCKET_ADDRESS, server_socket_address) + + +def add_template_context_repr_sequence() -> None: + try: + from django.template.context import BaseContext + + add_repr_sequence_type(BaseContext) + except Exception: + pass diff --git a/sentry_sdk/integrations/django/asgi.py b/sentry_sdk/integrations/django/asgi.py index 41ebe18e62..f3aff113d6 100644 --- a/sentry_sdk/integrations/django/asgi.py +++ b/sentry_sdk/integrations/django/asgi.py @@ -7,72 +7,153 @@ """ import asyncio +import functools +import inspect -from sentry_sdk import Hub, _functools -from sentry_sdk._types import TYPE_CHECKING +from django.core.handlers.wsgi import WSGIRequest + +import sentry_sdk from sentry_sdk.consts import OP from sentry_sdk.integrations.asgi import SentryAsgiMiddleware +from sentry_sdk.scope import should_send_default_pii +from sentry_sdk.utils import ( + capture_internal_exceptions, + ensure_integration_enabled, +) + +from typing import TYPE_CHECKING if TYPE_CHECKING: - from typing import Any - from typing import Union - from typing import Callable + from typing import Any, Callable, Union, TypeVar + from django.core.handlers.asgi import ASGIRequest from django.http.response import HttpResponse + from sentry_sdk._types import Event, EventProcessor + + _F = TypeVar("_F", bound=Callable[..., Any]) + + +# Python 3.12 deprecates asyncio.iscoroutinefunction() as an alias for +# inspect.iscoroutinefunction(), whilst also removing the _is_coroutine marker. +# The latter is replaced with the inspect.markcoroutinefunction decorator. +# Until 3.12 is the minimum supported Python version, provide a shim. +# This was copied from https://github.com/django/asgiref/blob/main/asgiref/sync.py +if hasattr(inspect, "markcoroutinefunction"): + iscoroutinefunction = inspect.iscoroutinefunction + markcoroutinefunction = inspect.markcoroutinefunction +else: + iscoroutinefunction = asyncio.iscoroutinefunction # type: ignore[assignment] + + def markcoroutinefunction(func: "_F") -> "_F": + func._is_coroutine = asyncio.coroutines._is_coroutine # type: ignore + return func + + +def _make_asgi_request_event_processor(request: "ASGIRequest") -> "EventProcessor": + def asgi_request_event_processor(event: "Event", hint: "dict[str, Any]") -> "Event": + # if the request is gone we are fine not logging the data from + # it. This might happen if the processor is pushed away to + # another thread. + from sentry_sdk.integrations.django import ( + DjangoRequestExtractor, + _set_user_info, + ) + + if request is None: + return event + + if type(request) == WSGIRequest: + return event + + with capture_internal_exceptions(): + DjangoRequestExtractor(request).extract_into_event(event) + + if should_send_default_pii(): + with capture_internal_exceptions(): + _set_user_info(request, event) -def patch_django_asgi_handler_impl(cls): - # type: (Any) -> None + return event + return asgi_request_event_processor + + +def patch_django_asgi_handler_impl(cls: "Any") -> None: from sentry_sdk.integrations.django import DjangoIntegration old_app = cls.__call__ - async def sentry_patched_asgi_handler(self, scope, receive, send): - # type: (Any, Any, Any, Any) -> Any - if Hub.current.get_integration(DjangoIntegration) is None: + async def sentry_patched_asgi_handler( + self: "Any", scope: "Any", receive: "Any", send: "Any" + ) -> "Any": + 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 + 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) cls.__call__ = sentry_patched_asgi_handler + modern_django_asgi_support = hasattr(cls, "create_request") + if modern_django_asgi_support: + old_create_request = cls.create_request + + @ensure_integration_enabled(DjangoIntegration, old_create_request) + def sentry_patched_create_request( + self: "Any", *args: "Any", **kwargs: "Any" + ) -> "Any": + request, error_response = old_create_request(self, *args, **kwargs) + scope = sentry_sdk.get_isolation_scope() + scope.add_event_processor(_make_asgi_request_event_processor(request)) + + return request, error_response + + cls.create_request = sentry_patched_create_request -def patch_get_response_async(cls, _before_get_response): - # type: (Any, Any) -> None + +def patch_get_response_async(cls: "Any", _before_get_response: "Any") -> None: old_get_response_async = cls.get_response_async - async def sentry_patched_get_response_async(self, request): - # type: (Any, Any) -> Union[HttpResponse, BaseException] + async def sentry_patched_get_response_async( + self: "Any", request: "Any" + ) -> "Union[HttpResponse, BaseException]": _before_get_response(request) return await old_get_response_async(self, request) cls.get_response_async = sentry_patched_get_response_async -def patch_channels_asgi_handler_impl(cls): - # type: (Any) -> None - +def patch_channels_asgi_handler_impl(cls: "Any") -> None: import channels # type: ignore + from sentry_sdk.integrations.django import DjangoIntegration if channels.__version__ < "3.0.0": old_app = cls.__call__ - async def sentry_patched_asgi_handler(self, receive, send): - # type: (Any, Any, Any) -> Any - if Hub.current.get_integration(DjangoIntegration) is None: + async def sentry_patched_asgi_handler( + self: "Any", receive: "Any", send: "Any" + ) -> "Any": + 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 + 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) + return await middleware(self.scope)(receive, send) # type: ignore cls.__call__ = sentry_patched_asgi_handler @@ -82,26 +163,38 @@ async def sentry_patched_asgi_handler(self, receive, send): patch_django_asgi_handler_impl(cls) -def wrap_async_view(hub, callback): - # type: (Hub, Any) -> Any - @_functools.wraps(callback) - async def sentry_wrapped_callback(request, *args, **kwargs): - # type: (Any, *Any, **Any) -> Any - - with hub.configure_scope() as sentry_scope: - if sentry_scope.profile is not None: - sentry_scope.profile.update_active_thread_id() +def wrap_async_view(callback: "Any") -> "Any": + from sentry_sdk.integrations.django import DjangoIntegration - with hub.start_span( - op=OP.VIEW_RENDER, description=request.resolver_match.view_name - ): - return await callback(request, *args, **kwargs) + @functools.wraps(callback) + async def sentry_wrapped_callback( + request: "Any", *args: "Any", **kwargs: "Any" + ) -> "Any": + current_scope = sentry_sdk.get_current_scope() + if current_scope.transaction is not None: + current_scope.transaction.update_active_thread() + + sentry_scope = sentry_sdk.get_isolation_scope() + if sentry_scope.profile is not None: + sentry_scope.profile.update_active_thread_id() + + integration = sentry_sdk.get_client().get_integration(DjangoIntegration) + if not integration or not integration.middleware_spans: + return await callback(request, *args, **kwargs) + + with sentry_sdk.start_span( + op=OP.VIEW_RENDER, + name=request.resolver_match.view_name, + origin=DjangoIntegration.origin, + ): + return await callback(request, *args, **kwargs) return sentry_wrapped_callback -def _asgi_middleware_mixin_factory(_check_middleware_span): - # type: (Callable[..., Any]) -> Any +def _asgi_middleware_mixin_factory( + _check_middleware_span: "Callable[..., Any]", +) -> "Any": """ Mixin class factory that generates a middleware mixin for handling requests in async mode. @@ -111,32 +204,28 @@ class SentryASGIMixin: if TYPE_CHECKING: _inner = None - def __init__(self, get_response): - # type: (Callable[..., Any]) -> None + def __init__(self, get_response: "Callable[..., Any]") -> None: self.get_response = get_response self._acall_method = None self._async_check() - def _async_check(self): - # type: () -> None + def _async_check(self) -> None: """ If get_response is a coroutine function, turns us into async mode so a thread is not consumed during a whole request. Taken from django.utils.deprecation::MiddlewareMixin._async_check """ - if asyncio.iscoroutinefunction(self.get_response): - self._is_coroutine = asyncio.coroutines._is_coroutine # type: ignore + if iscoroutinefunction(self.get_response): + markcoroutinefunction(self) - def async_route_check(self): - # type: () -> bool + def async_route_check(self) -> bool: """ Function that checks if we are in async mode, and if we are forwards the handling of requests to __acall__ """ - return asyncio.iscoroutinefunction(self.get_response) + return iscoroutinefunction(self.get_response) - async def __acall__(self, *args, **kwargs): - # type: (*Any, **Any) -> Any + async def __acall__(self, *args: "Any", **kwargs: "Any") -> "Any": f = self._acall_method if f is None: if hasattr(self._inner, "__acall__"): @@ -147,9 +236,9 @@ async def __acall__(self, *args, **kwargs): middleware_span = _check_middleware_span(old_method=f) if middleware_span is None: - return await f(*args, **kwargs) + return await f(*args, **kwargs) # type: ignore with middleware_span: - return await f(*args, **kwargs) + return await f(*args, **kwargs) # type: ignore return SentryASGIMixin diff --git a/sentry_sdk/integrations/django/caching.py b/sentry_sdk/integrations/django/caching.py index 921f8e485d..2ea49a2fa1 100644 --- a/sentry_sdk/integrations/django/caching.py +++ b/sentry_sdk/integrations/django/caching.py @@ -1,98 +1,192 @@ import functools from typing import TYPE_CHECKING +from sentry_sdk.integrations.redis.utils import _get_safe_key, _key_as_string +from urllib3.util import parse_url as urlparse from django import VERSION as DJANGO_VERSION from django.core.cache import CacheHandler -from sentry_sdk import Hub +import sentry_sdk from sentry_sdk.consts import OP, SPANDATA -from sentry_sdk._compat import text_type +from sentry_sdk.utils import ( + capture_internal_exceptions, + ensure_integration_enabled, +) if TYPE_CHECKING: from typing import Any from typing import Callable + from typing import Optional METHODS_TO_INSTRUMENT = [ + "set", + "set_many", "get", "get_many", ] -def _get_span_description(method_name, args, kwargs): - # type: (str, Any, Any) -> str - description = "{} ".format(method_name) +def _get_span_description( + method_name: str, args: "tuple[Any]", kwargs: "dict[str, Any]" +) -> str: + return _key_as_string(_get_safe_key(method_name, args, kwargs)) - if args is not None and len(args) >= 1: - description += text_type(args[0]) - elif kwargs is not None and "key" in kwargs: - description += text_type(kwargs["key"]) - return description - - -def _patch_cache_method(cache, method_name): - # type: (CacheHandler, str) -> None +def _patch_cache_method( + cache: "CacheHandler", + method_name: str, + address: "Optional[str]", + port: "Optional[int]", +) -> None: from sentry_sdk.integrations.django import DjangoIntegration - def _instrument_call(cache, method_name, original_method, args, kwargs): - # type: (CacheHandler, str, Callable[..., Any], Any, Any) -> Any - hub = Hub.current - integration = hub.get_integration(DjangoIntegration) - if integration is None or not integration.cache_spans: - return original_method(*args, **kwargs) + original_method = getattr(cache, method_name) + @ensure_integration_enabled(DjangoIntegration, original_method) + def _instrument_call( + cache: "CacheHandler", + method_name: str, + original_method: "Callable[..., Any]", + args: "tuple[Any, ...]", + kwargs: "dict[str, Any]", + address: "Optional[str]", + port: "Optional[int]", + ) -> "Any": + is_set_operation = method_name.startswith("set") + is_get_method = method_name == "get" + is_get_many_method = method_name == "get_many" + + op = OP.CACHE_PUT if is_set_operation else OP.CACHE_GET description = _get_span_description(method_name, args, kwargs) - with hub.start_span(op=OP.CACHE_GET_ITEM, description=description) as span: + with sentry_sdk.start_span( + op=op, + name=description, + origin=DjangoIntegration.origin, + ) as span: value = original_method(*args, **kwargs) - if value: - span.set_data(SPANDATA.CACHE_HIT, True) - - size = len(text_type(value)) - span.set_data(SPANDATA.CACHE_ITEM_SIZE, size) - - else: - span.set_data(SPANDATA.CACHE_HIT, False) + with capture_internal_exceptions(): + if address is not None: + span.set_data(SPANDATA.NETWORK_PEER_ADDRESS, address) + + if port is not None: + span.set_data(SPANDATA.NETWORK_PEER_PORT, port) + + key = _get_safe_key(method_name, args, kwargs) + if key is not None: + span.set_data(SPANDATA.CACHE_KEY, key) + + item_size = None + if is_get_many_method: + if value != {}: + item_size = len(str(value)) + span.set_data(SPANDATA.CACHE_HIT, True) + else: + span.set_data(SPANDATA.CACHE_HIT, False) + elif is_get_method: + default_value = None + if len(args) >= 2: + default_value = args[1] + elif "default" in kwargs: + default_value = kwargs["default"] + + if value != default_value: + item_size = len(str(value)) + span.set_data(SPANDATA.CACHE_HIT, True) + else: + span.set_data(SPANDATA.CACHE_HIT, False) + else: # TODO: We don't handle `get_or_set` which we should + arg_count = len(args) + if arg_count >= 2: + # 'set' command + item_size = len(str(args[1])) + elif arg_count == 1: + # 'set_many' command + item_size = len(str(args[0])) + + if item_size is not None: + span.set_data(SPANDATA.CACHE_ITEM_SIZE, item_size) return value - original_method = getattr(cache, method_name) - @functools.wraps(original_method) - def sentry_method(*args, **kwargs): - # type: (*Any, **Any) -> Any - return _instrument_call(cache, method_name, original_method, args, kwargs) + def sentry_method(*args: "Any", **kwargs: "Any") -> "Any": + return _instrument_call( + cache, method_name, original_method, args, kwargs, address, port + ) setattr(cache, method_name, sentry_method) -def _patch_cache(cache): - # type: (CacheHandler) -> None +def _patch_cache( + cache: "CacheHandler", address: "Optional[str]" = None, port: "Optional[int]" = None +) -> None: if not hasattr(cache, "_sentry_patched"): for method_name in METHODS_TO_INSTRUMENT: - _patch_cache_method(cache, method_name) + _patch_cache_method(cache, method_name, address, port) cache._sentry_patched = True -def patch_caching(): - # type: () -> None +def _get_address_port( + settings: "dict[str, Any]", +) -> "tuple[Optional[str], Optional[int]]": + location = settings.get("LOCATION") + + # TODO: location can also be an array of locations + # see: https://docs.djangoproject.com/en/5.0/topics/cache/#redis + # GitHub issue: https://github.com/getsentry/sentry-python/issues/3062 + if not isinstance(location, str): + return None, None + + if "://" in location: + parsed_url = urlparse(location) + # remove the username and password from URL to not leak sensitive data. + address = "{}://{}{}".format( + parsed_url.scheme or "", + parsed_url.hostname or "", + parsed_url.path or "", + ) + port = parsed_url.port + else: + address = location + port = None + + return address, int(port) if port is not None else None + + +def should_enable_cache_spans() -> bool: from sentry_sdk.integrations.django import DjangoIntegration + client = sentry_sdk.get_client() + integration = client.get_integration(DjangoIntegration) + from django.conf import settings + + return integration is not None and ( + (client.spotlight is not None and settings.DEBUG is True) + or integration.cache_spans is True + ) + + +def patch_caching() -> None: if not hasattr(CacheHandler, "_sentry_patched"): if DJANGO_VERSION < (3, 2): original_get_item = CacheHandler.__getitem__ @functools.wraps(original_get_item) - def sentry_get_item(self, alias): - # type: (CacheHandler, str) -> Any + def sentry_get_item(self: "CacheHandler", alias: str) -> "Any": cache = original_get_item(self, alias) - integration = Hub.current.get_integration(DjangoIntegration) - if integration and integration.cache_spans: - _patch_cache(cache) + if should_enable_cache_spans(): + from django.conf import settings + + address, port = _get_address_port( + settings.CACHES[alias or "default"] + ) + + _patch_cache(cache, address, port) return cache @@ -103,13 +197,13 @@ def sentry_get_item(self, alias): original_create_connection = CacheHandler.create_connection @functools.wraps(original_create_connection) - def sentry_create_connection(self, alias): - # type: (CacheHandler, str) -> Any + def sentry_create_connection(self: "CacheHandler", alias: str) -> "Any": cache = original_create_connection(self, alias) - integration = Hub.current.get_integration(DjangoIntegration) - if integration and integration.cache_spans: - _patch_cache(cache) + if should_enable_cache_spans(): + address, port = _get_address_port(self.settings[alias or "default"]) + + _patch_cache(cache, address, port) return cache diff --git a/sentry_sdk/integrations/django/middleware.py b/sentry_sdk/integrations/django/middleware.py index aa8023dbd4..94c0decf87 100644 --- a/sentry_sdk/integrations/django/middleware.py +++ b/sentry_sdk/integrations/django/middleware.py @@ -2,11 +2,11 @@ Create spans from Django middleware invocations """ +from functools import wraps + from django import VERSION as DJANGO_VERSION -from sentry_sdk import Hub -from sentry_sdk._functools import wraps -from sentry_sdk._types import TYPE_CHECKING +import sentry_sdk from sentry_sdk.consts import OP from sentry_sdk.utils import ( ContextVar, @@ -14,6 +14,8 @@ capture_internal_exceptions, ) +from typing import TYPE_CHECKING + if TYPE_CHECKING: from typing import Any from typing import Callable @@ -28,26 +30,20 @@ "import_string_should_wrap_middleware" ) -if DJANGO_VERSION < (1, 7): - import_string_name = "import_by_path" -else: - import_string_name = "import_string" +DJANGO_SUPPORTS_ASYNC_MIDDLEWARE = DJANGO_VERSION >= (3, 1) - -if DJANGO_VERSION < (3, 1): +if not DJANGO_SUPPORTS_ASYNC_MIDDLEWARE: _asgi_middleware_mixin_factory = lambda _: object else: from .asgi import _asgi_middleware_mixin_factory -def patch_django_middlewares(): - # type: () -> None +def patch_django_middlewares() -> None: from django.core.handlers import base - old_import_string = getattr(base, import_string_name) + old_import_string = base.import_string - def sentry_patched_import_string(dotted_path): - # type: (str) -> Any + def sentry_patched_import_string(dotted_path: str) -> "Any": rv = old_import_string(dotted_path) if _import_string_should_wrap_middleware.get(None): @@ -55,12 +51,11 @@ def sentry_patched_import_string(dotted_path): return rv - setattr(base, import_string_name, sentry_patched_import_string) + base.import_string = sentry_patched_import_string old_load_middleware = base.BaseHandler.load_middleware - def sentry_patched_load_middleware(*args, **kwargs): - # type: (Any, Any) -> Any + def sentry_patched_load_middleware(*args: "Any", **kwargs: "Any") -> "Any": _import_string_should_wrap_middleware.set(True) try: return old_load_middleware(*args, **kwargs) @@ -70,14 +65,11 @@ def sentry_patched_load_middleware(*args, **kwargs): base.BaseHandler.load_middleware = sentry_patched_load_middleware -def _wrap_middleware(middleware, middleware_name): - # type: (Any, str) -> Any +def _wrap_middleware(middleware: "Any", middleware_name: str) -> "Any": from sentry_sdk.integrations.django import DjangoIntegration - def _check_middleware_span(old_method): - # type: (Callable[..., Any]) -> Optional[Span] - hub = Hub.current - integration = hub.get_integration(DjangoIntegration) + def _check_middleware_span(old_method: "Callable[..., Any]") -> "Optional[Span]": + integration = sentry_sdk.get_client().get_integration(DjangoIntegration) if integration is None or not integration.middleware_spans: return None @@ -88,20 +80,20 @@ def _check_middleware_span(old_method): if function_basename: description = "{}.{}".format(description, function_basename) - middleware_span = hub.start_span( - op=OP.MIDDLEWARE_DJANGO, description=description + middleware_span = sentry_sdk.start_span( + op=OP.MIDDLEWARE_DJANGO, + name=description, + origin=DjangoIntegration.origin, ) middleware_span.set_tag("django.function_name", function_name) middleware_span.set_tag("django.middleware_name", middleware_name) return middleware_span - def _get_wrapped_method(old_method): - # type: (F) -> F + def _get_wrapped_method(old_method: "F") -> "F": with capture_internal_exceptions(): - def sentry_wrapped_method(*args, **kwargs): - # type: (*Any, **Any) -> Any + def sentry_wrapped_method(*args: "Any", **kwargs: "Any") -> "Any": middleware_span = _check_middleware_span(old_method) if middleware_span is None: @@ -126,10 +118,17 @@ def sentry_wrapped_method(*args, **kwargs): class SentryWrappingMiddleware( _asgi_middleware_mixin_factory(_check_middleware_span) # type: ignore ): - async_capable = getattr(middleware, "async_capable", False) + sync_capable = getattr(middleware, "sync_capable", True) + async_capable = DJANGO_SUPPORTS_ASYNC_MIDDLEWARE and getattr( + middleware, "async_capable", False + ) - def __init__(self, get_response=None, *args, **kwargs): - # type: (Optional[Callable[..., Any]], *Any, **Any) -> None + def __init__( + self, + get_response: "Optional[Callable[..., Any]]" = None, + *args: "Any", + **kwargs: "Any", + ) -> None: if get_response: self._inner = middleware(get_response, *args, **kwargs) else: @@ -137,12 +136,11 @@ def __init__(self, get_response=None, *args, **kwargs): self.get_response = get_response self._call_method = None if self.async_capable: - super(SentryWrappingMiddleware, self).__init__(get_response) + super().__init__(get_response) # We need correct behavior for `hasattr()`, which we can only determine # when we have an instance of the middleware we're wrapping. - def __getattr__(self, method_name): - # type: (str) -> Any + def __getattr__(self, method_name: str) -> "Any": if method_name not in ( "process_request", "process_view", @@ -157,8 +155,7 @@ def __getattr__(self, method_name): self.__dict__[method_name] = rv return rv - def __call__(self, *args, **kwargs): - # type: (*Any, **Any) -> Any + def __call__(self, *args: "Any", **kwargs: "Any") -> "Any": if hasattr(self, "async_route_check") and self.async_route_check(): return self.__acall__(*args, **kwargs) diff --git a/sentry_sdk/integrations/django/signals_handlers.py b/sentry_sdk/integrations/django/signals_handlers.py index 87b6b22ff8..0c834ff8c6 100644 --- a/sentry_sdk/integrations/django/signals_handlers.py +++ b/sentry_sdk/integrations/django/signals_handlers.py @@ -1,22 +1,19 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import +from functools import wraps from django.dispatch import Signal -from sentry_sdk import Hub -from sentry_sdk._functools import wraps -from sentry_sdk._types import TYPE_CHECKING +import sentry_sdk from sentry_sdk.consts import OP +from sentry_sdk.integrations.django import DJANGO_VERSION +from typing import TYPE_CHECKING if TYPE_CHECKING: - from typing import Any - from typing import Callable - from typing import List + from collections.abc import Callable + from typing import Any, Union -def _get_receiver_name(receiver): - # type: (Callable[..., Any]) -> str +def _get_receiver_name(receiver: "Callable[..., Any]") -> str: name = "" if hasattr(receiver, "__qualname__"): @@ -40,38 +37,54 @@ def _get_receiver_name(receiver): return name -def patch_signals(): - # type: () -> None - """Patch django signal receivers to create a span""" +def patch_signals() -> None: + """ + Patch django signal receivers to create a span. + + This only wraps sync receivers. Django>=5.0 introduced async receivers, but + since we don't create transactions for ASGI Django, we don't wrap them. + """ from sentry_sdk.integrations.django import DjangoIntegration old_live_receivers = Signal._live_receivers - def _sentry_live_receivers(self, sender): - # type: (Signal, Any) -> List[Callable[..., Any]] - hub = Hub.current - receivers = old_live_receivers(self, sender) - - def sentry_receiver_wrapper(receiver): - # type: (Callable[..., Any]) -> Callable[..., Any] + def _sentry_live_receivers( + self: "Signal", sender: "Any" + ) -> "Union[tuple[list[Callable[..., Any]], list[Callable[..., Any]]], list[Callable[..., Any]]]": + if DJANGO_VERSION >= (5, 0): + sync_receivers, async_receivers = old_live_receivers(self, sender) + else: + sync_receivers = old_live_receivers(self, sender) + async_receivers = [] + + def sentry_sync_receiver_wrapper( + receiver: "Callable[..., Any]", + ) -> "Callable[..., Any]": @wraps(receiver) - def wrapper(*args, **kwargs): - # type: (Any, Any) -> Any + def wrapper(*args: "Any", **kwargs: "Any") -> "Any": signal_name = _get_receiver_name(receiver) - with hub.start_span( + with sentry_sdk.start_span( op=OP.EVENT_DJANGO, - description=signal_name, + name=signal_name, + origin=DjangoIntegration.origin, ) as span: span.set_data("signal", signal_name) return receiver(*args, **kwargs) return wrapper - integration = hub.get_integration(DjangoIntegration) - if integration and integration.signals_spans: - for idx, receiver in enumerate(receivers): - receivers[idx] = sentry_receiver_wrapper(receiver) - - return receivers + integration = sentry_sdk.get_client().get_integration(DjangoIntegration) + if ( + integration + and integration.signals_spans + and self not in integration.signals_denylist + ): + for idx, receiver in enumerate(sync_receivers): + sync_receivers[idx] = sentry_sync_receiver_wrapper(receiver) + + if DJANGO_VERSION >= (5, 0): + return sync_receivers, async_receivers + else: + return sync_receivers Signal._live_receivers = _sentry_live_receivers diff --git a/sentry_sdk/integrations/django/tasks.py b/sentry_sdk/integrations/django/tasks.py new file mode 100644 index 0000000000..84bc4d2396 --- /dev/null +++ b/sentry_sdk/integrations/django/tasks.py @@ -0,0 +1,41 @@ +from functools import wraps + +import sentry_sdk +from sentry_sdk.consts import OP +from sentry_sdk.tracing import SPANSTATUS +from sentry_sdk.utils import qualname_from_function + +try: + # django.tasks were added in Django 6.0 + from django.tasks.base import Task, TaskResultStatus +except ImportError: + Task = None + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Any + + +def patch_tasks() -> None: + if Task is None: + return + + old_task_enqueue = Task.enqueue + + @wraps(old_task_enqueue) + def _sentry_enqueue(self: "Any", *args: "Any", **kwargs: "Any") -> "Any": + from sentry_sdk.integrations.django import DjangoIntegration + + integration = sentry_sdk.get_client().get_integration(DjangoIntegration) + if integration is None: + return old_task_enqueue(self, *args, **kwargs) + + name = qualname_from_function(self.func) or "" + + with sentry_sdk.start_span( + op=OP.QUEUE_SUBMIT_DJANGO, name=name, origin=DjangoIntegration.origin + ): + return old_task_enqueue(self, *args, **kwargs) + + Task.enqueue = _sentry_enqueue diff --git a/sentry_sdk/integrations/django/templates.py b/sentry_sdk/integrations/django/templates.py index e6c83b5bf2..c8ca6682fe 100644 --- a/sentry_sdk/integrations/django/templates.py +++ b/sentry_sdk/integrations/django/templates.py @@ -1,10 +1,14 @@ +import functools + from django.template import TemplateSyntaxError from django.utils.safestring import mark_safe from django import VERSION as DJANGO_VERSION -from sentry_sdk import _functools, Hub -from sentry_sdk._types import TYPE_CHECKING +import sentry_sdk from sentry_sdk.consts import OP +from sentry_sdk.utils import ensure_integration_enabled + +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any @@ -21,9 +25,9 @@ from django.template.loader import LoaderOrigin as Origin -def get_template_frame_from_exception(exc_value): - # type: (Optional[BaseException]) -> Optional[Dict[str, Any]] - +def get_template_frame_from_exception( + exc_value: "Optional[BaseException]", +) -> "Optional[Dict[str, Any]]": # As of Django 1.9 or so the new template debug thing showed up. if hasattr(exc_value, "template_debug"): return _get_template_frame_from_debug(exc_value.template_debug) # type: ignore @@ -44,8 +48,7 @@ def get_template_frame_from_exception(exc_value): return None -def _get_template_name_description(template_name): - # type: (str) -> str +def _get_template_name_description(template_name: str) -> str: if isinstance(template_name, (list, tuple)): if template_name: return "[{}, ...]".format(template_name[0]) @@ -53,23 +56,19 @@ def _get_template_name_description(template_name): return template_name -def patch_templates(): - # type: () -> None +def patch_templates() -> None: from django.template.response import SimpleTemplateResponse from sentry_sdk.integrations.django import DjangoIntegration real_rendered_content = SimpleTemplateResponse.rendered_content @property # type: ignore - def rendered_content(self): - # type: (SimpleTemplateResponse) -> str - hub = Hub.current - if hub.get_integration(DjangoIntegration) is None: - return real_rendered_content.fget(self) - - with hub.start_span( + @ensure_integration_enabled(DjangoIntegration, real_rendered_content.fget) + def rendered_content(self: "SimpleTemplateResponse") -> str: + with sentry_sdk.start_span( op=OP.TEMPLATE_RENDER, - description=_get_template_name_description(self.template_name), + name=_get_template_name_description(self.template_name), + origin=DjangoIntegration.origin, ) as span: span.set_data("context", self.context_data) return real_rendered_content.fget(self) @@ -82,21 +81,26 @@ def rendered_content(self): real_render = django.shortcuts.render - @_functools.wraps(real_render) - def render(request, template_name, context=None, *args, **kwargs): - # type: (django.http.HttpRequest, str, Optional[Dict[str, Any]], *Any, **Any) -> django.http.HttpResponse - hub = Hub.current - if hub.get_integration(DjangoIntegration) is None: - return real_render(request, template_name, context, *args, **kwargs) - + @functools.wraps(real_render) + @ensure_integration_enabled(DjangoIntegration, real_render) + def render( + request: "django.http.HttpRequest", + template_name: str, + context: "Optional[Dict[str, Any]]" = None, + *args: "Any", + **kwargs: "Any", + ) -> "django.http.HttpResponse": # Inject trace meta tags into template context context = context or {} if "sentry_trace_meta" not in context: - context["sentry_trace_meta"] = mark_safe(hub.trace_propagation_meta()) + context["sentry_trace_meta"] = mark_safe( + sentry_sdk.get_current_scope().trace_propagation_meta() + ) - with hub.start_span( + with sentry_sdk.start_span( op=OP.TEMPLATE_RENDER, - description=_get_template_name_description(template_name), + name=_get_template_name_description(template_name), + origin=DjangoIntegration.origin, ) as span: span.set_data("context", context) return real_render(request, template_name, context, *args, **kwargs) @@ -104,8 +108,7 @@ def render(request, template_name, context=None, *args, **kwargs): django.shortcuts.render = render -def _get_template_frame_from_debug(debug): - # type: (Dict[str, Any]) -> Dict[str, Any] +def _get_template_frame_from_debug(debug: "Dict[str, Any]") -> "Dict[str, Any]": if debug is None: return None @@ -136,8 +139,7 @@ def _get_template_frame_from_debug(debug): } -def _linebreak_iter(template_source): - # type: (str) -> Iterator[int] +def _linebreak_iter(template_source: str) -> "Iterator[int]": yield 0 p = template_source.find("\n") while p >= 0: @@ -145,8 +147,9 @@ def _linebreak_iter(template_source): p = template_source.find("\n", p + 1) -def _get_template_frame_from_source(source): - # type: (Tuple[Origin, Tuple[int, int]]) -> Optional[Dict[str, Any]] +def _get_template_frame_from_source( + source: "Tuple[Origin, Tuple[int, int]]", +) -> "Optional[Dict[str, Any]]": if not source: return None diff --git a/sentry_sdk/integrations/django/transactions.py b/sentry_sdk/integrations/django/transactions.py index 91349c4bf9..0017aa437c 100644 --- a/sentry_sdk/integrations/django/transactions.py +++ b/sentry_sdk/integrations/django/transactions.py @@ -1,13 +1,13 @@ """ -Copied from raven-python. Used for -`DjangoIntegration(transaction_fron="raven_legacy")`. -""" +Copied from raven-python. -from __future__ import absolute_import +Despite being called "legacy" in some places this resolver is very much still +in use. +""" import re -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from django.urls.resolvers import URLResolver @@ -19,14 +19,20 @@ from typing import Union from re import Pattern +from django import VERSION as DJANGO_VERSION + +if DJANGO_VERSION >= (2, 0): + from django.urls.resolvers import RoutePattern +else: + RoutePattern = None + try: from django.urls import get_resolver except ImportError: from django.core.urlresolvers import get_resolver -def get_regex(resolver_or_pattern): - # type: (Union[URLPattern, URLResolver]) -> Pattern[str] +def get_regex(resolver_or_pattern: "Union[URLPattern, URLResolver]") -> "Pattern[str]": """Utility method for django's deprecated resolver.regex""" try: regex = resolver_or_pattern.regex @@ -35,7 +41,10 @@ def get_regex(resolver_or_pattern): return regex -class RavenResolver(object): +class RavenResolver: + _new_style_group_matcher = re.compile( + r"<(?:([^>:]+):)?([^>]+)>" + ) # https://github.com/django/django/blob/21382e2743d06efbf5623e7c9b6dccf2a325669b/django/urls/resolvers.py#L245-L247 _optional_group_matcher = re.compile(r"\(\?\:([^\)]+)\)") _named_group_matcher = re.compile(r"\(\?P<(\w+)>[^\)]+\)+") _non_named_group_matcher = re.compile(r"\([^\)]+\)") @@ -43,10 +52,9 @@ class RavenResolver(object): _either_option_matcher = re.compile(r"\[([^\]]+)\|([^\]]+)\]") _camel_re = re.compile(r"([A-Z]+)([a-z])") - _cache = {} # type: Dict[URLPattern, str] + _cache: "Dict[URLPattern, str]" = {} - def _simplify(self, pattern): - # type: (str) -> str + def _simplify(self, pattern: "Union[URLPattern, URLResolver]") -> str: r""" Clean up urlpattern regexes into something readable by humans: @@ -56,11 +64,24 @@ def _simplify(self, pattern): To: > "{sport_slug}/athletes/{athlete_slug}/" """ + # "new-style" path patterns can be parsed directly without turning them + # into regexes first + if ( + RoutePattern is not None + and hasattr(pattern, "pattern") + and isinstance(pattern.pattern, RoutePattern) + ): + return self._new_style_group_matcher.sub( + lambda m: "{%s}" % m.group(2), str(pattern.pattern._route) + ) + + result = get_regex(pattern).pattern + # remove optional params # TODO(dcramer): it'd be nice to change these into [%s] but it currently # conflicts with the other rules because we're doing regexp matches # rather than parsing tokens - result = self._optional_group_matcher.sub(lambda m: "%s" % m.group(1), pattern) + result = self._optional_group_matcher.sub(lambda m: "%s" % m.group(1), result) # handle named groups first result = self._named_group_matcher.sub(lambda m: "{%s}" % m.group(1), result) @@ -84,9 +105,12 @@ def _simplify(self, pattern): return result - def _resolve(self, resolver, path, parents=None): - # type: (URLResolver, str, Optional[List[URLResolver]]) -> Optional[str] - + def _resolve( + self, + resolver: "URLResolver", + path: str, + parents: "Optional[List[URLResolver]]" = None, + ) -> "Optional[str]": match = get_regex(resolver).search(path) # Django < 2.0 if not match: @@ -113,8 +137,8 @@ def _resolve(self, resolver, path, parents=None): except KeyError: pass - prefix = "".join(self._simplify(get_regex(p).pattern) for p in parents) - result = prefix + self._simplify(get_regex(pattern).pattern) + prefix = "".join(self._simplify(p) for p in parents) + result = prefix + self._simplify(pattern) if not result.startswith("/"): result = "/" + result self._cache[pattern] = result @@ -124,10 +148,9 @@ def _resolve(self, resolver, path, parents=None): def resolve( self, - path, # type: str - urlconf=None, # type: Union[None, Tuple[URLPattern, URLPattern, URLResolver], Tuple[URLPattern]] - ): - # type: (...) -> Optional[str] + path: str, + urlconf: "Union[None, Tuple[URLPattern, URLPattern, URLResolver], Tuple[URLPattern]]" = None, + ) -> "Optional[str]": resolver = get_resolver(urlconf) match = self._resolve(resolver, path) return match diff --git a/sentry_sdk/integrations/django/views.py b/sentry_sdk/integrations/django/views.py index c1034d0d85..c9e370029e 100644 --- a/sentry_sdk/integrations/django/views.py +++ b/sentry_sdk/integrations/django/views.py @@ -1,7 +1,9 @@ +import functools + +import sentry_sdk from sentry_sdk.consts import OP -from sentry_sdk.hub import Hub -from sentry_sdk._types import TYPE_CHECKING -from sentry_sdk import _functools + +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any @@ -19,9 +21,7 @@ wrap_async_view = None # type: ignore -def patch_views(): - # type: () -> None - +def patch_views() -> None: from django.core.handlers.base import BaseHandler from django.template.response import SimpleTemplateResponse from sentry_sdk.integrations.django import DjangoIntegration @@ -29,34 +29,34 @@ def patch_views(): old_make_view_atomic = BaseHandler.make_view_atomic old_render = SimpleTemplateResponse.render - def sentry_patched_render(self): - # type: (SimpleTemplateResponse) -> Any - hub = Hub.current - with hub.start_span( - op=OP.VIEW_RESPONSE_RENDER, description="serialize response" + def sentry_patched_render(self: "SimpleTemplateResponse") -> "Any": + with sentry_sdk.start_span( + op=OP.VIEW_RESPONSE_RENDER, + name="serialize response", + origin=DjangoIntegration.origin, ): return old_render(self) - @_functools.wraps(old_make_view_atomic) - def sentry_patched_make_view_atomic(self, *args, **kwargs): - # type: (Any, *Any, **Any) -> Any + @functools.wraps(old_make_view_atomic) + def sentry_patched_make_view_atomic( + self: "Any", *args: "Any", **kwargs: "Any" + ) -> "Any": callback = old_make_view_atomic(self, *args, **kwargs) # XXX: The wrapper function is created for every request. Find more # efficient way to wrap views (or build a cache?) - hub = Hub.current - integration = hub.get_integration(DjangoIntegration) - - if integration is not None and integration.middleware_spans: - if ( + integration = sentry_sdk.get_client().get_integration(DjangoIntegration) + if integration is not None: + is_async_view = ( iscoroutinefunction is not None and wrap_async_view is not None and iscoroutinefunction(callback) - ): - sentry_wrapped_callback = wrap_async_view(hub, callback) + ) + if is_async_view: + sentry_wrapped_callback = wrap_async_view(callback) else: - sentry_wrapped_callback = _wrap_sync_view(hub, callback) + sentry_wrapped_callback = _wrap_sync_view(callback) else: sentry_wrapped_callback = callback @@ -67,20 +67,30 @@ def sentry_patched_make_view_atomic(self, *args, **kwargs): BaseHandler.make_view_atomic = sentry_patched_make_view_atomic -def _wrap_sync_view(hub, callback): - # type: (Hub, Any) -> Any - @_functools.wraps(callback) - def sentry_wrapped_callback(request, *args, **kwargs): - # type: (Any, *Any, **Any) -> Any - with hub.configure_scope() as sentry_scope: - # set the active thread id to the handler thread for sync views - # this isn't necessary for async views since that runs on main - if sentry_scope.profile is not None: - sentry_scope.profile.update_active_thread_id() - - with hub.start_span( - op=OP.VIEW_RENDER, description=request.resolver_match.view_name - ): - return callback(request, *args, **kwargs) +def _wrap_sync_view(callback: "Any") -> "Any": + from sentry_sdk.integrations.django import DjangoIntegration + + @functools.wraps(callback) + def sentry_wrapped_callback(request: "Any", *args: "Any", **kwargs: "Any") -> "Any": + current_scope = sentry_sdk.get_current_scope() + if current_scope.transaction is not None: + current_scope.transaction.update_active_thread() + + sentry_scope = sentry_sdk.get_isolation_scope() + # set the active thread id to the handler thread for sync views + # this isn't necessary for async views since that runs on main + if sentry_scope.profile is not None: + sentry_scope.profile.update_active_thread_id() + + integration = sentry_sdk.get_client().get_integration(DjangoIntegration) + if not integration or not integration.middleware_spans: + return callback(request, *args, **kwargs) + + with sentry_sdk.start_span( + op=OP.VIEW_RENDER, + name=request.resolver_match.view_name, + origin=DjangoIntegration.origin, + ): + return callback(request, *args, **kwargs) return sentry_wrapped_callback diff --git a/sentry_sdk/integrations/dramatiq.py b/sentry_sdk/integrations/dramatiq.py new file mode 100644 index 0000000000..ae87de7525 --- /dev/null +++ b/sentry_sdk/integrations/dramatiq.py @@ -0,0 +1,226 @@ +import json + +import sentry_sdk +from sentry_sdk.consts import OP, SPANSTATUS +from sentry_sdk.api import continue_trace, get_baggage, get_traceparent +from sentry_sdk.integrations import Integration, DidNotEnable +from sentry_sdk.integrations._wsgi_common import request_body_within_bounds +from sentry_sdk.tracing import ( + BAGGAGE_HEADER_NAME, + SENTRY_TRACE_HEADER_NAME, + TransactionSource, +) +from sentry_sdk.utils import ( + AnnotatedValue, + capture_internal_exceptions, + event_from_exception, +) +from typing import TypeVar + +R = TypeVar("R") + +try: + from dramatiq.broker import Broker + from dramatiq.middleware import Middleware, default_middleware + from dramatiq.errors import Retry + from dramatiq.message import Message +except ImportError: + raise DidNotEnable("Dramatiq is not installed") + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Any, Callable, Dict, Optional, Union + from sentry_sdk._types import Event, Hint + + +class DramatiqIntegration(Integration): + """ + Dramatiq integration for Sentry + + Please make sure that you call `sentry_sdk.init` *before* initializing + your broker, as it monkey patches `Broker.__init__`. + + This integration was originally developed and maintained + by https://github.com/jacobsvante and later donated to the Sentry + project. + """ + + identifier = "dramatiq" + origin = f"auto.queue.{identifier}" + + @staticmethod + def setup_once() -> None: + _patch_dramatiq_broker() + + +def _patch_dramatiq_broker() -> None: + original_broker__init__ = Broker.__init__ + + def sentry_patched_broker__init__( + self: "Broker", *args: "Any", **kw: "Any" + ) -> None: + integration = sentry_sdk.get_client().get_integration(DramatiqIntegration) + + try: + middleware = kw.pop("middleware") + except KeyError: + # Unfortunately Broker and StubBroker allows middleware to be + # passed in as positional arguments, whilst RabbitmqBroker and + # RedisBroker does not. + if len(args) == 1: + middleware = args[0] + args = [] # type: ignore + else: + middleware = None + + if middleware is None: + middleware = list(m() for m in default_middleware) + else: + middleware = list(middleware) + + if integration is not None: + middleware = [m for m in middleware if not isinstance(m, SentryMiddleware)] + middleware.insert(0, SentryMiddleware()) + + kw["middleware"] = middleware + original_broker__init__(self, *args, **kw) + + Broker.__init__ = sentry_patched_broker__init__ + + +class SentryMiddleware(Middleware): # type: ignore[misc] + """ + A Dramatiq middleware that automatically captures and sends + exceptions to Sentry. + + This is automatically added to every instantiated broker via the + DramatiqIntegration. + """ + + SENTRY_HEADERS_NAME = "_sentry_headers" + + def before_enqueue( + self, broker: "Broker", message: "Message[R]", delay: int + ) -> None: + integration = sentry_sdk.get_client().get_integration(DramatiqIntegration) + if integration is None: + return + + message.options[self.SENTRY_HEADERS_NAME] = { + BAGGAGE_HEADER_NAME: get_baggage(), + SENTRY_TRACE_HEADER_NAME: get_traceparent(), + } + + def before_process_message(self, broker: "Broker", message: "Message[R]") -> None: + integration = sentry_sdk.get_client().get_integration(DramatiqIntegration) + if integration is None: + return + + message._scope_manager = sentry_sdk.isolation_scope() + scope = message._scope_manager.__enter__() + scope.clear_breadcrumbs() + scope.set_extra("dramatiq_message_id", message.message_id) + scope.add_event_processor(_make_message_event_processor(message, integration)) + + sentry_headers = message.options.get(self.SENTRY_HEADERS_NAME) or {} + if "retries" in message.options: + # start new trace in case of retrying + sentry_headers = {} + + transaction = continue_trace( + sentry_headers, + name=message.actor_name, + op=OP.QUEUE_TASK_DRAMATIQ, + source=TransactionSource.TASK, + origin=DramatiqIntegration.origin, + ) + transaction.set_status(SPANSTATUS.OK) + sentry_sdk.start_transaction( + transaction, + name=message.actor_name, + op=OP.QUEUE_TASK_DRAMATIQ, + source=TransactionSource.TASK, + ) + transaction.__enter__() + + def after_process_message( + self, + broker: "Broker", + message: "Message[R]", + *, + result: "Optional[Any]" = None, + exception: "Optional[Exception]" = None, + ) -> None: + integration = sentry_sdk.get_client().get_integration(DramatiqIntegration) + if integration is None: + return + + actor = broker.get_actor(message.actor_name) + throws = message.options.get("throws") or actor.options.get("throws") + + scope_manager = message._scope_manager + transaction = sentry_sdk.get_current_scope().transaction + if not transaction: + return None + + is_event_capture_required = ( + exception is not None + and not (throws and isinstance(exception, throws)) + and not isinstance(exception, Retry) + ) + if not is_event_capture_required: + # normal transaction finish + transaction.__exit__(None, None, None) + scope_manager.__exit__(None, None, None) + return + + event, hint = event_from_exception( + exception, # type: ignore[arg-type] + client_options=sentry_sdk.get_client().options, + mechanism={ + "type": DramatiqIntegration.identifier, + "handled": False, + }, + ) + sentry_sdk.capture_event(event, hint=hint) + # transaction error + transaction.__exit__(type(exception), exception, None) + scope_manager.__exit__(type(exception), exception, None) + + +def _make_message_event_processor( + message: "Message[R]", integration: "DramatiqIntegration" +) -> "Callable[[Event, Hint], Optional[Event]]": + def inner(event: "Event", hint: "Hint") -> "Optional[Event]": + with capture_internal_exceptions(): + DramatiqMessageExtractor(message).extract_into_event(event) + + return event + + return inner + + +class DramatiqMessageExtractor: + def __init__(self, message: "Message[R]") -> None: + self.message_data = dict(message.asdict()) + + def content_length(self) -> int: + return len(json.dumps(self.message_data)) + + def extract_into_event(self, event: "Event") -> None: + client = sentry_sdk.get_client() + if not client.is_active(): + return + + contexts = event.setdefault("contexts", {}) + request_info = contexts.setdefault("dramatiq", {}) + request_info["type"] = "dramatiq" + + data: "Optional[Union[AnnotatedValue, Dict[str, Any]]]" = None + if not request_body_within_bounds(client, self.content_length()): + data = AnnotatedValue.removed_because_over_size_limit() + else: + data = self.message_data + + request_info["data"] = data diff --git a/sentry_sdk/integrations/excepthook.py b/sentry_sdk/integrations/excepthook.py index 514e082b31..6409319990 100644 --- a/sentry_sdk/integrations/excepthook.py +++ b/sentry_sdk/integrations/excepthook.py @@ -1,10 +1,13 @@ import sys -from sentry_sdk.hub import Hub -from sentry_sdk.utils import capture_internal_exceptions, event_from_exception +import sentry_sdk +from sentry_sdk.utils import ( + capture_internal_exceptions, + event_from_exception, +) from sentry_sdk.integrations import Integration -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Callable @@ -25,9 +28,7 @@ class ExcepthookIntegration(Integration): always_run = False - def __init__(self, always_run=False): - # type: (bool) -> None - + def __init__(self, always_run: bool = False) -> None: if not isinstance(always_run, bool): raise ValueError( "Invalid value for always_run: %s (must be type boolean)" @@ -36,37 +37,39 @@ def __init__(self, always_run=False): self.always_run = always_run @staticmethod - def setup_once(): - # type: () -> None + def setup_once() -> None: sys.excepthook = _make_excepthook(sys.excepthook) -def _make_excepthook(old_excepthook): - # type: (Excepthook) -> Excepthook - def sentry_sdk_excepthook(type_, value, traceback): - # type: (Type[BaseException], BaseException, Optional[TracebackType]) -> None - hub = Hub.current - integration = hub.get_integration(ExcepthookIntegration) +def _make_excepthook(old_excepthook: "Excepthook") -> "Excepthook": + def sentry_sdk_excepthook( + type_: "Type[BaseException]", + value: BaseException, + traceback: "Optional[TracebackType]", + ) -> None: + integration = sentry_sdk.get_client().get_integration(ExcepthookIntegration) - if integration is not None and _should_send(integration.always_run): - # If an integration is there, a client has to be there. - client = hub.client # type: Any + # Note: If we replace this with ensure_integration_enabled then + # we break the exceptiongroup backport; + # See: https://github.com/getsentry/sentry-python/issues/3097 + if integration is None: + return old_excepthook(type_, value, traceback) + if _should_send(integration.always_run): with capture_internal_exceptions(): event, hint = event_from_exception( (type_, value, traceback), - client_options=client.options, + client_options=sentry_sdk.get_client().options, mechanism={"type": "excepthook", "handled": False}, ) - hub.capture_event(event, hint=hint) + sentry_sdk.capture_event(event, hint=hint) return old_excepthook(type_, value, traceback) return sentry_sdk_excepthook -def _should_send(always_run=False): - # type: (bool) -> bool +def _should_send(always_run: bool = False) -> bool: if always_run: return True diff --git a/sentry_sdk/integrations/executing.py b/sentry_sdk/integrations/executing.py index e8636b61f8..c5aa522667 100644 --- a/sentry_sdk/integrations/executing.py +++ b/sentry_sdk/integrations/executing.py @@ -1,11 +1,10 @@ -from __future__ import absolute_import - -from sentry_sdk import Hub -from sentry_sdk._types import TYPE_CHECKING +import sentry_sdk from sentry_sdk.integrations import Integration, DidNotEnable from sentry_sdk.scope import add_global_event_processor from sentry_sdk.utils import walk_exception_chain, iter_stacks +from typing import TYPE_CHECKING + if TYPE_CHECKING: from typing import Optional @@ -21,13 +20,12 @@ class ExecutingIntegration(Integration): identifier = "executing" @staticmethod - def setup_once(): - # type: () -> None - + def setup_once() -> None: @add_global_event_processor - def add_executing_info(event, hint): - # type: (Event, Optional[Hint]) -> Optional[Event] - if Hub.current.get_integration(ExecutingIntegration) is None: + def add_executing_info( + event: "Event", hint: "Optional[Hint]" + ) -> "Optional[Event]": + if sentry_sdk.get_client().get_integration(ExecutingIntegration) is None: return event if hint is None: diff --git a/sentry_sdk/integrations/falcon.py b/sentry_sdk/integrations/falcon.py index 9b3cc40cd6..158b4e61aa 100644 --- a/sentry_sdk/integrations/falcon.py +++ b/sentry_sdk/integrations/falcon.py @@ -1,24 +1,23 @@ -from __future__ import absolute_import - -from sentry_sdk.hub import Hub -from sentry_sdk.integrations import Integration, DidNotEnable +import sentry_sdk +from sentry_sdk.integrations import _check_minimum_version, Integration, DidNotEnable from sentry_sdk.integrations._wsgi_common import RequestExtractor from sentry_sdk.integrations.wsgi import SentryWsgiMiddleware from sentry_sdk.tracing import SOURCE_FOR_STYLE from sentry_sdk.utils import ( capture_internal_exceptions, + ensure_integration_enabled, event_from_exception, parse_version, ) -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any from typing import Dict from typing import Optional - from sentry_sdk._types import EventProcessor + from sentry_sdk._types import Event, EventProcessor # In Falcon 3.0 `falcon.api_helpers` is renamed to `falcon.app_helpers` # and `falcon.API` to `falcon.App` @@ -44,26 +43,26 @@ FALCON3 = False +_FALCON_UNSET: "Optional[object]" = None +if FALCON3: # falcon.request._UNSET is only available in Falcon 3.0+ + with capture_internal_exceptions(): + from falcon.request import _UNSET as _FALCON_UNSET # type: ignore[import-not-found, no-redef] + + class FalconRequestExtractor(RequestExtractor): - def env(self): - # type: () -> Dict[str, Any] + def env(self) -> "Dict[str, Any]": return self.request.env - def cookies(self): - # type: () -> Dict[str, Any] + def cookies(self) -> "Dict[str, Any]": return self.request.cookies - def form(self): - # type: () -> None + def form(self) -> None: return None # No such concept in Falcon - def files(self): - # type: () -> None + def files(self) -> None: return None # No such concept in Falcon - def raw_data(self): - # type: () -> Optional[str] - + def raw_data(self) -> "Optional[str]": # As request data can only be read once we won't make this available # to Sentry. Just send back a dummy string in case there was a # content length. @@ -74,42 +73,37 @@ def raw_data(self): else: return None - if FALCON3: - - def json(self): - # type: () -> Optional[Dict[str, Any]] - try: - return self.request.media - except falcon.errors.HTTPBadRequest: - return None + def json(self) -> "Optional[Dict[str, Any]]": + # fallback to cached_media = None if self.request._media is not available + cached_media = None + with capture_internal_exceptions(): + # self.request._media is the cached self.request.media + # value. It is only available if self.request.media + # has already been accessed. Therefore, reading + # self.request._media will not exhaust the raw request + # stream (self.request.bounded_stream) because it has + # already been read if self.request._media is set. + cached_media = self.request._media - else: + if cached_media is not _FALCON_UNSET: + return cached_media - def json(self): - # type: () -> Optional[Dict[str, Any]] - try: - return self.request.media - except falcon.errors.HTTPBadRequest: - # NOTE(jmagnusson): We return `falcon.Request._media` here because - # falcon 1.4 doesn't do proper type checking in - # `falcon.Request.media`. This has been fixed in 2.0. - # Relevant code: https://github.com/falconry/falcon/blob/1.4.1/falcon/request.py#L953 - return self.request._media + return None -class SentryFalconMiddleware(object): +class SentryFalconMiddleware: """Captures exceptions in Falcon requests and send to Sentry""" - def process_request(self, req, resp, *args, **kwargs): - # type: (Any, Any, *Any, **Any) -> None - hub = Hub.current - integration = hub.get_integration(FalconIntegration) + def process_request( + self, req: "Any", resp: "Any", *args: "Any", **kwargs: "Any" + ) -> None: + integration = sentry_sdk.get_client().get_integration(FalconIntegration) if integration is None: return - with hub.configure_scope() as scope: - scope._name = "falcon" - scope.add_event_processor(_make_request_event_processor(req, integration)) + scope = sentry_sdk.get_isolation_scope() + scope._name = "falcon" + scope.add_event_processor(_make_request_event_processor(req, integration)) TRANSACTION_STYLE_VALUES = ("uri_template", "path") @@ -117,11 +111,11 @@ def process_request(self, req, resp, *args, **kwargs): class FalconIntegration(Integration): identifier = "falcon" + origin = f"auto.http.{identifier}" transaction_style = "" - def __init__(self, transaction_style="uri_template"): - # type: (str) -> None + def __init__(self, transaction_style: str = "uri_template") -> None: if transaction_style not in TRANSACTION_STYLE_VALUES: raise ValueError( "Invalid value for transaction_style: %s (must be in %s)" @@ -130,35 +124,28 @@ def __init__(self, transaction_style="uri_template"): self.transaction_style = transaction_style @staticmethod - def setup_once(): - # type: () -> None - + def setup_once() -> None: version = parse_version(FALCON_VERSION) - - if version is None: - raise DidNotEnable("Unparsable Falcon version: {}".format(FALCON_VERSION)) - - if version < (1, 4): - raise DidNotEnable("Falcon 1.4 or newer required.") + _check_minimum_version(FalconIntegration, version) _patch_wsgi_app() _patch_handle_exception() _patch_prepare_middleware() -def _patch_wsgi_app(): - # type: () -> None +def _patch_wsgi_app() -> None: original_wsgi_app = falcon_app_class.__call__ - def sentry_patched_wsgi_app(self, env, start_response): - # type: (falcon.API, Any, Any) -> Any - hub = Hub.current - integration = hub.get_integration(FalconIntegration) + def sentry_patched_wsgi_app( + self: "falcon.API", env: "Any", start_response: "Any" + ) -> "Any": + integration = sentry_sdk.get_client().get_integration(FalconIntegration) if integration is None: return original_wsgi_app(self, env, start_response) sentry_wrapped = SentryWsgiMiddleware( - lambda envi, start_resp: original_wsgi_app(self, envi, start_resp) + lambda envi, start_resp: original_wsgi_app(self, envi, start_resp), + span_origin=FalconIntegration.origin, ) return sentry_wrapped(env, start_response) @@ -166,55 +153,55 @@ def sentry_patched_wsgi_app(self, env, start_response): falcon_app_class.__call__ = sentry_patched_wsgi_app -def _patch_handle_exception(): - # type: () -> None +def _patch_handle_exception() -> None: original_handle_exception = falcon_app_class._handle_exception - def sentry_patched_handle_exception(self, *args): - # type: (falcon.API, *Any) -> Any + @ensure_integration_enabled(FalconIntegration, original_handle_exception) + def sentry_patched_handle_exception(self: "falcon.API", *args: "Any") -> "Any": # NOTE(jmagnusson): falcon 2.0 changed falcon.API._handle_exception # method signature from `(ex, req, resp, params)` to # `(req, resp, ex, params)` - if isinstance(args[0], Exception): - ex = args[0] - else: - ex = args[2] + ex = response = None + with capture_internal_exceptions(): + ex = next(argument for argument in args if isinstance(argument, Exception)) + response = next( + argument for argument in args if isinstance(argument, falcon.Response) + ) was_handled = original_handle_exception(self, *args) - hub = Hub.current - integration = hub.get_integration(FalconIntegration) - - if integration is not None and _exception_leads_to_http_5xx(ex): - # If an integration is there, a client has to be there. - client = hub.client # type: Any + if ex is None or response is None: + # Both ex and response should have a non-None value at this point; otherwise, + # there is an error with the SDK that will have been captured in the + # capture_internal_exceptions block above. + return was_handled + if _exception_leads_to_http_5xx(ex, response): event, hint = event_from_exception( ex, - client_options=client.options, + client_options=sentry_sdk.get_client().options, mechanism={"type": "falcon", "handled": False}, ) - hub.capture_event(event, hint=hint) + sentry_sdk.capture_event(event, hint=hint) return was_handled falcon_app_class._handle_exception = sentry_patched_handle_exception -def _patch_prepare_middleware(): - # type: () -> None +def _patch_prepare_middleware() -> None: original_prepare_middleware = falcon_helpers.prepare_middleware def sentry_patched_prepare_middleware( - middleware=None, independent_middleware=False, asgi=False - ): - # type: (Any, Any, bool) -> Any + middleware: "Any" = None, + independent_middleware: "Any" = False, + asgi: bool = False, + ) -> "Any": if asgi: # We don't support ASGI Falcon apps, so we don't patch anything here return original_prepare_middleware(middleware, independent_middleware, asgi) - hub = Hub.current - integration = hub.get_integration(FalconIntegration) + integration = sentry_sdk.get_client().get_integration(FalconIntegration) if integration is not None: middleware = [SentryFalconMiddleware()] + (middleware or []) @@ -225,19 +212,31 @@ def sentry_patched_prepare_middleware( falcon_helpers.prepare_middleware = sentry_patched_prepare_middleware -def _exception_leads_to_http_5xx(ex): - # type: (Exception) -> bool +def _exception_leads_to_http_5xx(ex: Exception, response: "falcon.Response") -> bool: is_server_error = isinstance(ex, falcon.HTTPError) and (ex.status or "").startswith( "5" ) is_unhandled_error = not isinstance( ex, (falcon.HTTPError, falcon.http_status.HTTPStatus) ) - return is_server_error or is_unhandled_error + # We only check the HTTP status on Falcon 3 because in Falcon 2, the status on the response + # at the stage where we capture it is listed as 200, even though we would expect to see a 500 + # status. Since at the time of this change, Falcon 2 is ca. 4 years old, we have decided to + # only perform this check on Falcon 3+, despite the risk that some handled errors might be + # reported to Sentry as unhandled on Falcon 2. + return (is_server_error or is_unhandled_error) and ( + not FALCON3 or _has_http_5xx_status(response) + ) -def _set_transaction_name_and_source(event, transaction_style, request): - # type: (Dict[str, Any], str, falcon.Request) -> None + +def _has_http_5xx_status(response: "falcon.Response") -> bool: + return response.status.startswith("5") + + +def _set_transaction_name_and_source( + event: "Event", transaction_style: str, request: "falcon.Request" +) -> None: name_for_style = { "uri_template": request.uri_template, "path": request.path, @@ -246,11 +245,10 @@ def _set_transaction_name_and_source(event, transaction_style, request): event["transaction_info"] = {"source": SOURCE_FOR_STYLE[transaction_style]} -def _make_request_event_processor(req, integration): - # type: (falcon.Request, FalconIntegration) -> EventProcessor - - def event_processor(event, hint): - # type: (Dict[str, Any], Dict[str, Any]) -> Dict[str, Any] +def _make_request_event_processor( + req: "falcon.Request", integration: "FalconIntegration" +) -> "EventProcessor": + def event_processor(event: "Event", hint: "dict[str, Any]") -> "Event": _set_transaction_name_and_source(event, integration.transaction_style, req) with capture_internal_exceptions(): diff --git a/sentry_sdk/integrations/fastapi.py b/sentry_sdk/integrations/fastapi.py index 11c9bdcf51..66f73ea4e0 100644 --- a/sentry_sdk/integrations/fastapi.py +++ b/sentry_sdk/integrations/fastapi.py @@ -1,15 +1,18 @@ import asyncio from copy import deepcopy +from functools import wraps -from sentry_sdk._types import TYPE_CHECKING -from sentry_sdk.hub import Hub, _should_send_default_pii +import sentry_sdk from sentry_sdk.integrations import DidNotEnable -from sentry_sdk.tracing import SOURCE_FOR_STYLE, TRANSACTION_SOURCE_ROUTE -from sentry_sdk.utils import transaction_from_function, logger +from sentry_sdk.scope import should_send_default_pii +from sentry_sdk.tracing import SOURCE_FOR_STYLE, TransactionSource +from sentry_sdk.utils import transaction_from_function + +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any, Callable, Dict - from sentry_sdk.scope import Scope + from sentry_sdk._types import Event try: from sentry_sdk.integrations.starlette import ( @@ -32,13 +35,13 @@ class FastApiIntegration(StarletteIntegration): identifier = "fastapi" @staticmethod - def setup_once(): - # type: () -> None + def setup_once() -> None: patch_get_request_handler() -def _set_transaction_name_and_source(scope, transaction_style, request): - # type: (Scope, str, Any) -> None +def _set_transaction_name_and_source( + scope: "sentry_sdk.Scope", transaction_style: str, request: "Any" +) -> None: name = "" if transaction_style == "endpoint": @@ -55,22 +58,17 @@ def _set_transaction_name_and_source(scope, transaction_style, request): if not name: name = _DEFAULT_TRANSACTION_NAME - source = TRANSACTION_SOURCE_ROUTE + source = TransactionSource.ROUTE else: source = SOURCE_FOR_STYLE[transaction_style] scope.set_transaction_name(name, source=source) - logger.debug( - "[FastAPI] Set transaction name and source on scope: %s / %s", name, source - ) -def patch_get_request_handler(): - # type: () -> None +def patch_get_request_handler() -> None: old_get_request_handler = fastapi.routing.get_request_handler - def _sentry_get_request_handler(*args, **kwargs): - # type: (*Any, **Any) -> Any + def _sentry_get_request_handler(*args: "Any", **kwargs: "Any") -> "Any": dependant = kwargs.get("dependant") if ( dependant @@ -79,57 +77,57 @@ def _sentry_get_request_handler(*args, **kwargs): ): old_call = dependant.call - def _sentry_call(*args, **kwargs): - # type: (*Any, **Any) -> Any - hub = Hub.current - with hub.configure_scope() as sentry_scope: - if sentry_scope.profile is not None: - sentry_scope.profile.update_active_thread_id() - return old_call(*args, **kwargs) + @wraps(old_call) + def _sentry_call(*args: "Any", **kwargs: "Any") -> "Any": + current_scope = sentry_sdk.get_current_scope() + if current_scope.transaction is not None: + current_scope.transaction.update_active_thread() + + sentry_scope = sentry_sdk.get_isolation_scope() + if sentry_scope.profile is not None: + sentry_scope.profile.update_active_thread_id() + + return old_call(*args, **kwargs) dependant.call = _sentry_call old_app = old_get_request_handler(*args, **kwargs) - async def _sentry_app(*args, **kwargs): - # type: (*Any, **Any) -> Any - hub = Hub.current - integration = hub.get_integration(FastApiIntegration) + async def _sentry_app(*args: "Any", **kwargs: "Any") -> "Any": + integration = sentry_sdk.get_client().get_integration(FastApiIntegration) if integration is None: return await old_app(*args, **kwargs) - with hub.configure_scope() as sentry_scope: - request = args[0] - - _set_transaction_name_and_source( - sentry_scope, integration.transaction_style, request - ) - - extractor = StarletteRequestExtractor(request) - info = await extractor.extract_request_info() - - def _make_request_event_processor(req, integration): - # type: (Any, Any) -> Callable[[Dict[str, Any], Dict[str, Any]], Dict[str, Any]] - def event_processor(event, hint): - # type: (Dict[str, Any], Dict[str, Any]) -> Dict[str, Any] - - # Extract information from request - request_info = event.get("request", {}) - if info: - if "cookies" in info and _should_send_default_pii(): - request_info["cookies"] = info["cookies"] - if "data" in info: - request_info["data"] = info["data"] - event["request"] = deepcopy(request_info) - - return event - - return event_processor - - sentry_scope._name = FastApiIntegration.identifier - sentry_scope.add_event_processor( - _make_request_event_processor(request, integration) - ) + request = args[0] + + _set_transaction_name_and_source( + sentry_sdk.get_current_scope(), integration.transaction_style, request + ) + sentry_scope = sentry_sdk.get_isolation_scope() + extractor = StarletteRequestExtractor(request) + info = await extractor.extract_request_info() + + def _make_request_event_processor( + req: "Any", integration: "Any" + ) -> "Callable[[Event, Dict[str, Any]], Event]": + def event_processor(event: "Event", hint: "Dict[str, Any]") -> "Event": + # Extract information from request + request_info = event.get("request", {}) + if info: + if "cookies" in info and should_send_default_pii(): + request_info["cookies"] = info["cookies"] + if "data" in info: + request_info["data"] = info["data"] + event["request"] = deepcopy(request_info) + + return event + + return event_processor + + sentry_scope._name = FastApiIntegration.identifier + sentry_scope.add_event_processor( + _make_request_event_processor(request, integration) + ) return await old_app(*args, **kwargs) diff --git a/sentry_sdk/integrations/flask.py b/sentry_sdk/integrations/flask.py index 0da411c23d..9adf8d51e8 100644 --- a/sentry_sdk/integrations/flask.py +++ b/sentry_sdk/integrations/flask.py @@ -1,23 +1,25 @@ -from __future__ import absolute_import - -from sentry_sdk._types import TYPE_CHECKING -from sentry_sdk.hub import Hub, _should_send_default_pii -from sentry_sdk.integrations import DidNotEnable, Integration -from sentry_sdk.integrations._wsgi_common import RequestExtractor +import sentry_sdk +from sentry_sdk.integrations import _check_minimum_version, DidNotEnable, Integration +from sentry_sdk.integrations._wsgi_common import ( + DEFAULT_HTTP_METHODS_TO_CAPTURE, + RequestExtractor, +) from sentry_sdk.integrations.wsgi import SentryWsgiMiddleware -from sentry_sdk.integrations.modules import _get_installed_modules -from sentry_sdk.scope import Scope +from sentry_sdk.scope import should_send_default_pii from sentry_sdk.tracing import SOURCE_FOR_STYLE from sentry_sdk.utils import ( capture_internal_exceptions, + ensure_integration_enabled, event_from_exception, - parse_version, + package_version, ) +from typing import TYPE_CHECKING + if TYPE_CHECKING: from typing import Any, Callable, Dict, Union - from sentry_sdk._types import EventProcessor + from sentry_sdk._types import Event, EventProcessor from sentry_sdk.integrations.wsgi import _ScopedResponse from werkzeug.datastructures import FileStorage, ImmutableMultiDict @@ -49,31 +51,39 @@ class FlaskIntegration(Integration): identifier = "flask" + origin = f"auto.http.{identifier}" transaction_style = "" - def __init__(self, transaction_style="endpoint"): - # type: (str) -> None + def __init__( + self, + transaction_style: str = "endpoint", + http_methods_to_capture: "tuple[str, ...]" = DEFAULT_HTTP_METHODS_TO_CAPTURE, + ) -> None: if transaction_style not in TRANSACTION_STYLE_VALUES: raise ValueError( "Invalid value for transaction_style: %s (must be in %s)" % (transaction_style, TRANSACTION_STYLE_VALUES) ) self.transaction_style = transaction_style + self.http_methods_to_capture = tuple(map(str.upper, http_methods_to_capture)) @staticmethod - def setup_once(): - # type: () -> None - - installed_packages = _get_installed_modules() - flask_version = installed_packages["flask"] - version = parse_version(flask_version) - - if version is None: - raise DidNotEnable("Unparsable Flask version: {}".format(flask_version)) + def setup_once() -> None: + try: + from quart import Quart # type: ignore + + if Flask == Quart: + # This is Quart masquerading as Flask, don't enable the Flask + # integration. See https://github.com/getsentry/sentry-python/issues/2709 + raise DidNotEnable( + "This is not a Flask app but rather Quart pretending to be Flask" + ) + except ImportError: + pass - if version < (0, 10): - raise DidNotEnable("Flask 0.10 or newer is required.") + version = package_version("flask") + _check_minimum_version(FlaskIntegration, version) before_render_template.connect(_add_sentry_trace) request_started.connect(_request_started) @@ -81,31 +91,43 @@ def setup_once(): old_app = Flask.__call__ - def sentry_patched_wsgi_app(self, environ, start_response): - # type: (Any, Dict[str, str], Callable[..., Any]) -> _ScopedResponse - if Hub.current.get_integration(FlaskIntegration) is None: + def sentry_patched_wsgi_app( + self: "Any", environ: "Dict[str, str]", start_response: "Callable[..., Any]" + ) -> "_ScopedResponse": + if sentry_sdk.get_client().get_integration(FlaskIntegration) is None: return old_app(self, environ, start_response) - return SentryWsgiMiddleware(lambda *a, **kw: old_app(self, *a, **kw))( - environ, start_response + integration = sentry_sdk.get_client().get_integration(FlaskIntegration) + + middleware = SentryWsgiMiddleware( + lambda *a, **kw: old_app(self, *a, **kw), + span_origin=FlaskIntegration.origin, + http_methods_to_capture=( + integration.http_methods_to_capture + if integration + else DEFAULT_HTTP_METHODS_TO_CAPTURE + ), ) + return middleware(environ, start_response) Flask.__call__ = sentry_patched_wsgi_app -def _add_sentry_trace(sender, template, context, **extra): - # type: (Flask, Any, Dict[str, Any], **Any) -> None +def _add_sentry_trace( + sender: "Flask", template: "Any", context: "Dict[str, Any]", **extra: "Any" +) -> None: if "sentry_trace" in context: return - hub = Hub.current - trace_meta = Markup(hub.trace_propagation_meta()) + scope = sentry_sdk.get_current_scope() + trace_meta = Markup(scope.trace_propagation_meta()) context["sentry_trace"] = trace_meta # for backwards compatibility context["sentry_trace_meta"] = trace_meta -def _set_transaction_name_and_source(scope, transaction_style, request): - # type: (Scope, str, Request) -> None +def _set_transaction_name_and_source( + scope: "sentry_sdk.Scope", transaction_style: str, request: "Request" +) -> None: try: name_for_style = { "url": request.url_rule.rule, @@ -119,65 +141,57 @@ def _set_transaction_name_and_source(scope, transaction_style, request): pass -def _request_started(app, **kwargs): - # type: (Flask, **Any) -> None - hub = Hub.current - integration = hub.get_integration(FlaskIntegration) +def _request_started(app: "Flask", **kwargs: "Any") -> None: + integration = sentry_sdk.get_client().get_integration(FlaskIntegration) if integration is None: return - with hub.configure_scope() as scope: - # Set the transaction name and source here, - # but rely on WSGI middleware to actually start the transaction - request = flask_request._get_current_object() - _set_transaction_name_and_source(scope, integration.transaction_style, request) - evt_processor = _make_request_event_processor(app, request, integration) - scope.add_event_processor(evt_processor) + request = flask_request._get_current_object() + + # Set the transaction name and source here, + # but rely on WSGI middleware to actually start the transaction + _set_transaction_name_and_source( + sentry_sdk.get_current_scope(), integration.transaction_style, request + ) + + scope = sentry_sdk.get_isolation_scope() + evt_processor = _make_request_event_processor(app, request, integration) + scope.add_event_processor(evt_processor) class FlaskRequestExtractor(RequestExtractor): - def env(self): - # type: () -> Dict[str, str] + def env(self) -> "Dict[str, str]": return self.request.environ - def cookies(self): - # type: () -> Dict[Any, Any] + def cookies(self) -> "Dict[Any, Any]": return { k: v[0] if isinstance(v, list) and len(v) == 1 else v for k, v in self.request.cookies.items() } - def raw_data(self): - # type: () -> bytes + def raw_data(self) -> bytes: return self.request.get_data() - def form(self): - # type: () -> ImmutableMultiDict[str, Any] + def form(self) -> "ImmutableMultiDict[str, Any]": return self.request.form - def files(self): - # type: () -> ImmutableMultiDict[str, Any] + def files(self) -> "ImmutableMultiDict[str, Any]": return self.request.files - def is_json(self): - # type: () -> bool + def is_json(self) -> bool: return self.request.is_json - def json(self): - # type: () -> Any + def json(self) -> "Any": return self.request.get_json(silent=True) - def size_of_file(self, file): - # type: (FileStorage) -> int + def size_of_file(self, file: "FileStorage") -> int: return file.content_length -def _make_request_event_processor(app, request, integration): - # type: (Flask, Callable[[], Request], FlaskIntegration) -> EventProcessor - - def inner(event, hint): - # type: (Dict[str, Any], Dict[str, Any]) -> Dict[str, Any] - +def _make_request_event_processor( + app: "Flask", request: "Callable[[], Request]", integration: "FlaskIntegration" +) -> "EventProcessor": + def inner(event: "Event", hint: "dict[str, Any]") -> "Event": # if the request is gone we are fine not logging the data from # it. This might happen if the processor is pushed away to # another thread. @@ -187,7 +201,7 @@ def inner(event, hint): with capture_internal_exceptions(): FlaskRequestExtractor(request).extract_into_event(event) - if _should_send_default_pii(): + if should_send_default_pii(): with capture_internal_exceptions(): _add_user_to_event(event) @@ -196,26 +210,20 @@ def inner(event, hint): return inner -def _capture_exception(sender, exception, **kwargs): - # type: (Flask, Union[ValueError, BaseException], **Any) -> None - hub = Hub.current - if hub.get_integration(FlaskIntegration) is None: - return - - # If an integration is there, a client has to be there. - client = hub.client # type: Any - +@ensure_integration_enabled(FlaskIntegration) +def _capture_exception( + sender: "Flask", exception: "Union[ValueError, BaseException]", **kwargs: "Any" +) -> None: event, hint = event_from_exception( exception, - client_options=client.options, + client_options=sentry_sdk.get_client().options, mechanism={"type": "flask", "handled": False}, ) - hub.capture_event(event, hint=hint) + sentry_sdk.capture_event(event, hint=hint) -def _add_user_to_event(event): - # type: (Dict[str, Any]) -> None +def _add_user_to_event(event: "Event") -> None: if flask_login is None: return diff --git a/sentry_sdk/integrations/gcp.py b/sentry_sdk/integrations/gcp.py index 5f771c95c6..994d38f932 100644 --- a/sentry_sdk/integrations/gcp.py +++ b/sentry_sdk/integrations/gcp.py @@ -1,31 +1,32 @@ +import functools import sys from copy import deepcopy -from datetime import timedelta +from datetime import datetime, timedelta, timezone from os import environ +import sentry_sdk from sentry_sdk.api import continue_trace from sentry_sdk.consts import OP -from sentry_sdk.hub import Hub, _should_send_default_pii -from sentry_sdk.tracing import TRANSACTION_SOURCE_COMPONENT -from sentry_sdk._compat import datetime_utcnow, reraise +from sentry_sdk.integrations import Integration +from sentry_sdk.integrations._wsgi_common import _filter_headers +from sentry_sdk.scope import should_send_default_pii +from sentry_sdk.tracing import TransactionSource from sentry_sdk.utils import ( AnnotatedValue, capture_internal_exceptions, event_from_exception, logger, TimeoutThread, + reraise, ) -from sentry_sdk.integrations import Integration -from sentry_sdk.integrations._wsgi_common import _filter_headers -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING # Constants TIMEOUT_WARNING_BUFFER = 1.5 # Buffer time required to send timeout warning to Sentry MILLIS_TO_SECONDS = 1000.0 if TYPE_CHECKING: - from datetime import datetime from typing import Any from typing import TypeVar from typing import Callable @@ -36,19 +37,17 @@ F = TypeVar("F", bound=Callable[..., Any]) -def _wrap_func(func): - # type: (F) -> F - def sentry_func(functionhandler, gcp_event, *args, **kwargs): - # type: (Any, Any, *Any, **Any) -> Any +def _wrap_func(func: "F") -> "F": + @functools.wraps(func) + def sentry_func( + functionhandler: "Any", gcp_event: "Any", *args: "Any", **kwargs: "Any" + ) -> "Any": + client = sentry_sdk.get_client() - hub = Hub.current - integration = hub.get_integration(GcpIntegration) + integration = client.get_integration(GcpIntegration) if integration is None: return func(functionhandler, gcp_event, *args, **kwargs) - # If an integration is there, a client has to be there. - client = hub.client # type: Any - configured_time = environ.get("FUNCTION_TIMEOUT_SEC") if not configured_time: logger.debug( @@ -58,9 +57,9 @@ def sentry_func(functionhandler, gcp_event, *args, **kwargs): configured_time = int(configured_time) - initial_time = datetime_utcnow() + initial_time = datetime.now(timezone.utc) - with hub.push_scope() as scope: + with sentry_sdk.isolation_scope() as scope: with capture_internal_exceptions(): scope.clear_breadcrumbs() scope.add_event_processor( @@ -76,7 +75,12 @@ def sentry_func(functionhandler, gcp_event, *args, **kwargs): ): waiting_time = configured_time - TIMEOUT_WARNING_BUFFER - timeout_thread = TimeoutThread(waiting_time, configured_time) + timeout_thread = TimeoutThread( + waiting_time, + configured_time, + isolation_scope=scope, + current_scope=sentry_sdk.get_current_scope(), + ) # Starting the thread to raise timeout warning exception timeout_thread.start() @@ -89,7 +93,8 @@ def sentry_func(functionhandler, gcp_event, *args, **kwargs): headers, op=OP.FUNCTION_GCP, name=environ.get("FUNCTION_NAME", ""), - source=TRANSACTION_SOURCE_COMPONENT, + source=TransactionSource.COMPONENT, + origin=GcpIntegration.origin, ) sampling_context = { "gcp_env": { @@ -101,7 +106,7 @@ def sentry_func(functionhandler, gcp_event, *args, **kwargs): }, "gcp_event": gcp_event, } - with hub.start_transaction( + with sentry_sdk.start_transaction( transaction, custom_sampling_context=sampling_context ): try: @@ -113,27 +118,26 @@ def sentry_func(functionhandler, gcp_event, *args, **kwargs): client_options=client.options, mechanism={"type": "gcp", "handled": False}, ) - hub.capture_event(sentry_event, hint=hint) + sentry_sdk.capture_event(sentry_event, hint=hint) reraise(*exc_info) finally: if timeout_thread: timeout_thread.stop() # Flush out the event queue - hub.flush() + client.flush() return sentry_func # type: ignore class GcpIntegration(Integration): identifier = "gcp" + origin = f"auto.function.{identifier}" - def __init__(self, timeout_warning=False): - # type: (bool) -> None + def __init__(self, timeout_warning: bool = False) -> None: self.timeout_warning = timeout_warning @staticmethod - def setup_once(): - # type: () -> None + def setup_once() -> None: import __main__ as gcp_functions if not hasattr(gcp_functions, "worker_v1"): @@ -149,16 +153,14 @@ def setup_once(): ) -def _make_request_event_processor(gcp_event, configured_timeout, initial_time): - # type: (Any, Any, Any) -> EventProcessor - - def event_processor(event, hint): - # type: (Event, Hint) -> Optional[Event] - - final_time = datetime_utcnow() +def _make_request_event_processor( + gcp_event: "Any", configured_timeout: "Any", initial_time: "Any" +) -> "EventProcessor": + def event_processor(event: "Event", hint: "Hint") -> "Optional[Event]": + final_time = datetime.now(timezone.utc) time_diff = final_time - initial_time - execution_duration_in_millis = time_diff.microseconds / MILLIS_TO_SECONDS + execution_duration_in_millis = time_diff / timedelta(milliseconds=1) extra = event.setdefault("extra", {}) extra["google cloud functions"] = { @@ -188,7 +190,7 @@ def event_processor(event, hint): if hasattr(gcp_event, "headers"): request["headers"] = _filter_headers(gcp_event.headers) - if _should_send_default_pii(): + if should_send_default_pii(): if hasattr(gcp_event, "data"): request["data"] = gcp_event.data else: @@ -204,8 +206,7 @@ def event_processor(event, hint): return event_processor -def _get_google_cloud_logs_url(final_time): - # type: (datetime) -> str +def _get_google_cloud_logs_url(final_time: "datetime") -> str: """ Generates a Google Cloud Logs console URL based on the environment variables Arguments: diff --git a/sentry_sdk/integrations/gnu_backtrace.py b/sentry_sdk/integrations/gnu_backtrace.py index ad9c437878..dbadf42088 100644 --- a/sentry_sdk/integrations/gnu_backtrace.py +++ b/sentry_sdk/integrations/gnu_backtrace.py @@ -1,34 +1,26 @@ import re -from sentry_sdk.hub import Hub +import sentry_sdk from sentry_sdk.integrations import Integration from sentry_sdk.scope import add_global_event_processor from sentry_sdk.utils import capture_internal_exceptions -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any - from typing import Dict - - -MODULE_RE = r"[a-zA-Z0-9/._:\\-]+" -TYPE_RE = r"[a-zA-Z0-9._:<>,-]+" -HEXVAL_RE = r"[A-Fa-f0-9]+" + from sentry_sdk._types import Event +# function is everything between index at @ +# and then we match on the @ plus the hex val +FUNCTION_RE = r"[^@]+?" +HEX_ADDRESS = r"\s+@\s+0x[0-9a-fA-F]+" FRAME_RE = r""" -^(?P\d+)\.\s -(?P{MODULE_RE})\( - (?P{TYPE_RE}\ )? - ((?P{TYPE_RE}) - (?P\(.*\))? - )? - ((?P\ const)?\+0x(?P{HEXVAL_RE}))? -\)\s -\[0x(?P{HEXVAL_RE})\]$ +^(?P\d+)\.\s+(?P{FUNCTION_RE}){HEX_ADDRESS}(?:\s+in\s+(?P.+))?$ """.format( - MODULE_RE=MODULE_RE, HEXVAL_RE=HEXVAL_RE, TYPE_RE=TYPE_RE + FUNCTION_RE=FUNCTION_RE, + HEX_ADDRESS=HEX_ADDRESS, ) FRAME_RE = re.compile(FRAME_RE, re.MULTILINE | re.VERBOSE) @@ -38,18 +30,15 @@ class GnuBacktraceIntegration(Integration): identifier = "gnu_backtrace" @staticmethod - def setup_once(): - # type: () -> None + def setup_once() -> None: @add_global_event_processor - def process_gnu_backtrace(event, hint): - # type: (Dict[str, Any], Dict[str, Any]) -> Dict[str, Any] + def process_gnu_backtrace(event: "Event", hint: "dict[str, Any]") -> "Event": with capture_internal_exceptions(): return _process_gnu_backtrace(event, hint) -def _process_gnu_backtrace(event, hint): - # type: (Dict[str, Any], Dict[str, Any]) -> Dict[str, Any] - if Hub.current.get_integration(GnuBacktraceIntegration) is None: +def _process_gnu_backtrace(event: "Event", hint: "dict[str, Any]") -> "Event": + if sentry_sdk.get_client().get_integration(GnuBacktraceIntegration) is None: return event exc_info = hint.get("exc_info", None) diff --git a/sentry_sdk/integrations/google_genai/__init__.py b/sentry_sdk/integrations/google_genai/__init__.py new file mode 100644 index 0000000000..27a42f4f6a --- /dev/null +++ b/sentry_sdk/integrations/google_genai/__init__.py @@ -0,0 +1,370 @@ +from functools import wraps +from typing import ( + Any, + AsyncIterator, + Callable, + Iterator, + List, +) + +import sentry_sdk +from sentry_sdk.ai.utils import get_start_span_function +from sentry_sdk.integrations import DidNotEnable, Integration +from sentry_sdk.consts import OP, SPANDATA +from sentry_sdk.tracing import SPANSTATUS + + +try: + from google.genai.models import Models, AsyncModels +except ImportError: + raise DidNotEnable("google-genai not installed") + + +from .consts import IDENTIFIER, ORIGIN, GEN_AI_SYSTEM +from .utils import ( + set_span_data_for_request, + set_span_data_for_response, + _capture_exception, + prepare_generate_content_args, + prepare_embed_content_args, + set_span_data_for_embed_request, + set_span_data_for_embed_response, +) +from .streaming import ( + set_span_data_for_streaming_response, + accumulate_streaming_response, +) + + +class GoogleGenAIIntegration(Integration): + identifier = IDENTIFIER + origin = ORIGIN + + def __init__(self: "GoogleGenAIIntegration", include_prompts: bool = True) -> None: + self.include_prompts = include_prompts + + @staticmethod + def setup_once() -> None: + # Patch sync methods + Models.generate_content = _wrap_generate_content(Models.generate_content) + Models.generate_content_stream = _wrap_generate_content_stream( + Models.generate_content_stream + ) + Models.embed_content = _wrap_embed_content(Models.embed_content) + + # Patch async methods + AsyncModels.generate_content = _wrap_async_generate_content( + AsyncModels.generate_content + ) + AsyncModels.generate_content_stream = _wrap_async_generate_content_stream( + AsyncModels.generate_content_stream + ) + AsyncModels.embed_content = _wrap_async_embed_content(AsyncModels.embed_content) + + +def _wrap_generate_content_stream(f: "Callable[..., Any]") -> "Callable[..., Any]": + @wraps(f) + def new_generate_content_stream( + self: "Any", *args: "Any", **kwargs: "Any" + ) -> "Any": + integration = sentry_sdk.get_client().get_integration(GoogleGenAIIntegration) + if integration is None: + return f(self, *args, **kwargs) + + _model, contents, model_name = prepare_generate_content_args(args, kwargs) + + span = get_start_span_function()( + op=OP.GEN_AI_INVOKE_AGENT, + name="invoke_agent", + origin=ORIGIN, + ) + span.__enter__() + span.set_data(SPANDATA.GEN_AI_AGENT_NAME, model_name) + span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "invoke_agent") + set_span_data_for_request(span, integration, model_name, contents, kwargs) + span.set_data(SPANDATA.GEN_AI_RESPONSE_STREAMING, True) + + chat_span = sentry_sdk.start_span( + op=OP.GEN_AI_CHAT, + name=f"chat {model_name}", + origin=ORIGIN, + ) + chat_span.__enter__() + chat_span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "chat") + chat_span.set_data(SPANDATA.GEN_AI_SYSTEM, GEN_AI_SYSTEM) + chat_span.set_data(SPANDATA.GEN_AI_REQUEST_MODEL, model_name) + set_span_data_for_request(chat_span, integration, model_name, contents, kwargs) + chat_span.set_data(SPANDATA.GEN_AI_RESPONSE_STREAMING, True) + chat_span.set_data(SPANDATA.GEN_AI_AGENT_NAME, model_name) + + try: + stream = f(self, *args, **kwargs) + + # Create wrapper iterator to accumulate responses + def new_iterator() -> "Iterator[Any]": + chunks: "List[Any]" = [] + try: + for chunk in stream: + chunks.append(chunk) + yield chunk + except Exception as exc: + _capture_exception(exc) + chat_span.set_status(SPANSTATUS.INTERNAL_ERROR) + raise + finally: + # Accumulate all chunks and set final response data on spans + if chunks: + accumulated_response = accumulate_streaming_response(chunks) + set_span_data_for_streaming_response( + chat_span, integration, accumulated_response + ) + set_span_data_for_streaming_response( + span, integration, accumulated_response + ) + chat_span.__exit__(None, None, None) + span.__exit__(None, None, None) + + return new_iterator() + + except Exception as exc: + _capture_exception(exc) + chat_span.__exit__(None, None, None) + span.__exit__(None, None, None) + raise + + return new_generate_content_stream + + +def _wrap_async_generate_content_stream( + f: "Callable[..., Any]", +) -> "Callable[..., Any]": + @wraps(f) + async def new_async_generate_content_stream( + self: "Any", *args: "Any", **kwargs: "Any" + ) -> "Any": + integration = sentry_sdk.get_client().get_integration(GoogleGenAIIntegration) + if integration is None: + return await f(self, *args, **kwargs) + + _model, contents, model_name = prepare_generate_content_args(args, kwargs) + + span = get_start_span_function()( + op=OP.GEN_AI_INVOKE_AGENT, + name="invoke_agent", + origin=ORIGIN, + ) + span.__enter__() + span.set_data(SPANDATA.GEN_AI_AGENT_NAME, model_name) + span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "invoke_agent") + set_span_data_for_request(span, integration, model_name, contents, kwargs) + span.set_data(SPANDATA.GEN_AI_RESPONSE_STREAMING, True) + + chat_span = sentry_sdk.start_span( + op=OP.GEN_AI_CHAT, + name=f"chat {model_name}", + origin=ORIGIN, + ) + chat_span.__enter__() + chat_span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "chat") + chat_span.set_data(SPANDATA.GEN_AI_SYSTEM, GEN_AI_SYSTEM) + chat_span.set_data(SPANDATA.GEN_AI_REQUEST_MODEL, model_name) + set_span_data_for_request(chat_span, integration, model_name, contents, kwargs) + chat_span.set_data(SPANDATA.GEN_AI_RESPONSE_STREAMING, True) + chat_span.set_data(SPANDATA.GEN_AI_AGENT_NAME, model_name) + + try: + stream = await f(self, *args, **kwargs) + + # Create wrapper async iterator to accumulate responses + async def new_async_iterator() -> "AsyncIterator[Any]": + chunks: "List[Any]" = [] + try: + async for chunk in stream: + chunks.append(chunk) + yield chunk + except Exception as exc: + _capture_exception(exc) + chat_span.set_status(SPANSTATUS.INTERNAL_ERROR) + raise + finally: + # Accumulate all chunks and set final response data on spans + if chunks: + accumulated_response = accumulate_streaming_response(chunks) + set_span_data_for_streaming_response( + chat_span, integration, accumulated_response + ) + set_span_data_for_streaming_response( + span, integration, accumulated_response + ) + chat_span.__exit__(None, None, None) + span.__exit__(None, None, None) + + return new_async_iterator() + + except Exception as exc: + _capture_exception(exc) + chat_span.__exit__(None, None, None) + span.__exit__(None, None, None) + raise + + return new_async_generate_content_stream + + +def _wrap_generate_content(f: "Callable[..., Any]") -> "Callable[..., Any]": + @wraps(f) + def new_generate_content(self: "Any", *args: "Any", **kwargs: "Any") -> "Any": + integration = sentry_sdk.get_client().get_integration(GoogleGenAIIntegration) + if integration is None: + return f(self, *args, **kwargs) + + model, contents, model_name = prepare_generate_content_args(args, kwargs) + + with get_start_span_function()( + op=OP.GEN_AI_INVOKE_AGENT, + name="invoke_agent", + origin=ORIGIN, + ) as span: + span.set_data(SPANDATA.GEN_AI_AGENT_NAME, model_name) + span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "invoke_agent") + set_span_data_for_request(span, integration, model_name, contents, kwargs) + + with sentry_sdk.start_span( + op=OP.GEN_AI_CHAT, + name=f"chat {model_name}", + origin=ORIGIN, + ) as chat_span: + chat_span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "chat") + chat_span.set_data(SPANDATA.GEN_AI_SYSTEM, GEN_AI_SYSTEM) + chat_span.set_data(SPANDATA.GEN_AI_REQUEST_MODEL, model_name) + chat_span.set_data(SPANDATA.GEN_AI_AGENT_NAME, model_name) + set_span_data_for_request( + chat_span, integration, model_name, contents, kwargs + ) + + try: + response = f(self, *args, **kwargs) + except Exception as exc: + _capture_exception(exc) + chat_span.set_status(SPANSTATUS.INTERNAL_ERROR) + raise + + set_span_data_for_response(chat_span, integration, response) + set_span_data_for_response(span, integration, response) + + return response + + return new_generate_content + + +def _wrap_async_generate_content(f: "Callable[..., Any]") -> "Callable[..., Any]": + @wraps(f) + async def new_async_generate_content( + self: "Any", *args: "Any", **kwargs: "Any" + ) -> "Any": + integration = sentry_sdk.get_client().get_integration(GoogleGenAIIntegration) + if integration is None: + return await f(self, *args, **kwargs) + + model, contents, model_name = prepare_generate_content_args(args, kwargs) + + with get_start_span_function()( + op=OP.GEN_AI_INVOKE_AGENT, + name="invoke_agent", + origin=ORIGIN, + ) as span: + span.set_data(SPANDATA.GEN_AI_AGENT_NAME, model_name) + span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "invoke_agent") + set_span_data_for_request(span, integration, model_name, contents, kwargs) + + with sentry_sdk.start_span( + op=OP.GEN_AI_CHAT, + name=f"chat {model_name}", + origin=ORIGIN, + ) as chat_span: + chat_span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "chat") + chat_span.set_data(SPANDATA.GEN_AI_SYSTEM, GEN_AI_SYSTEM) + chat_span.set_data(SPANDATA.GEN_AI_REQUEST_MODEL, model_name) + set_span_data_for_request( + chat_span, integration, model_name, contents, kwargs + ) + try: + response = await f(self, *args, **kwargs) + except Exception as exc: + _capture_exception(exc) + chat_span.set_status(SPANSTATUS.INTERNAL_ERROR) + raise + + set_span_data_for_response(chat_span, integration, response) + set_span_data_for_response(span, integration, response) + + return response + + return new_async_generate_content + + +def _wrap_embed_content(f: "Callable[..., Any]") -> "Callable[..., Any]": + @wraps(f) + def new_embed_content(self: "Any", *args: "Any", **kwargs: "Any") -> "Any": + integration = sentry_sdk.get_client().get_integration(GoogleGenAIIntegration) + if integration is None: + return f(self, *args, **kwargs) + + model_name, contents = prepare_embed_content_args(args, kwargs) + + with sentry_sdk.start_span( + op=OP.GEN_AI_EMBEDDINGS, + name=f"embeddings {model_name}", + origin=ORIGIN, + ) as span: + span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "embeddings") + span.set_data(SPANDATA.GEN_AI_SYSTEM, GEN_AI_SYSTEM) + span.set_data(SPANDATA.GEN_AI_REQUEST_MODEL, model_name) + set_span_data_for_embed_request(span, integration, contents, kwargs) + + try: + response = f(self, *args, **kwargs) + except Exception as exc: + _capture_exception(exc) + span.set_status(SPANSTATUS.INTERNAL_ERROR) + raise + + set_span_data_for_embed_response(span, integration, response) + + return response + + return new_embed_content + + +def _wrap_async_embed_content(f: "Callable[..., Any]") -> "Callable[..., Any]": + @wraps(f) + async def new_async_embed_content( + self: "Any", *args: "Any", **kwargs: "Any" + ) -> "Any": + integration = sentry_sdk.get_client().get_integration(GoogleGenAIIntegration) + if integration is None: + return await f(self, *args, **kwargs) + + model_name, contents = prepare_embed_content_args(args, kwargs) + + with sentry_sdk.start_span( + op=OP.GEN_AI_EMBEDDINGS, + name=f"embeddings {model_name}", + origin=ORIGIN, + ) as span: + span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "embeddings") + span.set_data(SPANDATA.GEN_AI_SYSTEM, GEN_AI_SYSTEM) + span.set_data(SPANDATA.GEN_AI_REQUEST_MODEL, model_name) + set_span_data_for_embed_request(span, integration, contents, kwargs) + + try: + response = await f(self, *args, **kwargs) + except Exception as exc: + _capture_exception(exc) + span.set_status(SPANSTATUS.INTERNAL_ERROR) + raise + + set_span_data_for_embed_response(span, integration, response) + + return response + + return new_async_embed_content diff --git a/sentry_sdk/integrations/google_genai/consts.py b/sentry_sdk/integrations/google_genai/consts.py new file mode 100644 index 0000000000..5b53ebf0e2 --- /dev/null +++ b/sentry_sdk/integrations/google_genai/consts.py @@ -0,0 +1,16 @@ +GEN_AI_SYSTEM = "gcp.gemini" + +# Mapping of tool attributes to their descriptions +# These are all tools that are available in the Google GenAI API +TOOL_ATTRIBUTES_MAP = { + "google_search_retrieval": "Google Search retrieval tool", + "google_search": "Google Search tool", + "retrieval": "Retrieval tool", + "enterprise_web_search": "Enterprise web search tool", + "google_maps": "Google Maps tool", + "code_execution": "Code execution tool", + "computer_use": "Computer use tool", +} + +IDENTIFIER = "google_genai" +ORIGIN = f"auto.ai.{IDENTIFIER}" diff --git a/sentry_sdk/integrations/google_genai/streaming.py b/sentry_sdk/integrations/google_genai/streaming.py new file mode 100644 index 0000000000..5bd8890d02 --- /dev/null +++ b/sentry_sdk/integrations/google_genai/streaming.py @@ -0,0 +1,157 @@ +from typing import ( + TYPE_CHECKING, + Any, + List, + TypedDict, + Optional, +) + +from sentry_sdk.ai.utils import set_data_normalized +from sentry_sdk.consts import SPANDATA +from sentry_sdk.scope import should_send_default_pii +from sentry_sdk.utils import ( + safe_serialize, +) +from .utils import ( + extract_tool_calls, + extract_finish_reasons, + extract_contents_text, + extract_usage_data, + UsageData, +) + +if TYPE_CHECKING: + from sentry_sdk.tracing import Span + from google.genai.types import GenerateContentResponse + + +class AccumulatedResponse(TypedDict): + id: "Optional[str]" + model: "Optional[str]" + text: str + finish_reasons: "List[str]" + tool_calls: "List[dict[str, Any]]" + usage_metadata: "UsageData" + + +def accumulate_streaming_response( + chunks: "List[GenerateContentResponse]", +) -> "AccumulatedResponse": + """Accumulate streaming chunks into a single response-like object.""" + accumulated_text = [] + finish_reasons = [] + tool_calls = [] + total_input_tokens = 0 + total_output_tokens = 0 + total_tokens = 0 + total_cached_tokens = 0 + total_reasoning_tokens = 0 + response_id = None + model = None + + for chunk in chunks: + # Extract text and tool calls + if getattr(chunk, "candidates", None): + for candidate in getattr(chunk, "candidates", []): + if hasattr(candidate, "content") and getattr( + candidate.content, "parts", [] + ): + extracted_text = extract_contents_text(candidate.content) + if extracted_text: + accumulated_text.append(extracted_text) + + extracted_finish_reasons = extract_finish_reasons(chunk) + if extracted_finish_reasons: + finish_reasons.extend(extracted_finish_reasons) + + extracted_tool_calls = extract_tool_calls(chunk) + if extracted_tool_calls: + tool_calls.extend(extracted_tool_calls) + + # Accumulate token usage + extracted_usage_data = extract_usage_data(chunk) + total_input_tokens += extracted_usage_data["input_tokens"] + total_output_tokens += extracted_usage_data["output_tokens"] + total_cached_tokens += extracted_usage_data["input_tokens_cached"] + total_reasoning_tokens += extracted_usage_data["output_tokens_reasoning"] + total_tokens += extracted_usage_data["total_tokens"] + + accumulated_response = AccumulatedResponse( + text="".join(accumulated_text), + finish_reasons=finish_reasons, + tool_calls=tool_calls, + usage_metadata=UsageData( + input_tokens=total_input_tokens, + output_tokens=total_output_tokens, + input_tokens_cached=total_cached_tokens, + output_tokens_reasoning=total_reasoning_tokens, + total_tokens=total_tokens, + ), + id=response_id, + model=model, + ) + + return accumulated_response + + +def set_span_data_for_streaming_response( + span: "Span", integration: "Any", accumulated_response: "AccumulatedResponse" +) -> None: + """Set span data for accumulated streaming response.""" + if ( + should_send_default_pii() + and integration.include_prompts + and accumulated_response.get("text") + ): + span.set_data( + SPANDATA.GEN_AI_RESPONSE_TEXT, + safe_serialize([accumulated_response["text"]]), + ) + + if accumulated_response.get("finish_reasons"): + set_data_normalized( + span, + SPANDATA.GEN_AI_RESPONSE_FINISH_REASONS, + accumulated_response["finish_reasons"], + ) + + if accumulated_response.get("tool_calls"): + span.set_data( + SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS, + safe_serialize(accumulated_response["tool_calls"]), + ) + + if accumulated_response.get("id"): + span.set_data(SPANDATA.GEN_AI_RESPONSE_ID, accumulated_response["id"]) + if accumulated_response.get("model"): + span.set_data(SPANDATA.GEN_AI_RESPONSE_MODEL, accumulated_response["model"]) + + if accumulated_response["usage_metadata"]["input_tokens"]: + span.set_data( + SPANDATA.GEN_AI_USAGE_INPUT_TOKENS, + accumulated_response["usage_metadata"]["input_tokens"], + ) + + if accumulated_response["usage_metadata"]["input_tokens_cached"]: + span.set_data( + SPANDATA.GEN_AI_USAGE_INPUT_TOKENS_CACHED, + accumulated_response["usage_metadata"]["input_tokens_cached"], + ) + + if accumulated_response["usage_metadata"]["output_tokens"]: + span.set_data( + SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS, + accumulated_response["usage_metadata"]["output_tokens"], + ) + + if accumulated_response["usage_metadata"]["output_tokens_reasoning"]: + span.set_data( + SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS_REASONING, + accumulated_response["usage_metadata"]["output_tokens_reasoning"], + ) + + if accumulated_response["usage_metadata"]["total_tokens"]: + span.set_data( + SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS, + accumulated_response["usage_metadata"]["total_tokens"], + ) diff --git a/sentry_sdk/integrations/google_genai/utils.py b/sentry_sdk/integrations/google_genai/utils.py new file mode 100644 index 0000000000..03423c385a --- /dev/null +++ b/sentry_sdk/integrations/google_genai/utils.py @@ -0,0 +1,649 @@ +import copy +import inspect +from functools import wraps +from .consts import ORIGIN, TOOL_ATTRIBUTES_MAP, GEN_AI_SYSTEM +from typing import ( + cast, + TYPE_CHECKING, + Iterable, + Any, + Callable, + List, + Optional, + Union, + TypedDict, +) + +import sentry_sdk +from sentry_sdk.ai.utils import ( + set_data_normalized, + truncate_and_annotate_messages, + normalize_message_roles, +) +from sentry_sdk.consts import OP, SPANDATA +from sentry_sdk.scope import should_send_default_pii +from sentry_sdk.utils import ( + capture_internal_exceptions, + event_from_exception, + safe_serialize, +) +from google.genai.types import GenerateContentConfig + +if TYPE_CHECKING: + from sentry_sdk.tracing import Span + from google.genai.types import ( + GenerateContentResponse, + ContentListUnion, + Tool, + Model, + EmbedContentResponse, + ) + + +class UsageData(TypedDict): + """Structure for token usage data.""" + + input_tokens: int + input_tokens_cached: int + output_tokens: int + output_tokens_reasoning: int + total_tokens: int + + +def extract_usage_data( + response: "Union[GenerateContentResponse, dict[str, Any]]", +) -> "UsageData": + """Extract usage data from response into a structured format. + + Args: + response: The GenerateContentResponse object or dictionary containing usage metadata + + Returns: + UsageData: Dictionary with input_tokens, input_tokens_cached, + output_tokens, and output_tokens_reasoning fields + """ + usage_data = UsageData( + input_tokens=0, + input_tokens_cached=0, + output_tokens=0, + output_tokens_reasoning=0, + total_tokens=0, + ) + + # Handle dictionary response (from streaming) + if isinstance(response, dict): + usage = response.get("usage_metadata", {}) + if not usage: + return usage_data + + prompt_tokens = usage.get("prompt_token_count", 0) or 0 + tool_use_prompt_tokens = usage.get("tool_use_prompt_token_count", 0) or 0 + usage_data["input_tokens"] = prompt_tokens + tool_use_prompt_tokens + + cached_tokens = usage.get("cached_content_token_count", 0) or 0 + usage_data["input_tokens_cached"] = cached_tokens + + reasoning_tokens = usage.get("thoughts_token_count", 0) or 0 + usage_data["output_tokens_reasoning"] = reasoning_tokens + + candidates_tokens = usage.get("candidates_token_count", 0) or 0 + # python-genai reports output and reasoning tokens separately + # reasoning should be sub-category of output tokens + usage_data["output_tokens"] = candidates_tokens + reasoning_tokens + + total_tokens = usage.get("total_token_count", 0) or 0 + usage_data["total_tokens"] = total_tokens + + return usage_data + + if not hasattr(response, "usage_metadata"): + return usage_data + + usage = response.usage_metadata + + # Input tokens include both prompt and tool use prompt tokens + prompt_tokens = getattr(usage, "prompt_token_count", 0) or 0 + tool_use_prompt_tokens = getattr(usage, "tool_use_prompt_token_count", 0) or 0 + usage_data["input_tokens"] = prompt_tokens + tool_use_prompt_tokens + + # Cached input tokens + cached_tokens = getattr(usage, "cached_content_token_count", 0) or 0 + usage_data["input_tokens_cached"] = cached_tokens + + # Reasoning tokens + reasoning_tokens = getattr(usage, "thoughts_token_count", 0) or 0 + usage_data["output_tokens_reasoning"] = reasoning_tokens + + # output_tokens = candidates_tokens + reasoning_tokens + # google-genai reports output and reasoning tokens separately + candidates_tokens = getattr(usage, "candidates_token_count", 0) or 0 + usage_data["output_tokens"] = candidates_tokens + reasoning_tokens + + total_tokens = getattr(usage, "total_token_count", 0) or 0 + usage_data["total_tokens"] = total_tokens + + return usage_data + + +def _capture_exception(exc: "Any") -> None: + """Capture exception with Google GenAI mechanism.""" + event, hint = event_from_exception( + exc, + client_options=sentry_sdk.get_client().options, + mechanism={"type": "google_genai", "handled": False}, + ) + sentry_sdk.capture_event(event, hint=hint) + + +def get_model_name(model: "Union[str, Model]") -> str: + """Extract model name from model parameter.""" + if isinstance(model, str): + return model + # Handle case where model might be an object with a name attribute + if hasattr(model, "name"): + return str(model.name) + return str(model) + + +def extract_contents_text(contents: "ContentListUnion") -> "Optional[str]": + """Extract text from contents parameter which can have various formats.""" + if contents is None: + return None + + # Simple string case + if isinstance(contents, str): + return contents + + # List of contents or parts + if isinstance(contents, list): + texts = [] + for item in contents: + # Recursively extract text from each item + extracted = extract_contents_text(item) + if extracted: + texts.append(extracted) + return " ".join(texts) if texts else None + + # Dictionary case + if isinstance(contents, dict): + if "text" in contents: + return contents["text"] + # Try to extract from parts if present in dict + if "parts" in contents: + return extract_contents_text(contents["parts"]) + + # Content object with parts - recurse into parts + if getattr(contents, "parts", None): + return extract_contents_text(contents.parts) + + # Direct text attribute + if hasattr(contents, "text"): + return contents.text + + return None + + +def _format_tools_for_span( + tools: "Iterable[Tool | Callable[..., Any]]", +) -> "Optional[List[dict[str, Any]]]": + """Format tools parameter for span data.""" + formatted_tools = [] + for tool in tools: + if callable(tool): + # Handle callable functions passed directly + formatted_tools.append( + { + "name": getattr(tool, "__name__", "unknown"), + "description": getattr(tool, "__doc__", None), + } + ) + elif ( + hasattr(tool, "function_declarations") + and tool.function_declarations is not None + ): + # Tool object with function declarations + for func_decl in tool.function_declarations: + formatted_tools.append( + { + "name": getattr(func_decl, "name", None), + "description": getattr(func_decl, "description", None), + } + ) + else: + # Check for predefined tool attributes - each of these tools + # is an attribute of the tool object, by default set to None + for attr_name, description in TOOL_ATTRIBUTES_MAP.items(): + if getattr(tool, attr_name, None): + formatted_tools.append( + { + "name": attr_name, + "description": description, + } + ) + break + + return formatted_tools if formatted_tools else None + + +def extract_tool_calls( + response: "GenerateContentResponse", +) -> "Optional[List[dict[str, Any]]]": + """Extract tool/function calls from response candidates and automatic function calling history.""" + + tool_calls = [] + + # Extract from candidates, sometimes tool calls are nested under the content.parts object + if getattr(response, "candidates", []): + for candidate in response.candidates: + if not hasattr(candidate, "content") or not getattr( + candidate.content, "parts", [] + ): + continue + + for part in candidate.content.parts: + if getattr(part, "function_call", None): + function_call = part.function_call + tool_call = { + "name": getattr(function_call, "name", None), + "type": "function_call", + } + + # Extract arguments if available + if getattr(function_call, "args", None): + tool_call["arguments"] = safe_serialize(function_call.args) + + tool_calls.append(tool_call) + + # Extract from automatic_function_calling_history + # This is the history of tool calls made by the model + if getattr(response, "automatic_function_calling_history", None): + for content in response.automatic_function_calling_history: + if not getattr(content, "parts", None): + continue + + for part in getattr(content, "parts", []): + if getattr(part, "function_call", None): + function_call = part.function_call + tool_call = { + "name": getattr(function_call, "name", None), + "type": "function_call", + } + + # Extract arguments if available + if hasattr(function_call, "args"): + tool_call["arguments"] = safe_serialize(function_call.args) + + tool_calls.append(tool_call) + + return tool_calls if tool_calls else None + + +def _capture_tool_input( + args: "tuple[Any, ...]", kwargs: "dict[str, Any]", tool: "Tool" +) -> "dict[str, Any]": + """Capture tool input from args and kwargs.""" + tool_input = kwargs.copy() if kwargs else {} + + # If we have positional args, try to map them to the function signature + if args: + try: + sig = inspect.signature(tool) + param_names = list(sig.parameters.keys()) + for i, arg in enumerate(args): + if i < len(param_names): + tool_input[param_names[i]] = arg + except Exception: + # Fallback if we can't get the signature + tool_input["args"] = args + + return tool_input + + +def _create_tool_span(tool_name: str, tool_doc: "Optional[str]") -> "Span": + """Create a span for tool execution.""" + span = sentry_sdk.start_span( + op=OP.GEN_AI_EXECUTE_TOOL, + name=f"execute_tool {tool_name}", + origin=ORIGIN, + ) + span.set_data(SPANDATA.GEN_AI_TOOL_NAME, tool_name) + span.set_data(SPANDATA.GEN_AI_TOOL_TYPE, "function") + if tool_doc: + span.set_data(SPANDATA.GEN_AI_TOOL_DESCRIPTION, tool_doc) + return span + + +def wrapped_tool(tool: "Tool | Callable[..., Any]") -> "Tool | Callable[..., Any]": + """Wrap a tool to emit execute_tool spans when called.""" + if not callable(tool): + # Not a callable function, return as-is (predefined tools) + return tool + + tool_name = getattr(tool, "__name__", "unknown") + tool_doc = tool.__doc__ + + if inspect.iscoroutinefunction(tool): + # Async function + @wraps(tool) + async def async_wrapped(*args: "Any", **kwargs: "Any") -> "Any": + with _create_tool_span(tool_name, tool_doc) as span: + # Capture tool input + tool_input = _capture_tool_input(args, kwargs, tool) + with capture_internal_exceptions(): + span.set_data( + SPANDATA.GEN_AI_TOOL_INPUT, safe_serialize(tool_input) + ) + + try: + result = await tool(*args, **kwargs) + + # Capture tool output + with capture_internal_exceptions(): + span.set_data( + SPANDATA.GEN_AI_TOOL_OUTPUT, safe_serialize(result) + ) + + return result + except Exception as exc: + _capture_exception(exc) + raise + + return async_wrapped + else: + # Sync function + @wraps(tool) + def sync_wrapped(*args: "Any", **kwargs: "Any") -> "Any": + with _create_tool_span(tool_name, tool_doc) as span: + # Capture tool input + tool_input = _capture_tool_input(args, kwargs, tool) + with capture_internal_exceptions(): + span.set_data( + SPANDATA.GEN_AI_TOOL_INPUT, safe_serialize(tool_input) + ) + + try: + result = tool(*args, **kwargs) + + # Capture tool output + with capture_internal_exceptions(): + span.set_data( + SPANDATA.GEN_AI_TOOL_OUTPUT, safe_serialize(result) + ) + + return result + except Exception as exc: + _capture_exception(exc) + raise + + return sync_wrapped + + +def wrapped_config_with_tools( + config: "GenerateContentConfig", +) -> "GenerateContentConfig": + """Wrap tools in config to emit execute_tool spans. Tools are sometimes passed directly as + callable functions as a part of the config object.""" + + if not config or not getattr(config, "tools", None): + return config + + result = copy.copy(config) + result.tools = [wrapped_tool(tool) for tool in config.tools] + + return result + + +def _extract_response_text( + response: "GenerateContentResponse", +) -> "Optional[List[str]]": + """Extract text from response candidates.""" + + if not response or not getattr(response, "candidates", []): + return None + + texts = [] + for candidate in response.candidates: + if not hasattr(candidate, "content") or not hasattr(candidate.content, "parts"): + continue + + for part in candidate.content.parts: + if getattr(part, "text", None): + texts.append(part.text) + + return texts if texts else None + + +def extract_finish_reasons( + response: "GenerateContentResponse", +) -> "Optional[List[str]]": + """Extract finish reasons from response candidates.""" + if not response or not getattr(response, "candidates", []): + return None + + finish_reasons = [] + for candidate in response.candidates: + if getattr(candidate, "finish_reason", None): + # Convert enum value to string if necessary + reason = str(candidate.finish_reason) + # Remove enum prefix if present (e.g., "FinishReason.STOP" -> "STOP") + if "." in reason: + reason = reason.split(".")[-1] + finish_reasons.append(reason) + + return finish_reasons if finish_reasons else None + + +def set_span_data_for_request( + span: "Span", + integration: "Any", + model: str, + contents: "ContentListUnion", + kwargs: "dict[str, Any]", +) -> None: + """Set span data for the request.""" + span.set_data(SPANDATA.GEN_AI_SYSTEM, GEN_AI_SYSTEM) + span.set_data(SPANDATA.GEN_AI_REQUEST_MODEL, model) + + if kwargs.get("stream", False): + span.set_data(SPANDATA.GEN_AI_RESPONSE_STREAMING, True) + + config: "Optional[GenerateContentConfig]" = kwargs.get("config") + + # Set input messages/prompts if PII is allowed + if should_send_default_pii() and integration.include_prompts: + messages = [] + + # Add system instruction if present + if config and hasattr(config, "system_instruction"): + system_instruction = config.system_instruction + if system_instruction: + system_text = extract_contents_text(system_instruction) + if system_text: + messages.append({"role": "system", "content": system_text}) + + # Add user message + contents_text = extract_contents_text(contents) + if contents_text: + messages.append({"role": "user", "content": contents_text}) + + if messages: + normalized_messages = normalize_message_roles(messages) + scope = sentry_sdk.get_current_scope() + messages_data = truncate_and_annotate_messages( + normalized_messages, span, scope + ) + if messages_data is not None: + set_data_normalized( + span, + SPANDATA.GEN_AI_REQUEST_MESSAGES, + messages_data, + unpack=False, + ) + + # Extract parameters directly from config (not nested under generation_config) + for param, span_key in [ + ("temperature", SPANDATA.GEN_AI_REQUEST_TEMPERATURE), + ("top_p", SPANDATA.GEN_AI_REQUEST_TOP_P), + ("top_k", SPANDATA.GEN_AI_REQUEST_TOP_K), + ("max_output_tokens", SPANDATA.GEN_AI_REQUEST_MAX_TOKENS), + ("presence_penalty", SPANDATA.GEN_AI_REQUEST_PRESENCE_PENALTY), + ("frequency_penalty", SPANDATA.GEN_AI_REQUEST_FREQUENCY_PENALTY), + ("seed", SPANDATA.GEN_AI_REQUEST_SEED), + ]: + if hasattr(config, param): + value = getattr(config, param) + if value is not None: + span.set_data(span_key, value) + + # Set tools if available + if config is not None and hasattr(config, "tools"): + tools = config.tools + if tools: + formatted_tools = _format_tools_for_span(tools) + if formatted_tools: + set_data_normalized( + span, + SPANDATA.GEN_AI_REQUEST_AVAILABLE_TOOLS, + formatted_tools, + unpack=False, + ) + + +def set_span_data_for_response( + span: "Span", integration: "Any", response: "GenerateContentResponse" +) -> None: + """Set span data for the response.""" + if not response: + return + + if should_send_default_pii() and integration.include_prompts: + response_texts = _extract_response_text(response) + if response_texts: + # Format as JSON string array as per documentation + span.set_data(SPANDATA.GEN_AI_RESPONSE_TEXT, safe_serialize(response_texts)) + + tool_calls = extract_tool_calls(response) + if tool_calls: + # Tool calls should be JSON serialized + span.set_data(SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS, safe_serialize(tool_calls)) + + finish_reasons = extract_finish_reasons(response) + if finish_reasons: + set_data_normalized( + span, SPANDATA.GEN_AI_RESPONSE_FINISH_REASONS, finish_reasons + ) + + if getattr(response, "response_id", None): + span.set_data(SPANDATA.GEN_AI_RESPONSE_ID, response.response_id) + + if getattr(response, "model_version", None): + span.set_data(SPANDATA.GEN_AI_RESPONSE_MODEL, response.model_version) + + usage_data = extract_usage_data(response) + + if usage_data["input_tokens"]: + span.set_data(SPANDATA.GEN_AI_USAGE_INPUT_TOKENS, usage_data["input_tokens"]) + + if usage_data["input_tokens_cached"]: + span.set_data( + SPANDATA.GEN_AI_USAGE_INPUT_TOKENS_CACHED, + usage_data["input_tokens_cached"], + ) + + if usage_data["output_tokens"]: + span.set_data(SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS, usage_data["output_tokens"]) + + if usage_data["output_tokens_reasoning"]: + span.set_data( + SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS_REASONING, + usage_data["output_tokens_reasoning"], + ) + + if usage_data["total_tokens"]: + span.set_data(SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS, usage_data["total_tokens"]) + + +def prepare_generate_content_args( + args: "tuple[Any, ...]", kwargs: "dict[str, Any]" +) -> "tuple[Any, Any, str]": + """Extract and prepare common arguments for generate_content methods.""" + model = args[0] if args else kwargs.get("model", "unknown") + contents = args[1] if len(args) > 1 else kwargs.get("contents") + model_name = get_model_name(model) + + config = kwargs.get("config") + wrapped_config = wrapped_config_with_tools(config) + if wrapped_config is not config: + kwargs["config"] = wrapped_config + + return model, contents, model_name + + +def prepare_embed_content_args( + args: "tuple[Any, ...]", kwargs: "dict[str, Any]" +) -> "tuple[str, Any]": + """Extract and prepare common arguments for embed_content methods. + + Returns: + tuple: (model_name, contents) + """ + model = kwargs.get("model", "unknown") + contents = kwargs.get("contents") + model_name = get_model_name(model) + + return model_name, contents + + +def set_span_data_for_embed_request( + span: "Span", integration: "Any", contents: "Any", kwargs: "dict[str, Any]" +) -> None: + """Set span data for embedding request.""" + # Include input contents if PII is allowed + if should_send_default_pii() and integration.include_prompts: + if contents: + # For embeddings, contents is typically a list of strings/texts + input_texts = [] + + # Handle various content formats + if isinstance(contents, str): + input_texts = [contents] + elif isinstance(contents, list): + for item in contents: + text = extract_contents_text(item) + if text: + input_texts.append(text) + else: + text = extract_contents_text(contents) + if text: + input_texts = [text] + + if input_texts: + set_data_normalized( + span, + SPANDATA.GEN_AI_EMBEDDINGS_INPUT, + input_texts, + unpack=False, + ) + + +def set_span_data_for_embed_response( + span: "Span", integration: "Any", response: "EmbedContentResponse" +) -> None: + """Set span data for embedding response.""" + if not response: + return + + # Extract token counts from embeddings statistics (Vertex AI only) + # Each embedding has its own statistics with token_count + if hasattr(response, "embeddings") and response.embeddings: + total_tokens = 0 + + for embedding in response.embeddings: + if hasattr(embedding, "statistics") and embedding.statistics: + token_count = getattr(embedding.statistics, "token_count", None) + if token_count is not None: + total_tokens += int(token_count) + + # Set token count if we found any + if total_tokens > 0: + span.set_data(SPANDATA.GEN_AI_USAGE_INPUT_TOKENS, total_tokens) diff --git a/sentry_sdk/integrations/gql.py b/sentry_sdk/integrations/gql.py index 79fc8d022f..083ceaf517 100644 --- a/sentry_sdk/integrations/gql.py +++ b/sentry_sdk/integrations/gql.py @@ -1,46 +1,57 @@ -from sentry_sdk.utils import event_from_exception, parse_version -from sentry_sdk.hub import Hub, _should_send_default_pii -from sentry_sdk.integrations import DidNotEnable, Integration +import sentry_sdk +from sentry_sdk.utils import ( + event_from_exception, + ensure_integration_enabled, + parse_version, +) + +from sentry_sdk.integrations import _check_minimum_version, DidNotEnable, Integration +from sentry_sdk.scope import should_send_default_pii try: import gql # type: ignore[import-not-found] - from graphql import print_ast, get_operation_ast, DocumentNode, VariableDefinitionNode # type: ignore[import-not-found] + from graphql import ( + print_ast, + get_operation_ast, + DocumentNode, + VariableDefinitionNode, + ) from gql.transport import Transport, AsyncTransport # type: ignore[import-not-found] from gql.transport.exceptions import TransportQueryError # type: ignore[import-not-found] + + try: + # gql 4.0+ + from gql import GraphQLRequest + except ImportError: + GraphQLRequest = None + except ImportError: raise DidNotEnable("gql is not installed") -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any, Dict, Tuple, Union - from sentry_sdk._types import EventProcessor + from sentry_sdk._types import Event, EventProcessor EventDataType = Dict[str, Union[str, Tuple[VariableDefinitionNode, ...]]] -MIN_GQL_VERSION = (3, 4, 1) - class GQLIntegration(Integration): identifier = "gql" @staticmethod - def setup_once(): - # type: () -> None + def setup_once() -> None: gql_version = parse_version(gql.__version__) - if gql_version is None or gql_version < MIN_GQL_VERSION: - raise DidNotEnable( - "GQLIntegration is only supported for GQL versions %s and above." - % ".".join(str(num) for num in MIN_GQL_VERSION) - ) + _check_minimum_version(GQLIntegration, gql_version) + _patch_execute() -def _data_from_document(document): - # type: (DocumentNode) -> EventDataType +def _data_from_document(document: "DocumentNode") -> "EventDataType": try: operation_ast = get_operation_ast(document) - data = {"query": print_ast(document)} # type: EventDataType + data: "EventDataType" = {"query": print_ast(document)} if operation_ast is not None: data["variables"] = operation_ast.variable_definitions @@ -52,8 +63,7 @@ def _data_from_document(document): return dict() -def _transport_method(transport): - # type: (Union[Transport, AsyncTransport]) -> str +def _transport_method(transport: "Union[Transport, AsyncTransport]") -> str: """ The RequestsHTTPTransport allows defining the HTTP method; all other transports use POST. @@ -64,8 +74,9 @@ def _transport_method(transport): return "POST" -def _request_info_from_transport(transport): - # type: (Union[Transport, AsyncTransport, None]) -> Dict[str, str] +def _request_info_from_transport( + transport: "Union[Transport, AsyncTransport, None]", +) -> "Dict[str, str]": if transport is None: return {} @@ -81,38 +92,38 @@ def _request_info_from_transport(transport): return request_info -def _patch_execute(): - # type: () -> None +def _patch_execute() -> None: real_execute = gql.Client.execute - def sentry_patched_execute(self, document, *args, **kwargs): - # type: (gql.Client, DocumentNode, Any, Any) -> Any - hub = Hub.current - if hub.get_integration(GQLIntegration) is None: - return real_execute(self, document, *args, **kwargs) - - with Hub.current.configure_scope() as scope: - scope.add_event_processor(_make_gql_event_processor(self, document)) + @ensure_integration_enabled(GQLIntegration, real_execute) + def sentry_patched_execute( + self: "gql.Client", + document_or_request: "DocumentNode", + *args: "Any", + **kwargs: "Any", + ) -> "Any": + scope = sentry_sdk.get_isolation_scope() + scope.add_event_processor(_make_gql_event_processor(self, document_or_request)) try: - return real_execute(self, document, *args, **kwargs) + return real_execute(self, document_or_request, *args, **kwargs) except TransportQueryError as e: event, hint = event_from_exception( e, - client_options=hub.client.options if hub.client is not None else None, + client_options=sentry_sdk.get_client().options, mechanism={"type": "gql", "handled": False}, ) - hub.capture_event(event, hint) + sentry_sdk.capture_event(event, hint) raise e gql.Client.execute = sentry_patched_execute -def _make_gql_event_processor(client, document): - # type: (gql.Client, DocumentNode) -> EventProcessor - def processor(event, hint): - # type: (Dict[str, Any], Dict[str, Any]) -> Dict[str, Any] +def _make_gql_event_processor( + client: "gql.Client", document_or_request: "Union[DocumentNode, gql.GraphQLRequest]" +) -> "EventProcessor": + def processor(event: "Event", hint: "dict[str, Any]") -> "Event": try: errors = hint["exc_info"][1].errors except (AttributeError, KeyError): @@ -126,7 +137,17 @@ def processor(event, hint): } ) - if _should_send_default_pii(): + if should_send_default_pii(): + if GraphQLRequest is not None and isinstance( + document_or_request, GraphQLRequest + ): + # In v4.0.0, gql moved to using GraphQLRequest instead of + # DocumentNode in execute + # https://github.com/graphql-python/gql/pull/556 + document = document_or_request.document + else: + document = document_or_request + request["data"] = _data_from_document(document) contexts = event.setdefault("contexts", {}) response = contexts.setdefault("response", {}) diff --git a/sentry_sdk/integrations/graphene.py b/sentry_sdk/integrations/graphene.py index 5d3c656145..5a61ca5c78 100644 --- a/sentry_sdk/integrations/graphene.py +++ b/sentry_sdk/integrations/graphene.py @@ -1,99 +1,103 @@ -from sentry_sdk.hub import Hub, _should_send_default_pii -from sentry_sdk.integrations import DidNotEnable, Integration -from sentry_sdk.integrations.modules import _get_installed_modules +from contextlib import contextmanager + +import sentry_sdk +from sentry_sdk.consts import OP +from sentry_sdk.integrations import _check_minimum_version, DidNotEnable, Integration +from sentry_sdk.scope import should_send_default_pii from sentry_sdk.utils import ( capture_internal_exceptions, + ensure_integration_enabled, event_from_exception, - parse_version, + package_version, ) -from sentry_sdk._types import TYPE_CHECKING - try: from graphene.types import schema as graphene_schema # type: ignore except ImportError: raise DidNotEnable("graphene is not installed") +from typing import TYPE_CHECKING if TYPE_CHECKING: + from collections.abc import Generator from typing import Any, Dict, Union from graphene.language.source import Source # type: ignore - from graphql.execution import ExecutionResult # type: ignore - from graphql.type import GraphQLSchema # type: ignore + from graphql.execution import ExecutionResult + from graphql.type import GraphQLSchema + from sentry_sdk._types import Event class GrapheneIntegration(Integration): identifier = "graphene" @staticmethod - def setup_once(): - # type: () -> None - installed_packages = _get_installed_modules() - version = parse_version(installed_packages["graphene"]) - - if version is None: - raise DidNotEnable("Unparsable graphene version: {}".format(version)) - - if version < (3, 3): - raise DidNotEnable("graphene 3.3 or newer required.") + def setup_once() -> None: + version = package_version("graphene") + _check_minimum_version(GrapheneIntegration, version) _patch_graphql() -def _patch_graphql(): - # type: () -> None +def _patch_graphql() -> None: old_graphql_sync = graphene_schema.graphql_sync old_graphql_async = graphene_schema.graphql - def _sentry_patched_graphql_sync(schema, source, *args, **kwargs): - # type: (GraphQLSchema, Union[str, Source], Any, Any) -> ExecutionResult - hub = Hub.current - integration = hub.get_integration(GrapheneIntegration) - if integration is None: - return old_graphql_sync(schema, source, *args, **kwargs) - - with hub.configure_scope() as scope: - scope.add_event_processor(_event_processor) + @ensure_integration_enabled(GrapheneIntegration, old_graphql_sync) + def _sentry_patched_graphql_sync( + schema: "GraphQLSchema", + source: "Union[str, Source]", + *args: "Any", + **kwargs: "Any", + ) -> "ExecutionResult": + scope = sentry_sdk.get_isolation_scope() + scope.add_event_processor(_event_processor) - result = old_graphql_sync(schema, source, *args, **kwargs) + with graphql_span(schema, source, kwargs): + result = old_graphql_sync(schema, source, *args, **kwargs) with capture_internal_exceptions(): + client = sentry_sdk.get_client() for error in result.errors or []: event, hint = event_from_exception( error, - client_options=hub.client.options if hub.client else None, + client_options=client.options, mechanism={ - "type": integration.identifier, + "type": GrapheneIntegration.identifier, "handled": False, }, ) - hub.capture_event(event, hint=hint) + sentry_sdk.capture_event(event, hint=hint) return result - async def _sentry_patched_graphql_async(schema, source, *args, **kwargs): - # type: (GraphQLSchema, Union[str, Source], Any, Any) -> ExecutionResult - hub = Hub.current - integration = hub.get_integration(GrapheneIntegration) + async def _sentry_patched_graphql_async( + schema: "GraphQLSchema", + source: "Union[str, Source]", + *args: "Any", + **kwargs: "Any", + ) -> "ExecutionResult": + integration = sentry_sdk.get_client().get_integration(GrapheneIntegration) if integration is None: return await old_graphql_async(schema, source, *args, **kwargs) - with hub.configure_scope() as scope: - scope.add_event_processor(_event_processor) + scope = sentry_sdk.get_isolation_scope() + scope.add_event_processor(_event_processor) - result = await old_graphql_async(schema, source, *args, **kwargs) + with graphql_span(schema, source, kwargs): + result = await old_graphql_async(schema, source, *args, **kwargs) with capture_internal_exceptions(): + client = sentry_sdk.get_client() for error in result.errors or []: event, hint = event_from_exception( error, - client_options=hub.client.options if hub.client else None, + client_options=client.options, mechanism={ - "type": integration.identifier, + "type": GrapheneIntegration.identifier, "handled": False, }, ) - hub.capture_event(event, hint=hint) + sentry_sdk.capture_event(event, hint=hint) return result @@ -101,9 +105,8 @@ async def _sentry_patched_graphql_async(schema, source, *args, **kwargs): graphene_schema.graphql = _sentry_patched_graphql_async -def _event_processor(event, hint): - # type: (Dict[str, Any], Dict[str, Any]) -> Dict[str, Any] - if _should_send_default_pii(): +def _event_processor(event: "Event", hint: "Dict[str, Any]") -> "Event": + if should_send_default_pii(): request_info = event.setdefault("request", {}) request_info["api_target"] = "graphql" @@ -111,3 +114,44 @@ def _event_processor(event, hint): del event["request"]["data"] return event + + +@contextmanager +def graphql_span( + schema: "GraphQLSchema", source: "Union[str, Source]", kwargs: "Dict[str, Any]" +) -> "Generator[None, None, None]": + operation_name = kwargs.get("operation_name") + + operation_type = "query" + op = OP.GRAPHQL_QUERY + if source.strip().startswith("mutation"): + operation_type = "mutation" + op = OP.GRAPHQL_MUTATION + elif source.strip().startswith("subscription"): + operation_type = "subscription" + op = OP.GRAPHQL_SUBSCRIPTION + + sentry_sdk.add_breadcrumb( + crumb={ + "data": { + "operation_name": operation_name, + "operation_type": operation_type, + }, + "category": "graphql.operation", + }, + ) + + scope = sentry_sdk.get_current_scope() + if scope.span: + _graphql_span = scope.span.start_child(op=op, name=operation_name) + else: + _graphql_span = sentry_sdk.start_span(op=op, name=operation_name) + + _graphql_span.set_data("graphql.document", source) + _graphql_span.set_data("graphql.operation.name", operation_name) + _graphql_span.set_data("graphql.operation.type", operation_type) + + try: + yield + finally: + _graphql_span.finish() diff --git a/sentry_sdk/integrations/grpc/__init__.py b/sentry_sdk/integrations/grpc/__init__.py index 59bfd502e5..b6641163a9 100644 --- a/sentry_sdk/integrations/grpc/__init__.py +++ b/sentry_sdk/integrations/grpc/__init__.py @@ -1,2 +1,175 @@ -from .server import ServerInterceptor # noqa: F401 -from .client import ClientInterceptor # noqa: F401 +from functools import wraps + +from sentry_sdk.integrations import Integration +from sentry_sdk.utils import parse_version +from sentry_sdk.integrations import DidNotEnable + +from .client import ClientInterceptor +from .server import ServerInterceptor + +from typing import TYPE_CHECKING, Any, Optional, Sequence + +try: + import grpc + from grpc import Channel, Server, intercept_channel + from grpc.aio import Channel as AsyncChannel + from grpc.aio import Server as AsyncServer + + from .aio.server import ServerInterceptor as AsyncServerInterceptor + from .aio.client import ( + SentryUnaryUnaryClientInterceptor as AsyncUnaryUnaryClientInterceptor, + ) + from .aio.client import ( + SentryUnaryStreamClientInterceptor as AsyncUnaryStreamClientIntercetor, + ) +except ImportError: + raise DidNotEnable("grpcio is not installed.") + +# Hack to get new Python features working in older versions +# without introducing a hard dependency on `typing_extensions` +# from: https://stackoverflow.com/a/71944042/300572 +if TYPE_CHECKING: + from typing import ParamSpec, Callable +else: + # Fake ParamSpec + class ParamSpec: + def __init__(self, _): + self.args = None + self.kwargs = None + + # Callable[anything] will return None + class _Callable: + def __getitem__(self, _): + return None + + # Make instances + Callable = _Callable() + +P = ParamSpec("P") + +GRPC_VERSION = parse_version(grpc.__version__) + + +def _wrap_channel_sync(func: "Callable[P, Channel]") -> "Callable[P, Channel]": + "Wrapper for synchronous secure and insecure channel." + + @wraps(func) + def patched_channel(*args: "Any", **kwargs: "Any") -> "Channel": + channel = func(*args, **kwargs) + if not ClientInterceptor._is_intercepted: + ClientInterceptor._is_intercepted = True + return intercept_channel(channel, ClientInterceptor()) + else: + return channel + + return patched_channel + + +def _wrap_intercept_channel(func: "Callable[P, Channel]") -> "Callable[P, Channel]": + @wraps(func) + def patched_intercept_channel( + channel: "Channel", *interceptors: "grpc.ServerInterceptor" + ) -> "Channel": + if ClientInterceptor._is_intercepted: + interceptors = tuple( + [ + interceptor + for interceptor in interceptors + if not isinstance(interceptor, ClientInterceptor) + ] + ) + else: + interceptors = interceptors + return intercept_channel(channel, *interceptors) + + return patched_intercept_channel # type: ignore + + +def _wrap_channel_async( + func: "Callable[P, AsyncChannel]", +) -> "Callable[P, AsyncChannel]": + "Wrapper for asynchronous secure and insecure channel." + + @wraps(func) + def patched_channel( # type: ignore + *args: "P.args", + interceptors: "Optional[Sequence[grpc.aio.ClientInterceptor]]" = None, + **kwargs: "P.kwargs", + ) -> "Channel": + sentry_interceptors = [ + AsyncUnaryUnaryClientInterceptor(), + AsyncUnaryStreamClientIntercetor(), + ] + interceptors = [*sentry_interceptors, *(interceptors or [])] + return func(*args, interceptors=interceptors, **kwargs) # type: ignore + + return patched_channel # type: ignore + + +def _wrap_sync_server(func: "Callable[P, Server]") -> "Callable[P, Server]": + """Wrapper for synchronous server.""" + + @wraps(func) + def patched_server( # type: ignore + *args: "P.args", + interceptors: "Optional[Sequence[grpc.ServerInterceptor]]" = None, + **kwargs: "P.kwargs", + ) -> "Server": + interceptors = [ + interceptor + for interceptor in interceptors or [] + if not isinstance(interceptor, ServerInterceptor) + ] + server_interceptor = ServerInterceptor() + interceptors = [server_interceptor, *(interceptors or [])] + return func(*args, interceptors=interceptors, **kwargs) # type: ignore + + return patched_server # type: ignore + + +def _wrap_async_server(func: "Callable[P, AsyncServer]") -> "Callable[P, AsyncServer]": + """Wrapper for asynchronous server.""" + + @wraps(func) + def patched_aio_server( # type: ignore + *args: "P.args", + interceptors: "Optional[Sequence[grpc.ServerInterceptor]]" = None, + **kwargs: "P.kwargs", + ) -> "Server": + server_interceptor = AsyncServerInterceptor() + interceptors: "Sequence[grpc.ServerInterceptor]" = [ + server_interceptor, + *(interceptors or []), + ] + + try: + # We prefer interceptors as a list because of compatibility with + # opentelemetry https://github.com/getsentry/sentry-python/issues/4389 + # However, prior to grpc 1.42.0, only tuples were accepted, so we + # have no choice there. + if GRPC_VERSION is not None and GRPC_VERSION < (1, 42, 0): + interceptors = tuple(interceptors) + except Exception: + pass + + return func(*args, interceptors=interceptors, **kwargs) # type: ignore + + return patched_aio_server # type: ignore + + +class GRPCIntegration(Integration): + identifier = "grpc" + + @staticmethod + def setup_once() -> None: + import grpc + + grpc.insecure_channel = _wrap_channel_sync(grpc.insecure_channel) + grpc.secure_channel = _wrap_channel_sync(grpc.secure_channel) + grpc.intercept_channel = _wrap_intercept_channel(grpc.intercept_channel) + + grpc.aio.insecure_channel = _wrap_channel_async(grpc.aio.insecure_channel) + grpc.aio.secure_channel = _wrap_channel_async(grpc.aio.secure_channel) + + grpc.server = _wrap_sync_server(grpc.server) + grpc.aio.server = _wrap_async_server(grpc.aio.server) diff --git a/sentry_sdk/integrations/grpc/aio/__init__.py b/sentry_sdk/integrations/grpc/aio/__init__.py new file mode 100644 index 0000000000..5b9e3b9949 --- /dev/null +++ b/sentry_sdk/integrations/grpc/aio/__init__.py @@ -0,0 +1,7 @@ +from .server import ServerInterceptor +from .client import ClientInterceptor + +__all__ = [ + "ClientInterceptor", + "ServerInterceptor", +] diff --git a/sentry_sdk/integrations/grpc/aio/client.py b/sentry_sdk/integrations/grpc/aio/client.py new file mode 100644 index 0000000000..2edad83aff --- /dev/null +++ b/sentry_sdk/integrations/grpc/aio/client.py @@ -0,0 +1,99 @@ +from typing import Callable, Union, AsyncIterable, Any + +import sentry_sdk +from sentry_sdk.consts import OP +from sentry_sdk.integrations import DidNotEnable +from sentry_sdk.integrations.grpc.consts import SPAN_ORIGIN + +try: + from grpc.aio import ( + UnaryUnaryClientInterceptor, + UnaryStreamClientInterceptor, + ClientCallDetails, + UnaryUnaryCall, + UnaryStreamCall, + Metadata, + ) + from google.protobuf.message import Message +except ImportError: + raise DidNotEnable("grpcio is not installed") + + +class ClientInterceptor: + @staticmethod + def _update_client_call_details_metadata_from_scope( + client_call_details: "ClientCallDetails", + ) -> "ClientCallDetails": + if client_call_details.metadata is None: + client_call_details = client_call_details._replace(metadata=Metadata()) + elif not isinstance(client_call_details.metadata, Metadata): + # This is a workaround for a GRPC bug, which was fixed in grpcio v1.60.0 + # See https://github.com/grpc/grpc/issues/34298. + client_call_details = client_call_details._replace( + metadata=Metadata.from_tuple(client_call_details.metadata) + ) + for ( + key, + value, + ) in sentry_sdk.get_current_scope().iter_trace_propagation_headers(): + client_call_details.metadata.add(key, value) + return client_call_details + + +class SentryUnaryUnaryClientInterceptor(ClientInterceptor, UnaryUnaryClientInterceptor): # type: ignore + async def intercept_unary_unary( + self, + continuation: "Callable[[ClientCallDetails, Message], UnaryUnaryCall]", + client_call_details: "ClientCallDetails", + request: "Message", + ) -> "Union[UnaryUnaryCall, Message]": + method = client_call_details.method + + with sentry_sdk.start_span( + op=OP.GRPC_CLIENT, + name="unary unary call to %s" % method.decode(), + origin=SPAN_ORIGIN, + ) as span: + span.set_data("type", "unary unary") + span.set_data("method", method) + + client_call_details = self._update_client_call_details_metadata_from_scope( + client_call_details + ) + + response = await continuation(client_call_details, request) + status_code = await response.code() + span.set_data("code", status_code.name) + + return response + + +class SentryUnaryStreamClientInterceptor( + ClientInterceptor, + UnaryStreamClientInterceptor, # type: ignore +): + async def intercept_unary_stream( + self, + continuation: "Callable[[ClientCallDetails, Message], UnaryStreamCall]", + client_call_details: "ClientCallDetails", + request: "Message", + ) -> "Union[AsyncIterable[Any], UnaryStreamCall]": + method = client_call_details.method + + with sentry_sdk.start_span( + op=OP.GRPC_CLIENT, + name="unary stream call to %s" % method.decode(), + origin=SPAN_ORIGIN, + ) as span: + span.set_data("type", "unary stream") + span.set_data("method", method) + + client_call_details = self._update_client_call_details_metadata_from_scope( + client_call_details + ) + + response = await continuation(client_call_details, request) + # status_code = await response.code() + # span.set_data("code", status_code) + + return response diff --git a/sentry_sdk/integrations/grpc/aio/server.py b/sentry_sdk/integrations/grpc/aio/server.py new file mode 100644 index 0000000000..3ed15c2de6 --- /dev/null +++ b/sentry_sdk/integrations/grpc/aio/server.py @@ -0,0 +1,100 @@ +import sentry_sdk +from sentry_sdk.consts import OP +from sentry_sdk.integrations import DidNotEnable +from sentry_sdk.integrations.grpc.consts import SPAN_ORIGIN +from sentry_sdk.tracing import Transaction, TransactionSource +from sentry_sdk.utils import event_from_exception + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + from typing import Any, Optional + + +try: + import grpc + from grpc import HandlerCallDetails, RpcMethodHandler + from grpc.aio import AbortError, ServicerContext +except ImportError: + raise DidNotEnable("grpcio is not installed") + + +class ServerInterceptor(grpc.aio.ServerInterceptor): # type: ignore + def __init__( + self: "ServerInterceptor", + find_name: "Callable[[ServicerContext], str] | None" = None, + ) -> None: + self._find_method_name = find_name or self._find_name + + super().__init__() + + async def intercept_service( + self: "ServerInterceptor", + continuation: "Callable[[HandlerCallDetails], Awaitable[RpcMethodHandler]]", + handler_call_details: "HandlerCallDetails", + ) -> "Optional[Awaitable[RpcMethodHandler]]": + self._handler_call_details = handler_call_details + handler = await continuation(handler_call_details) + if handler is None: + return None + + if not handler.request_streaming and not handler.response_streaming: + handler_factory = grpc.unary_unary_rpc_method_handler + + async def wrapped(request: "Any", context: "ServicerContext") -> "Any": + name = self._find_method_name(context) + if not name: + return await handler(request, context) + + # What if the headers are empty? + transaction = sentry_sdk.continue_trace( + dict(context.invocation_metadata()), + op=OP.GRPC_SERVER, + name=name, + source=TransactionSource.CUSTOM, + origin=SPAN_ORIGIN, + ) + + with sentry_sdk.start_transaction(transaction=transaction): + try: + return await handler.unary_unary(request, context) + except AbortError: + raise + except Exception as exc: + event, hint = event_from_exception( + exc, + mechanism={"type": "grpc", "handled": False}, + ) + sentry_sdk.capture_event(event, hint=hint) + raise + + elif not handler.request_streaming and handler.response_streaming: + handler_factory = grpc.unary_stream_rpc_method_handler + + async def wrapped(request: "Any", context: "ServicerContext") -> "Any": # type: ignore + async for r in handler.unary_stream(request, context): + yield r + + elif handler.request_streaming and not handler.response_streaming: + handler_factory = grpc.stream_unary_rpc_method_handler + + async def wrapped(request: "Any", context: "ServicerContext") -> "Any": + response = handler.stream_unary(request, context) + return await response + + elif handler.request_streaming and handler.response_streaming: + handler_factory = grpc.stream_stream_rpc_method_handler + + async def wrapped(request: "Any", context: "ServicerContext") -> "Any": # type: ignore + async for r in handler.stream_stream(request, context): + yield r + + return handler_factory( + wrapped, + request_deserializer=handler.request_deserializer, + response_serializer=handler.response_serializer, + ) + + def _find_name(self, context: "ServicerContext") -> str: + return self._handler_call_details.method diff --git a/sentry_sdk/integrations/grpc/client.py b/sentry_sdk/integrations/grpc/client.py index 1eb3621b0b..69b3f3d318 100644 --- a/sentry_sdk/integrations/grpc/client.py +++ b/sentry_sdk/integrations/grpc/client.py @@ -1,9 +1,11 @@ -from sentry_sdk import Hub -from sentry_sdk._types import MYPY +import sentry_sdk from sentry_sdk.consts import OP from sentry_sdk.integrations import DidNotEnable +from sentry_sdk.integrations.grpc.consts import SPAN_ORIGIN -if MYPY: +from typing import TYPE_CHECKING + +if TYPE_CHECKING: from typing import Any, Callable, Iterator, Iterable, Union try: @@ -11,27 +13,35 @@ from grpc import ClientCallDetails, Call from grpc._interceptor import _UnaryOutcome from grpc.aio._interceptor import UnaryStreamCall - from google.protobuf.message import Message # type: ignore + from google.protobuf.message import Message except ImportError: raise DidNotEnable("grpcio is not installed") class ClientInterceptor( - grpc.UnaryUnaryClientInterceptor, grpc.UnaryStreamClientInterceptor # type: ignore + grpc.UnaryUnaryClientInterceptor, # type: ignore + grpc.UnaryStreamClientInterceptor, # type: ignore ): - def intercept_unary_unary(self, continuation, client_call_details, request): - # type: (ClientInterceptor, Callable[[ClientCallDetails, Message], _UnaryOutcome], ClientCallDetails, Message) -> _UnaryOutcome - hub = Hub.current + _is_intercepted = False + + def intercept_unary_unary( + self: "ClientInterceptor", + continuation: "Callable[[ClientCallDetails, Message], _UnaryOutcome]", + client_call_details: "ClientCallDetails", + request: "Message", + ) -> "_UnaryOutcome": method = client_call_details.method - with hub.start_span( - op=OP.GRPC_CLIENT, description="unary unary call to %s" % method + with sentry_sdk.start_span( + op=OP.GRPC_CLIENT, + name="unary unary call to %s" % method, + origin=SPAN_ORIGIN, ) as span: span.set_data("type", "unary unary") span.set_data("method", method) - client_call_details = self._update_client_call_details_metadata_from_hub( - client_call_details, hub + client_call_details = self._update_client_call_details_metadata_from_scope( + client_call_details ) response = continuation(client_call_details, request) @@ -39,35 +49,43 @@ def intercept_unary_unary(self, continuation, client_call_details, request): return response - def intercept_unary_stream(self, continuation, client_call_details, request): - # type: (ClientInterceptor, Callable[[ClientCallDetails, Message], Union[Iterable[Any], UnaryStreamCall]], ClientCallDetails, Message) -> Union[Iterator[Message], Call] - hub = Hub.current + def intercept_unary_stream( + self: "ClientInterceptor", + continuation: "Callable[[ClientCallDetails, Message], Union[Iterable[Any], UnaryStreamCall]]", + client_call_details: "ClientCallDetails", + request: "Message", + ) -> "Union[Iterator[Message], Call]": method = client_call_details.method - with hub.start_span( - op=OP.GRPC_CLIENT, description="unary stream call to %s" % method + with sentry_sdk.start_span( + op=OP.GRPC_CLIENT, + name="unary stream call to %s" % method, + origin=SPAN_ORIGIN, ) as span: span.set_data("type", "unary stream") span.set_data("method", method) - client_call_details = self._update_client_call_details_metadata_from_hub( - client_call_details, hub + client_call_details = self._update_client_call_details_metadata_from_scope( + client_call_details ) - response = continuation( - client_call_details, request - ) # type: UnaryStreamCall - span.set_data("code", response.code().name) + response: "UnaryStreamCall" = continuation(client_call_details, request) + # Setting code on unary-stream leads to execution getting stuck + # span.set_data("code", response.code().name) return response @staticmethod - def _update_client_call_details_metadata_from_hub(client_call_details, hub): - # type: (ClientCallDetails, Hub) -> ClientCallDetails + def _update_client_call_details_metadata_from_scope( + client_call_details: "ClientCallDetails", + ) -> "ClientCallDetails": metadata = ( list(client_call_details.metadata) if client_call_details.metadata else [] ) - for key, value in hub.iter_trace_propagation_headers(): + for ( + key, + value, + ) in sentry_sdk.get_current_scope().iter_trace_propagation_headers(): metadata.append((key, value)) client_call_details = grpc._interceptor._ClientCallDetails( diff --git a/sentry_sdk/integrations/grpc/consts.py b/sentry_sdk/integrations/grpc/consts.py new file mode 100644 index 0000000000..9fdb975caf --- /dev/null +++ b/sentry_sdk/integrations/grpc/consts.py @@ -0,0 +1 @@ +SPAN_ORIGIN = "auto.grpc.grpc" diff --git a/sentry_sdk/integrations/grpc/server.py b/sentry_sdk/integrations/grpc/server.py index cdeea4a2fa..9edf9ea29e 100644 --- a/sentry_sdk/integrations/grpc/server.py +++ b/sentry_sdk/integrations/grpc/server.py @@ -1,12 +1,14 @@ -from sentry_sdk import Hub -from sentry_sdk._types import MYPY +import sentry_sdk from sentry_sdk.consts import OP from sentry_sdk.integrations import DidNotEnable -from sentry_sdk.tracing import Transaction, TRANSACTION_SOURCE_CUSTOM +from sentry_sdk.integrations.grpc.consts import SPAN_ORIGIN +from sentry_sdk.tracing import Transaction, TransactionSource -if MYPY: +from typing import TYPE_CHECKING + +if TYPE_CHECKING: from typing import Callable, Optional - from google.protobuf.message import Message # type: ignore + from google.protobuf.message import Message try: import grpc @@ -16,41 +18,45 @@ class ServerInterceptor(grpc.ServerInterceptor): # type: ignore - def __init__(self, find_name=None): - # type: (ServerInterceptor, Optional[Callable[[ServicerContext], str]]) -> None + def __init__( + self: "ServerInterceptor", + find_name: "Optional[Callable[[ServicerContext], str]]" = None, + ) -> None: self._find_method_name = find_name or ServerInterceptor._find_name - super(ServerInterceptor, self).__init__() + super().__init__() - def intercept_service(self, continuation, handler_call_details): - # type: (ServerInterceptor, Callable[[HandlerCallDetails], RpcMethodHandler], HandlerCallDetails) -> RpcMethodHandler + def intercept_service( + self: "ServerInterceptor", + continuation: "Callable[[HandlerCallDetails], RpcMethodHandler]", + handler_call_details: "HandlerCallDetails", + ) -> "RpcMethodHandler": handler = continuation(handler_call_details) if not handler or not handler.unary_unary: return handler - def behavior(request, context): - # type: (Message, ServicerContext) -> Message - hub = Hub(Hub.current) - - name = self._find_method_name(context) + def behavior(request: "Message", context: "ServicerContext") -> "Message": + with sentry_sdk.isolation_scope(): + name = self._find_method_name(context) - if name: - metadata = dict(context.invocation_metadata()) + if name: + metadata = dict(context.invocation_metadata()) - transaction = Transaction.continue_from_headers( - metadata, - op=OP.GRPC_SERVER, - name=name, - source=TRANSACTION_SOURCE_CUSTOM, - ) + transaction = sentry_sdk.continue_trace( + metadata, + op=OP.GRPC_SERVER, + name=name, + source=TransactionSource.CUSTOM, + origin=SPAN_ORIGIN, + ) - with hub.start_transaction(transaction=transaction): - try: - return handler.unary_unary(request, context) - except BaseException as e: - raise e - else: - return handler.unary_unary(request, context) + with sentry_sdk.start_transaction(transaction=transaction): + try: + return handler.unary_unary(request, context) + except BaseException as e: + raise e + else: + return handler.unary_unary(request, context) return grpc.unary_unary_rpc_method_handler( behavior, @@ -59,6 +65,5 @@ def behavior(request, context): ) @staticmethod - def _find_name(context): - # type: (ServicerContext) -> str + def _find_name(context: "ServicerContext") -> str: return context._rpc_event.call_details.method.decode() diff --git a/sentry_sdk/integrations/httpx.py b/sentry_sdk/integrations/httpx.py index 04db5047b4..38e9c1a3d7 100644 --- a/sentry_sdk/integrations/httpx.py +++ b/sentry_sdk/integrations/httpx.py @@ -1,18 +1,25 @@ -from sentry_sdk import Hub +import sentry_sdk +from sentry_sdk import start_span from sentry_sdk.consts import OP, SPANDATA from sentry_sdk.integrations import Integration, DidNotEnable from sentry_sdk.tracing import BAGGAGE_HEADER_NAME -from sentry_sdk.tracing_utils import should_propagate_trace +from sentry_sdk.tracing_utils import ( + Baggage, + should_propagate_trace, + add_http_request_source, +) from sentry_sdk.utils import ( SENSITIVE_DATA_SUBSTITUTE, capture_internal_exceptions, + ensure_integration_enabled, logger, parse_url, ) -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: + from collections.abc import MutableMapping from typing import Any @@ -26,10 +33,10 @@ class HttpxIntegration(Integration): identifier = "httpx" + origin = f"auto.http.{identifier}" @staticmethod - def setup_once(): - # type: () -> None + def setup_once() -> None: """ httpx has its own transport layer and can be customized when needed, so patch Client.send and AsyncClient.send to support both synchronous and async interfaces. @@ -38,27 +45,23 @@ def setup_once(): _install_httpx_async_client() -def _install_httpx_client(): - # type: () -> None +def _install_httpx_client() -> None: real_send = Client.send - def send(self, request, **kwargs): - # type: (Client, Request, **Any) -> Response - hub = Hub.current - if hub.get_integration(HttpxIntegration) is None: - return real_send(self, request, **kwargs) - + @ensure_integration_enabled(HttpxIntegration, real_send) + def send(self: "Client", request: "Request", **kwargs: "Any") -> "Response": parsed_url = None with capture_internal_exceptions(): parsed_url = parse_url(str(request.url), sanitize=False) - with hub.start_span( + with start_span( op=OP.HTTP_CLIENT, - description="%s %s" + name="%s %s" % ( request.method, parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE, ), + origin=HttpxIntegration.origin, ) as span: span.set_data(SPANDATA.HTTP_METHOD, request.method) if parsed_url is not None: @@ -66,18 +69,19 @@ def send(self, request, **kwargs): span.set_data(SPANDATA.HTTP_QUERY, parsed_url.query) span.set_data(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment) - if should_propagate_trace(hub, str(request.url)): - for key, value in hub.iter_trace_propagation_headers(): + if should_propagate_trace(sentry_sdk.get_client(), str(request.url)): + for ( + key, + value, + ) in sentry_sdk.get_current_scope().iter_trace_propagation_headers(): logger.debug( "[Tracing] Adding `{key}` header {value} to outgoing request to {url}.".format( key=key, value=value, url=request.url ) ) - if key == BAGGAGE_HEADER_NAME and request.headers.get( - BAGGAGE_HEADER_NAME - ): - # do not overwrite any existing baggage, just append to it - request.headers[key] += "," + value + + if key == BAGGAGE_HEADER_NAME: + _add_sentry_baggage_to_headers(request.headers, value) else: request.headers[key] = value @@ -86,32 +90,35 @@ def send(self, request, **kwargs): span.set_http_status(rv.status_code) span.set_data("reason", rv.reason_phrase) - return rv + with capture_internal_exceptions(): + add_http_request_source(span) + + return rv Client.send = send -def _install_httpx_async_client(): - # type: () -> None +def _install_httpx_async_client() -> None: real_send = AsyncClient.send - async def send(self, request, **kwargs): - # type: (AsyncClient, Request, **Any) -> Response - hub = Hub.current - if hub.get_integration(HttpxIntegration) is None: + async def send( + self: "AsyncClient", request: "Request", **kwargs: "Any" + ) -> "Response": + if sentry_sdk.get_client().get_integration(HttpxIntegration) is None: return await real_send(self, request, **kwargs) parsed_url = None with capture_internal_exceptions(): parsed_url = parse_url(str(request.url), sanitize=False) - with hub.start_span( + with start_span( op=OP.HTTP_CLIENT, - description="%s %s" + name="%s %s" % ( request.method, parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE, ), + origin=HttpxIntegration.origin, ) as span: span.set_data(SPANDATA.HTTP_METHOD, request.method) if parsed_url is not None: @@ -119,8 +126,11 @@ async def send(self, request, **kwargs): span.set_data(SPANDATA.HTTP_QUERY, parsed_url.query) span.set_data(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment) - if should_propagate_trace(hub, str(request.url)): - for key, value in hub.iter_trace_propagation_headers(): + if should_propagate_trace(sentry_sdk.get_client(), str(request.url)): + for ( + key, + value, + ) in sentry_sdk.get_current_scope().iter_trace_propagation_headers(): logger.debug( "[Tracing] Adding `{key}` header {value} to outgoing request to {url}.".format( key=key, value=value, url=request.url @@ -139,6 +149,28 @@ async def send(self, request, **kwargs): span.set_http_status(rv.status_code) span.set_data("reason", rv.reason_phrase) - return rv + with capture_internal_exceptions(): + add_http_request_source(span) + + return rv AsyncClient.send = send + + +def _add_sentry_baggage_to_headers( + headers: "MutableMapping[str, str]", sentry_baggage: str +) -> None: + """Add the Sentry baggage to the headers. + + This function directly mutates the provided headers. The provided sentry_baggage + is appended to the existing baggage. If the baggage already contains Sentry items, + they are stripped out first. + """ + existing_baggage = headers.get(BAGGAGE_HEADER_NAME, "") + stripped_existing_baggage = Baggage.strip_sentry_baggage(existing_baggage) + + separator = "," if len(stripped_existing_baggage) > 0 else "" + + headers[BAGGAGE_HEADER_NAME] = ( + stripped_existing_baggage + separator + sentry_baggage + ) diff --git a/sentry_sdk/integrations/huey.py b/sentry_sdk/integrations/huey.py index 52b0e549a2..1c7626f3fa 100644 --- a/sentry_sdk/integrations/huey.py +++ b/sentry_sdk/integrations/huey.py @@ -1,21 +1,26 @@ -from __future__ import absolute_import - import sys from datetime import datetime -from sentry_sdk._compat import reraise -from sentry_sdk._types import TYPE_CHECKING -from sentry_sdk import Hub -from sentry_sdk.consts import OP -from sentry_sdk.hub import _should_send_default_pii +import sentry_sdk +from sentry_sdk.api import continue_trace, get_baggage, get_traceparent +from sentry_sdk.consts import OP, SPANSTATUS from sentry_sdk.integrations import DidNotEnable, Integration -from sentry_sdk.tracing import Transaction, TRANSACTION_SOURCE_TASK +from sentry_sdk.scope import should_send_default_pii +from sentry_sdk.tracing import ( + BAGGAGE_HEADER_NAME, + SENTRY_TRACE_HEADER_NAME, + TransactionSource, +) from sentry_sdk.utils import ( capture_internal_exceptions, + ensure_integration_enabled, event_from_exception, SENSITIVE_DATA_SUBSTITUTE, + reraise, ) +from typing import TYPE_CHECKING + if TYPE_CHECKING: from typing import Any, Callable, Optional, Union, TypeVar @@ -25,7 +30,7 @@ F = TypeVar("F", bound=Callable[..., Any]) try: - from huey.api import Huey, Result, ResultGroup, Task + from huey.api import Huey, Result, ResultGroup, Task, PeriodicTask from huey.exceptions import CancelExecution, RetryTask, TaskLockedException except ImportError: raise DidNotEnable("Huey is not installed") @@ -36,36 +41,41 @@ class HueyIntegration(Integration): identifier = "huey" + origin = f"auto.queue.{identifier}" @staticmethod - def setup_once(): - # type: () -> None + def setup_once() -> None: patch_enqueue() patch_execute() -def patch_enqueue(): - # type: () -> None +def patch_enqueue() -> None: old_enqueue = Huey.enqueue - def _sentry_enqueue(self, task): - # type: (Huey, Task) -> Optional[Union[Result, ResultGroup]] - hub = Hub.current - - if hub.get_integration(HueyIntegration) is None: - return old_enqueue(self, task) - - with hub.start_span(op=OP.QUEUE_SUBMIT_HUEY, description=task.name): + @ensure_integration_enabled(HueyIntegration, old_enqueue) + def _sentry_enqueue( + self: "Huey", task: "Task" + ) -> "Optional[Union[Result, ResultGroup]]": + with sentry_sdk.start_span( + op=OP.QUEUE_SUBMIT_HUEY, + name=task.name, + origin=HueyIntegration.origin, + ): + if not isinstance(task, PeriodicTask): + # Attach trace propagation data to task kwargs. We do + # not do this for periodic tasks, as these don't + # really have an originating transaction. + task.kwargs["sentry_headers"] = { + BAGGAGE_HEADER_NAME: get_baggage(), + SENTRY_TRACE_HEADER_NAME: get_traceparent(), + } return old_enqueue(self, task) Huey.enqueue = _sentry_enqueue -def _make_event_processor(task): - # type: (Any) -> EventProcessor - def event_processor(event, hint): - # type: (Event, Hint) -> Optional[Event] - +def _make_event_processor(task: "Any") -> "EventProcessor": + def event_processor(event: "Event", hint: "Hint") -> "Optional[Event]": with capture_internal_exceptions(): tags = event.setdefault("tags", {}) tags["huey_task_id"] = task.id @@ -73,12 +83,16 @@ def event_processor(event, hint): extra = event.setdefault("extra", {}) extra["huey-job"] = { "task": task.name, - "args": task.args - if _should_send_default_pii() - else SENSITIVE_DATA_SUBSTITUTE, - "kwargs": task.kwargs - if _should_send_default_pii() - else SENSITIVE_DATA_SUBSTITUTE, + "args": ( + task.args + if should_send_default_pii() + else SENSITIVE_DATA_SUBSTITUTE + ), + "kwargs": ( + task.kwargs + if should_send_default_pii() + else SENSITIVE_DATA_SUBSTITUTE + ), "retry": (task.default_retries or 0) - task.retries, } @@ -87,31 +101,25 @@ def event_processor(event, hint): return event_processor -def _capture_exception(exc_info): - # type: (ExcInfo) -> None - hub = Hub.current +def _capture_exception(exc_info: "ExcInfo") -> None: + scope = sentry_sdk.get_current_scope() if exc_info[0] in HUEY_CONTROL_FLOW_EXCEPTIONS: - hub.scope.transaction.set_status("aborted") + scope.transaction.set_status(SPANSTATUS.ABORTED) return - hub.scope.transaction.set_status("internal_error") + scope.transaction.set_status(SPANSTATUS.INTERNAL_ERROR) event, hint = event_from_exception( exc_info, - client_options=hub.client.options if hub.client else None, + client_options=sentry_sdk.get_client().options, mechanism={"type": HueyIntegration.identifier, "handled": False}, ) - hub.capture_event(event, hint=hint) - + scope.capture_event(event, hint=hint) -def _wrap_task_execute(func): - # type: (F) -> F - def _sentry_execute(*args, **kwargs): - # type: (*Any, **Any) -> Any - hub = Hub.current - if hub.get_integration(HueyIntegration) is None: - return func(*args, **kwargs) +def _wrap_task_execute(func: "F") -> "F": + @ensure_integration_enabled(HueyIntegration, func) + def _sentry_execute(*args: "Any", **kwargs: "Any") -> "Any": try: result = func(*args, **kwargs) except Exception: @@ -124,35 +132,35 @@ def _sentry_execute(*args, **kwargs): return _sentry_execute # type: ignore -def patch_execute(): - # type: () -> None +def patch_execute() -> None: old_execute = Huey._execute - def _sentry_execute(self, task, timestamp=None): - # type: (Huey, Task, Optional[datetime]) -> Any - hub = Hub.current - - if hub.get_integration(HueyIntegration) is None: - return old_execute(self, task, timestamp) - - with hub.push_scope() as scope: + @ensure_integration_enabled(HueyIntegration, old_execute) + def _sentry_execute( + self: "Huey", task: "Task", timestamp: "Optional[datetime]" = None + ) -> "Any": + with sentry_sdk.isolation_scope() as scope: with capture_internal_exceptions(): scope._name = "huey" scope.clear_breadcrumbs() scope.add_event_processor(_make_event_processor(task)) - transaction = Transaction( + sentry_headers = task.kwargs.pop("sentry_headers", None) + + transaction = continue_trace( + sentry_headers or {}, name=task.name, - status="ok", op=OP.QUEUE_TASK_HUEY, - source=TRANSACTION_SOURCE_TASK, + source=TransactionSource.TASK, + origin=HueyIntegration.origin, ) + transaction.set_status(SPANSTATUS.OK) if not getattr(task, "_sentry_is_patched", False): task.execute = _wrap_task_execute(task.execute) task._sentry_is_patched = True - with hub.start_transaction(transaction): + with sentry_sdk.start_transaction(transaction): return old_execute(self, task, timestamp) Huey._execute = _sentry_execute diff --git a/sentry_sdk/integrations/huggingface_hub.py b/sentry_sdk/integrations/huggingface_hub.py new file mode 100644 index 0000000000..39a667dde9 --- /dev/null +++ b/sentry_sdk/integrations/huggingface_hub.py @@ -0,0 +1,372 @@ +import inspect +from functools import wraps + +import sentry_sdk +from sentry_sdk.ai.monitoring import record_token_usage +from sentry_sdk.ai.utils import set_data_normalized +from sentry_sdk.consts import OP, SPANDATA +from sentry_sdk.integrations import DidNotEnable, Integration +from sentry_sdk.scope import should_send_default_pii +from sentry_sdk.tracing_utils import set_span_errored +from sentry_sdk.utils import ( + capture_internal_exceptions, + event_from_exception, +) + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Any, Callable, Iterable + +try: + import huggingface_hub.inference._client +except ImportError: + raise DidNotEnable("Huggingface not installed") + + +class HuggingfaceHubIntegration(Integration): + identifier = "huggingface_hub" + origin = f"auto.ai.{identifier}" + + def __init__( + self: "HuggingfaceHubIntegration", include_prompts: bool = True + ) -> None: + self.include_prompts = include_prompts + + @staticmethod + def setup_once() -> None: + # Other tasks that can be called: https://huggingface.co/docs/huggingface_hub/guides/inference#supported-providers-and-tasks + huggingface_hub.inference._client.InferenceClient.text_generation = ( + _wrap_huggingface_task( + huggingface_hub.inference._client.InferenceClient.text_generation, + OP.GEN_AI_GENERATE_TEXT, + ) + ) + huggingface_hub.inference._client.InferenceClient.chat_completion = ( + _wrap_huggingface_task( + huggingface_hub.inference._client.InferenceClient.chat_completion, + OP.GEN_AI_CHAT, + ) + ) + + +def _capture_exception(exc: "Any") -> None: + set_span_errored() + + event, hint = event_from_exception( + exc, + client_options=sentry_sdk.get_client().options, + mechanism={"type": "huggingface_hub", "handled": False}, + ) + sentry_sdk.capture_event(event, hint=hint) + + +def _wrap_huggingface_task(f: "Callable[..., Any]", op: str) -> "Callable[..., Any]": + @wraps(f) + def new_huggingface_task(*args: "Any", **kwargs: "Any") -> "Any": + integration = sentry_sdk.get_client().get_integration(HuggingfaceHubIntegration) + if integration is None: + return f(*args, **kwargs) + + prompt = None + if "prompt" in kwargs: + prompt = kwargs["prompt"] + elif "messages" in kwargs: + prompt = kwargs["messages"] + elif len(args) >= 2: + if isinstance(args[1], str) or isinstance(args[1], list): + prompt = args[1] + + if prompt is None: + # invalid call, dont instrument, let it return error + return f(*args, **kwargs) + + client = args[0] + model = client.model or kwargs.get("model") or "" + operation_name = op.split(".")[-1] + + span = sentry_sdk.start_span( + op=op, + name=f"{operation_name} {model}", + origin=HuggingfaceHubIntegration.origin, + ) + span.__enter__() + + span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, operation_name) + + if model: + span.set_data(SPANDATA.GEN_AI_REQUEST_MODEL, model) + + # Input attributes + if should_send_default_pii() and integration.include_prompts: + set_data_normalized( + span, SPANDATA.GEN_AI_REQUEST_MESSAGES, prompt, unpack=False + ) + + attribute_mapping = { + "tools": SPANDATA.GEN_AI_REQUEST_AVAILABLE_TOOLS, + "frequency_penalty": SPANDATA.GEN_AI_REQUEST_FREQUENCY_PENALTY, + "max_tokens": SPANDATA.GEN_AI_REQUEST_MAX_TOKENS, + "presence_penalty": SPANDATA.GEN_AI_REQUEST_PRESENCE_PENALTY, + "temperature": SPANDATA.GEN_AI_REQUEST_TEMPERATURE, + "top_p": SPANDATA.GEN_AI_REQUEST_TOP_P, + "top_k": SPANDATA.GEN_AI_REQUEST_TOP_K, + "stream": SPANDATA.GEN_AI_RESPONSE_STREAMING, + } + + for attribute, span_attribute in attribute_mapping.items(): + value = kwargs.get(attribute, None) + if value is not None: + if isinstance(value, (int, float, bool, str)): + span.set_data(span_attribute, value) + else: + set_data_normalized(span, span_attribute, value, unpack=False) + + # LLM Execution + try: + res = f(*args, **kwargs) + except Exception as e: + _capture_exception(e) + span.__exit__(None, None, None) + raise e from None + + # Output attributes + finish_reason = None + response_model = None + response_text_buffer: "list[str]" = [] + tokens_used = 0 + tool_calls = None + usage = None + + with capture_internal_exceptions(): + if isinstance(res, str) and res is not None: + response_text_buffer.append(res) + + if hasattr(res, "generated_text") and res.generated_text is not None: + response_text_buffer.append(res.generated_text) + + if hasattr(res, "model") and res.model is not None: + response_model = res.model + + if hasattr(res, "details") and hasattr(res.details, "finish_reason"): + finish_reason = res.details.finish_reason + + if ( + hasattr(res, "details") + and hasattr(res.details, "generated_tokens") + and res.details.generated_tokens is not None + ): + tokens_used = res.details.generated_tokens + + if hasattr(res, "usage") and res.usage is not None: + usage = res.usage + + if hasattr(res, "choices") and res.choices is not None: + for choice in res.choices: + if hasattr(choice, "finish_reason"): + finish_reason = choice.finish_reason + if hasattr(choice, "message") and hasattr( + choice.message, "tool_calls" + ): + tool_calls = choice.message.tool_calls + if ( + hasattr(choice, "message") + and hasattr(choice.message, "content") + and choice.message.content is not None + ): + response_text_buffer.append(choice.message.content) + + if response_model is not None: + span.set_data(SPANDATA.GEN_AI_RESPONSE_MODEL, response_model) + + if finish_reason is not None: + set_data_normalized( + span, + SPANDATA.GEN_AI_RESPONSE_FINISH_REASONS, + finish_reason, + ) + + if should_send_default_pii() and integration.include_prompts: + if tool_calls is not None and len(tool_calls) > 0: + set_data_normalized( + span, + SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS, + tool_calls, + unpack=False, + ) + + if len(response_text_buffer) > 0: + text_response = "".join(response_text_buffer) + if text_response: + set_data_normalized( + span, + SPANDATA.GEN_AI_RESPONSE_TEXT, + text_response, + ) + + if usage is not None: + record_token_usage( + span, + input_tokens=usage.prompt_tokens, + output_tokens=usage.completion_tokens, + total_tokens=usage.total_tokens, + ) + elif tokens_used > 0: + record_token_usage( + span, + total_tokens=tokens_used, + ) + + # If the response is not a generator (meaning a streaming response) + # we are done and can return the response + if not inspect.isgenerator(res): + span.__exit__(None, None, None) + return res + + if kwargs.get("details", False): + # text-generation stream output + def new_details_iterator() -> "Iterable[Any]": + finish_reason = None + response_text_buffer: "list[str]" = [] + tokens_used = 0 + + with capture_internal_exceptions(): + for chunk in res: + if ( + hasattr(chunk, "token") + and hasattr(chunk.token, "text") + and chunk.token.text is not None + ): + response_text_buffer.append(chunk.token.text) + + if hasattr(chunk, "details") and hasattr( + chunk.details, "finish_reason" + ): + finish_reason = chunk.details.finish_reason + + if ( + hasattr(chunk, "details") + and hasattr(chunk.details, "generated_tokens") + and chunk.details.generated_tokens is not None + ): + tokens_used = chunk.details.generated_tokens + + yield chunk + + if finish_reason is not None: + set_data_normalized( + span, + SPANDATA.GEN_AI_RESPONSE_FINISH_REASONS, + finish_reason, + ) + + if should_send_default_pii() and integration.include_prompts: + if len(response_text_buffer) > 0: + text_response = "".join(response_text_buffer) + if text_response: + set_data_normalized( + span, + SPANDATA.GEN_AI_RESPONSE_TEXT, + text_response, + ) + + if tokens_used > 0: + record_token_usage( + span, + total_tokens=tokens_used, + ) + + span.__exit__(None, None, None) + + return new_details_iterator() + + else: + # chat-completion stream output + def new_iterator() -> "Iterable[str]": + finish_reason = None + response_model = None + response_text_buffer: "list[str]" = [] + tool_calls = None + usage = None + + with capture_internal_exceptions(): + for chunk in res: + if hasattr(chunk, "model") and chunk.model is not None: + response_model = chunk.model + + if hasattr(chunk, "usage") and chunk.usage is not None: + usage = chunk.usage + + if isinstance(chunk, str): + if chunk is not None: + response_text_buffer.append(chunk) + + if hasattr(chunk, "choices") and chunk.choices is not None: + for choice in chunk.choices: + if ( + hasattr(choice, "delta") + and hasattr(choice.delta, "content") + and choice.delta.content is not None + ): + response_text_buffer.append( + choice.delta.content + ) + + if ( + hasattr(choice, "finish_reason") + and choice.finish_reason is not None + ): + finish_reason = choice.finish_reason + + if ( + hasattr(choice, "delta") + and hasattr(choice.delta, "tool_calls") + and choice.delta.tool_calls is not None + ): + tool_calls = choice.delta.tool_calls + + yield chunk + + if response_model is not None: + span.set_data( + SPANDATA.GEN_AI_RESPONSE_MODEL, response_model + ) + + if finish_reason is not None: + set_data_normalized( + span, + SPANDATA.GEN_AI_RESPONSE_FINISH_REASONS, + finish_reason, + ) + + if should_send_default_pii() and integration.include_prompts: + if tool_calls is not None and len(tool_calls) > 0: + set_data_normalized( + span, + SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS, + tool_calls, + unpack=False, + ) + + if len(response_text_buffer) > 0: + text_response = "".join(response_text_buffer) + if text_response: + set_data_normalized( + span, + SPANDATA.GEN_AI_RESPONSE_TEXT, + text_response, + ) + + if usage is not None: + record_token_usage( + span, + input_tokens=usage.prompt_tokens, + output_tokens=usage.completion_tokens, + total_tokens=usage.total_tokens, + ) + + span.__exit__(None, None, None) + + return new_iterator() + + return new_huggingface_task diff --git a/sentry_sdk/integrations/langchain.py b/sentry_sdk/integrations/langchain.py new file mode 100644 index 0000000000..950f437d4c --- /dev/null +++ b/sentry_sdk/integrations/langchain.py @@ -0,0 +1,1162 @@ +import contextvars +import itertools +import sys +import warnings +from collections import OrderedDict +from functools import wraps +from typing import TYPE_CHECKING + +import sentry_sdk +from sentry_sdk.ai.monitoring import set_ai_pipeline_name +from sentry_sdk.ai.utils import ( + GEN_AI_ALLOWED_MESSAGE_ROLES, + get_start_span_function, + normalize_message_roles, + set_data_normalized, + truncate_and_annotate_messages, +) +from sentry_sdk.consts import OP, SPANDATA +from sentry_sdk.integrations import DidNotEnable, Integration +from sentry_sdk.scope import should_send_default_pii +from sentry_sdk.tracing_utils import _get_value, set_span_errored +from sentry_sdk.utils import capture_internal_exceptions, logger + +if TYPE_CHECKING: + from typing import ( + Any, + AsyncIterator, + Callable, + Dict, + Iterator, + List, + Optional, + Union, + ) + from uuid import UUID + + from sentry_sdk.tracing import Span + + +try: + from langchain_core.agents import AgentFinish + from langchain_core.callbacks import ( + BaseCallbackHandler, + BaseCallbackManager, + Callbacks, + manager, + ) + from langchain_core.messages import BaseMessage + from langchain_core.outputs import LLMResult + +except ImportError: + raise DidNotEnable("langchain not installed") + + +try: + # >=v1 + from langchain_classic.agents import AgentExecutor # type: ignore[import-not-found] +except ImportError: + try: + # None: + """Push an agent name onto the stack.""" + stack = _agent_stack.get() + if stack is None: + stack = [] + else: + # Copy the list to maintain contextvar isolation across async contexts + stack = stack.copy() + stack.append(agent_name) + _agent_stack.set(stack) + + +def _pop_agent() -> "Optional[str]": + """Pop an agent name from the stack and return it.""" + stack = _agent_stack.get() + if stack: + # Copy the list to maintain contextvar isolation across async contexts + stack = stack.copy() + agent_name = stack.pop() + _agent_stack.set(stack) + return agent_name + return None + + +def _get_current_agent() -> "Optional[str]": + """Get the current agent name (top of stack) without removing it.""" + stack = _agent_stack.get() + if stack: + return stack[-1] + return None + + +class LangchainIntegration(Integration): + identifier = "langchain" + origin = f"auto.ai.{identifier}" + + def __init__( + self: "LangchainIntegration", + include_prompts: bool = True, + max_spans: "Optional[int]" = None, + ) -> None: + self.include_prompts = include_prompts + self.max_spans = max_spans + + if max_spans is not None: + warnings.warn( + "The `max_spans` parameter of `LangchainIntegration` is " + "deprecated and will be removed in version 3.0 of sentry-sdk.", + DeprecationWarning, + stacklevel=2, + ) + + @staticmethod + def setup_once() -> None: + manager._configure = _wrap_configure(manager._configure) + + if AgentExecutor is not None: + AgentExecutor.invoke = _wrap_agent_executor_invoke(AgentExecutor.invoke) + AgentExecutor.stream = _wrap_agent_executor_stream(AgentExecutor.stream) + + # Patch embeddings providers + _patch_embeddings_provider(OpenAIEmbeddings) + _patch_embeddings_provider(AzureOpenAIEmbeddings) + _patch_embeddings_provider(VertexAIEmbeddings) + _patch_embeddings_provider(BedrockEmbeddings) + _patch_embeddings_provider(CohereEmbeddings) + _patch_embeddings_provider(MistralAIEmbeddings) + _patch_embeddings_provider(HuggingFaceEmbeddings) + _patch_embeddings_provider(OllamaEmbeddings) + + +class WatchedSpan: + span: "Span" = None # type: ignore[assignment] + children: "List[WatchedSpan]" = [] + is_pipeline: bool = False + + def __init__(self, span: "Span") -> None: + self.span = span + + +class SentryLangchainCallback(BaseCallbackHandler): # type: ignore[misc] + """Callback handler that creates Sentry spans.""" + + def __init__( + self, max_span_map_size: "Optional[int]", include_prompts: bool + ) -> None: + self.span_map: "OrderedDict[UUID, WatchedSpan]" = OrderedDict() + self.max_span_map_size = max_span_map_size + self.include_prompts = include_prompts + + def gc_span_map(self) -> None: + if self.max_span_map_size is not None: + while len(self.span_map) > self.max_span_map_size: + run_id, watched_span = self.span_map.popitem(last=False) + self._exit_span(watched_span, run_id) + + def _handle_error(self, run_id: "UUID", error: "Any") -> None: + with capture_internal_exceptions(): + if not run_id or run_id not in self.span_map: + return + + span_data = self.span_map[run_id] + span = span_data.span + set_span_errored(span) + + sentry_sdk.capture_exception(error, span.scope) + + span.__exit__(None, None, None) + del self.span_map[run_id] + + def _normalize_langchain_message(self, message: "BaseMessage") -> "Any": + parsed = {"role": message.type, "content": message.content} + parsed.update(message.additional_kwargs) + return parsed + + def _create_span( + self: "SentryLangchainCallback", + run_id: "UUID", + parent_id: "Optional[Any]", + **kwargs: "Any", + ) -> "WatchedSpan": + watched_span: "Optional[WatchedSpan]" = None + if parent_id: + parent_span: "Optional[WatchedSpan]" = self.span_map.get(parent_id) + if parent_span: + watched_span = WatchedSpan(parent_span.span.start_child(**kwargs)) + parent_span.children.append(watched_span) + + if watched_span is None: + watched_span = WatchedSpan(sentry_sdk.start_span(**kwargs)) + + watched_span.span.__enter__() + self.span_map[run_id] = watched_span + self.gc_span_map() + return watched_span + + def _exit_span( + self: "SentryLangchainCallback", span_data: "WatchedSpan", run_id: "UUID" + ) -> None: + if span_data.is_pipeline: + set_ai_pipeline_name(None) + + span_data.span.__exit__(None, None, None) + del self.span_map[run_id] + + def on_llm_start( + self: "SentryLangchainCallback", + serialized: "Dict[str, Any]", + prompts: "List[str]", + *, + run_id: "UUID", + tags: "Optional[List[str]]" = None, + parent_run_id: "Optional[UUID]" = None, + metadata: "Optional[Dict[str, Any]]" = None, + **kwargs: "Any", + ) -> "Any": + """Run when LLM starts running.""" + with capture_internal_exceptions(): + if not run_id: + return + + all_params = kwargs.get("invocation_params", {}) + all_params.update(serialized.get("kwargs", {})) + + model = ( + all_params.get("model") + or all_params.get("model_name") + or all_params.get("model_id") + or "" + ) + + watched_span = self._create_span( + run_id, + parent_run_id, + op=OP.GEN_AI_PIPELINE, + name=kwargs.get("name") or "Langchain LLM call", + origin=LangchainIntegration.origin, + ) + span = watched_span.span + + if model: + span.set_data( + SPANDATA.GEN_AI_REQUEST_MODEL, + model, + ) + + ai_type = all_params.get("_type", "") + if "anthropic" in ai_type: + span.set_data(SPANDATA.GEN_AI_SYSTEM, "anthropic") + elif "openai" in ai_type: + span.set_data(SPANDATA.GEN_AI_SYSTEM, "openai") + + for key, attribute in DATA_FIELDS.items(): + if key in all_params and all_params[key] is not None: + set_data_normalized(span, attribute, all_params[key], unpack=False) + + _set_tools_on_span(span, all_params.get("tools")) + + if should_send_default_pii() and self.include_prompts: + normalized_messages = [ + { + "role": GEN_AI_ALLOWED_MESSAGE_ROLES.USER, + "content": {"type": "text", "text": prompt}, + } + for prompt in prompts + ] + scope = sentry_sdk.get_current_scope() + messages_data = truncate_and_annotate_messages( + normalized_messages, span, scope + ) + if messages_data is not None: + set_data_normalized( + span, + SPANDATA.GEN_AI_REQUEST_MESSAGES, + messages_data, + unpack=False, + ) + + def on_chat_model_start( + self: "SentryLangchainCallback", + serialized: "Dict[str, Any]", + messages: "List[List[BaseMessage]]", + *, + run_id: "UUID", + **kwargs: "Any", + ) -> "Any": + """Run when Chat Model starts running.""" + with capture_internal_exceptions(): + if not run_id: + return + + all_params = kwargs.get("invocation_params", {}) + all_params.update(serialized.get("kwargs", {})) + + model = ( + all_params.get("model") + or all_params.get("model_name") + or all_params.get("model_id") + or "" + ) + + watched_span = self._create_span( + run_id, + kwargs.get("parent_run_id"), + op=OP.GEN_AI_CHAT, + name=f"chat {model}".strip(), + origin=LangchainIntegration.origin, + ) + span = watched_span.span + + span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "chat") + if model: + span.set_data(SPANDATA.GEN_AI_REQUEST_MODEL, model) + + ai_type = all_params.get("_type", "") + if "anthropic" in ai_type: + span.set_data(SPANDATA.GEN_AI_SYSTEM, "anthropic") + elif "openai" in ai_type: + span.set_data(SPANDATA.GEN_AI_SYSTEM, "openai") + + agent_name = _get_current_agent() + if agent_name: + span.set_data(SPANDATA.GEN_AI_AGENT_NAME, agent_name) + + for key, attribute in DATA_FIELDS.items(): + if key in all_params and all_params[key] is not None: + set_data_normalized(span, attribute, all_params[key], unpack=False) + + _set_tools_on_span(span, all_params.get("tools")) + + if should_send_default_pii() and self.include_prompts: + normalized_messages = [] + for list_ in messages: + for message in list_: + normalized_messages.append( + self._normalize_langchain_message(message) + ) + normalized_messages = normalize_message_roles(normalized_messages) + scope = sentry_sdk.get_current_scope() + messages_data = truncate_and_annotate_messages( + normalized_messages, span, scope + ) + if messages_data is not None: + set_data_normalized( + span, + SPANDATA.GEN_AI_REQUEST_MESSAGES, + messages_data, + unpack=False, + ) + + def on_chat_model_end( + self: "SentryLangchainCallback", + response: "LLMResult", + *, + run_id: "UUID", + **kwargs: "Any", + ) -> "Any": + """Run when Chat Model ends running.""" + with capture_internal_exceptions(): + if not run_id or run_id not in self.span_map: + return + + span_data = self.span_map[run_id] + span = span_data.span + + if should_send_default_pii() and self.include_prompts: + set_data_normalized( + span, + SPANDATA.GEN_AI_RESPONSE_TEXT, + [[x.text for x in list_] for list_ in response.generations], + ) + + _record_token_usage(span, response) + self._exit_span(span_data, run_id) + + def on_llm_end( + self: "SentryLangchainCallback", + response: "LLMResult", + *, + run_id: "UUID", + **kwargs: "Any", + ) -> "Any": + """Run when LLM ends running.""" + with capture_internal_exceptions(): + if not run_id or run_id not in self.span_map: + return + + span_data = self.span_map[run_id] + span = span_data.span + + try: + generation = response.generations[0][0] + except IndexError: + generation = None + + if generation is not None: + try: + response_model = generation.message.response_metadata.get( + "model_name" + ) + if response_model is not None: + span.set_data(SPANDATA.GEN_AI_RESPONSE_MODEL, response_model) + except AttributeError: + pass + + try: + finish_reason = generation.generation_info.get("finish_reason") + if finish_reason is not None: + span.set_data( + SPANDATA.GEN_AI_RESPONSE_FINISH_REASONS, finish_reason + ) + except AttributeError: + pass + + try: + if should_send_default_pii() and self.include_prompts: + tool_calls = getattr(generation.message, "tool_calls", None) + if tool_calls is not None and tool_calls != []: + set_data_normalized( + span, + SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS, + tool_calls, + unpack=False, + ) + except AttributeError: + pass + + if should_send_default_pii() and self.include_prompts: + set_data_normalized( + span, + SPANDATA.GEN_AI_RESPONSE_TEXT, + [[x.text for x in list_] for list_ in response.generations], + ) + + _record_token_usage(span, response) + self._exit_span(span_data, run_id) + + def on_llm_error( + self: "SentryLangchainCallback", + error: "Union[Exception, KeyboardInterrupt]", + *, + run_id: "UUID", + **kwargs: "Any", + ) -> "Any": + """Run when LLM errors.""" + self._handle_error(run_id, error) + + def on_chat_model_error( + self: "SentryLangchainCallback", + error: "Union[Exception, KeyboardInterrupt]", + *, + run_id: "UUID", + **kwargs: "Any", + ) -> "Any": + """Run when Chat Model errors.""" + self._handle_error(run_id, error) + + def on_agent_finish( + self: "SentryLangchainCallback", + finish: "AgentFinish", + *, + run_id: "UUID", + **kwargs: "Any", + ) -> "Any": + with capture_internal_exceptions(): + if not run_id or run_id not in self.span_map: + return + + span_data = self.span_map[run_id] + span = span_data.span + + if should_send_default_pii() and self.include_prompts: + set_data_normalized( + span, SPANDATA.GEN_AI_RESPONSE_TEXT, finish.return_values.items() + ) + + self._exit_span(span_data, run_id) + + def on_tool_start( + self: "SentryLangchainCallback", + serialized: "Dict[str, Any]", + input_str: str, + *, + run_id: "UUID", + **kwargs: "Any", + ) -> "Any": + """Run when tool starts running.""" + with capture_internal_exceptions(): + if not run_id: + return + + tool_name = serialized.get("name") or kwargs.get("name") or "" + + watched_span = self._create_span( + run_id, + kwargs.get("parent_run_id"), + op=OP.GEN_AI_EXECUTE_TOOL, + name=f"execute_tool {tool_name}".strip(), + origin=LangchainIntegration.origin, + ) + span = watched_span.span + + span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "execute_tool") + span.set_data(SPANDATA.GEN_AI_TOOL_NAME, tool_name) + + tool_description = serialized.get("description") + if tool_description is not None: + span.set_data(SPANDATA.GEN_AI_TOOL_DESCRIPTION, tool_description) + + agent_name = _get_current_agent() + if agent_name: + span.set_data(SPANDATA.GEN_AI_AGENT_NAME, agent_name) + + if should_send_default_pii() and self.include_prompts: + set_data_normalized( + span, + SPANDATA.GEN_AI_TOOL_INPUT, + kwargs.get("inputs", [input_str]), + ) + + def on_tool_end( + self: "SentryLangchainCallback", output: str, *, run_id: "UUID", **kwargs: "Any" + ) -> "Any": + """Run when tool ends running.""" + with capture_internal_exceptions(): + if not run_id or run_id not in self.span_map: + return + + span_data = self.span_map[run_id] + span = span_data.span + + if should_send_default_pii() and self.include_prompts: + set_data_normalized(span, SPANDATA.GEN_AI_TOOL_OUTPUT, output) + + self._exit_span(span_data, run_id) + + def on_tool_error( + self, + error: "SentryLangchainCallback", + *args: "Union[Exception, KeyboardInterrupt]", + run_id: "UUID", + **kwargs: "Any", + ) -> "Any": + """Run when tool errors.""" + self._handle_error(run_id, error) + + +def _extract_tokens( + token_usage: "Any", +) -> "tuple[Optional[int], Optional[int], Optional[int]]": + if not token_usage: + return None, None, None + + input_tokens = _get_value(token_usage, "prompt_tokens") or _get_value( + token_usage, "input_tokens" + ) + output_tokens = _get_value(token_usage, "completion_tokens") or _get_value( + token_usage, "output_tokens" + ) + total_tokens = _get_value(token_usage, "total_tokens") + + return input_tokens, output_tokens, total_tokens + + +def _extract_tokens_from_generations( + generations: "Any", +) -> "tuple[Optional[int], Optional[int], Optional[int]]": + """Extract token usage from response.generations structure.""" + if not generations: + return None, None, None + + total_input = 0 + total_output = 0 + total_total = 0 + + for gen_list in generations: + for gen in gen_list: + token_usage = _get_token_usage(gen) + input_tokens, output_tokens, total_tokens = _extract_tokens(token_usage) + total_input += input_tokens if input_tokens is not None else 0 + total_output += output_tokens if output_tokens is not None else 0 + total_total += total_tokens if total_tokens is not None else 0 + + return ( + total_input if total_input > 0 else None, + total_output if total_output > 0 else None, + total_total if total_total > 0 else None, + ) + + +def _get_token_usage(obj: "Any") -> "Optional[Dict[str, Any]]": + """ + Check multiple paths to extract token usage from different objects. + """ + possible_names = ("usage", "token_usage", "usage_metadata") + + message = _get_value(obj, "message") + if message is not None: + for name in possible_names: + usage = _get_value(message, name) + if usage is not None: + return usage + + llm_output = _get_value(obj, "llm_output") + if llm_output is not None: + for name in possible_names: + usage = _get_value(llm_output, name) + if usage is not None: + return usage + + for name in possible_names: + usage = _get_value(obj, name) + if usage is not None: + return usage + + return None + + +def _record_token_usage(span: "Span", response: "Any") -> None: + token_usage = _get_token_usage(response) + if token_usage: + input_tokens, output_tokens, total_tokens = _extract_tokens(token_usage) + else: + input_tokens, output_tokens, total_tokens = _extract_tokens_from_generations( + response.generations + ) + + if input_tokens is not None: + span.set_data(SPANDATA.GEN_AI_USAGE_INPUT_TOKENS, input_tokens) + + if output_tokens is not None: + span.set_data(SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS, output_tokens) + + if total_tokens is not None: + span.set_data(SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS, total_tokens) + + +def _get_request_data( + obj: "Any", args: "Any", kwargs: "Any" +) -> "tuple[Optional[str], Optional[List[Any]]]": + """ + Get the agent name and available tools for the agent. + """ + agent = getattr(obj, "agent", None) + runnable = getattr(agent, "runnable", None) + runnable_config = getattr(runnable, "config", {}) + tools = ( + getattr(obj, "tools", None) + or getattr(agent, "tools", None) + or runnable_config.get("tools") + or runnable_config.get("available_tools") + ) + tools = tools if tools and len(tools) > 0 else None + + try: + agent_name = None + if len(args) > 1: + agent_name = args[1].get("run_name") + if agent_name is None: + agent_name = runnable_config.get("run_name") + except Exception: + pass + + return (agent_name, tools) + + +def _simplify_langchain_tools(tools: "Any") -> "Optional[List[Any]]": + """Parse and simplify tools into a cleaner format.""" + if not tools: + return None + + if not isinstance(tools, (list, tuple)): + return None + + simplified_tools = [] + for tool in tools: + try: + if isinstance(tool, dict): + if "function" in tool and isinstance(tool["function"], dict): + func = tool["function"] + simplified_tool = { + "name": func.get("name"), + "description": func.get("description"), + } + if simplified_tool["name"]: + simplified_tools.append(simplified_tool) + elif "name" in tool: + simplified_tool = { + "name": tool.get("name"), + "description": tool.get("description"), + } + simplified_tools.append(simplified_tool) + else: + name = ( + tool.get("name") + or tool.get("tool_name") + or tool.get("function_name") + ) + if name: + simplified_tools.append( + { + "name": name, + "description": tool.get("description") + or tool.get("desc"), + } + ) + elif hasattr(tool, "name"): + simplified_tool = { + "name": getattr(tool, "name", None), + "description": getattr(tool, "description", None) + or getattr(tool, "desc", None), + } + if simplified_tool["name"]: + simplified_tools.append(simplified_tool) + elif hasattr(tool, "__name__"): + simplified_tools.append( + { + "name": tool.__name__, + "description": getattr(tool, "__doc__", None), + } + ) + else: + tool_str = str(tool) + if tool_str and tool_str != "": + simplified_tools.append({"name": tool_str, "description": None}) + except Exception: + continue + + return simplified_tools if simplified_tools else None + + +def _set_tools_on_span(span: "Span", tools: "Any") -> None: + """Set available tools data on a span if tools are provided.""" + if tools is not None: + simplified_tools = _simplify_langchain_tools(tools) + if simplified_tools: + set_data_normalized( + span, + SPANDATA.GEN_AI_REQUEST_AVAILABLE_TOOLS, + simplified_tools, + unpack=False, + ) + + +def _wrap_configure(f: "Callable[..., Any]") -> "Callable[..., Any]": + @wraps(f) + def new_configure( + callback_manager_cls: type, + inheritable_callbacks: "Callbacks" = None, + local_callbacks: "Callbacks" = None, + *args: "Any", + **kwargs: "Any", + ) -> "Any": + integration = sentry_sdk.get_client().get_integration(LangchainIntegration) + if integration is None: + return f( + callback_manager_cls, + inheritable_callbacks, + local_callbacks, + *args, + **kwargs, + ) + + local_callbacks = local_callbacks or [] + + # Handle each possible type of local_callbacks. For each type, we + # extract the list of callbacks to check for SentryLangchainCallback, + # and define a function that would add the SentryLangchainCallback + # to the existing callbacks list. + if isinstance(local_callbacks, BaseCallbackManager): + callbacks_list = local_callbacks.handlers + elif isinstance(local_callbacks, BaseCallbackHandler): + callbacks_list = [local_callbacks] + elif isinstance(local_callbacks, list): + callbacks_list = local_callbacks + else: + logger.debug("Unknown callback type: %s", local_callbacks) + # Just proceed with original function call + return f( + callback_manager_cls, + inheritable_callbacks, + local_callbacks, + *args, + **kwargs, + ) + + # Handle each possible type of inheritable_callbacks. + if isinstance(inheritable_callbacks, BaseCallbackManager): + inheritable_callbacks_list = inheritable_callbacks.handlers + elif isinstance(inheritable_callbacks, list): + inheritable_callbacks_list = inheritable_callbacks + else: + inheritable_callbacks_list = [] + + if not any( + isinstance(cb, SentryLangchainCallback) + for cb in itertools.chain(callbacks_list, inheritable_callbacks_list) + ): + sentry_handler = SentryLangchainCallback( + integration.max_spans, + integration.include_prompts, + ) + if isinstance(local_callbacks, BaseCallbackManager): + local_callbacks = local_callbacks.copy() + local_callbacks.handlers = [ + *local_callbacks.handlers, + sentry_handler, + ] + elif isinstance(local_callbacks, BaseCallbackHandler): + local_callbacks = [local_callbacks, sentry_handler] + else: + local_callbacks = [*local_callbacks, sentry_handler] + + return f( + callback_manager_cls, + inheritable_callbacks, + local_callbacks, + *args, + **kwargs, + ) + + return new_configure + + +def _wrap_agent_executor_invoke(f: "Callable[..., Any]") -> "Callable[..., Any]": + @wraps(f) + def new_invoke(self: "Any", *args: "Any", **kwargs: "Any") -> "Any": + integration = sentry_sdk.get_client().get_integration(LangchainIntegration) + if integration is None: + return f(self, *args, **kwargs) + + agent_name, tools = _get_request_data(self, args, kwargs) + start_span_function = get_start_span_function() + + with start_span_function( + op=OP.GEN_AI_INVOKE_AGENT, + name=f"invoke_agent {agent_name}" if agent_name else "invoke_agent", + origin=LangchainIntegration.origin, + ) as span: + _push_agent(agent_name) + try: + if agent_name: + span.set_data(SPANDATA.GEN_AI_AGENT_NAME, agent_name) + + span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "invoke_agent") + span.set_data(SPANDATA.GEN_AI_RESPONSE_STREAMING, False) + + _set_tools_on_span(span, tools) + + # Run the agent + result = f(self, *args, **kwargs) + + input = result.get("input") + if ( + input is not None + and should_send_default_pii() + and integration.include_prompts + ): + normalized_messages = normalize_message_roles([input]) + scope = sentry_sdk.get_current_scope() + messages_data = truncate_and_annotate_messages( + normalized_messages, span, scope + ) + if messages_data is not None: + set_data_normalized( + span, + SPANDATA.GEN_AI_REQUEST_MESSAGES, + messages_data, + unpack=False, + ) + + output = result.get("output") + if ( + output is not None + and should_send_default_pii() + and integration.include_prompts + ): + set_data_normalized(span, SPANDATA.GEN_AI_RESPONSE_TEXT, output) + + return result + finally: + # Ensure agent is popped even if an exception occurs + _pop_agent() + + return new_invoke + + +def _wrap_agent_executor_stream(f: "Callable[..., Any]") -> "Callable[..., Any]": + @wraps(f) + def new_stream(self: "Any", *args: "Any", **kwargs: "Any") -> "Any": + integration = sentry_sdk.get_client().get_integration(LangchainIntegration) + if integration is None: + return f(self, *args, **kwargs) + + agent_name, tools = _get_request_data(self, args, kwargs) + start_span_function = get_start_span_function() + + span = start_span_function( + op=OP.GEN_AI_INVOKE_AGENT, + name=f"invoke_agent {agent_name}" if agent_name else "invoke_agent", + origin=LangchainIntegration.origin, + ) + span.__enter__() + + _push_agent(agent_name) + + if agent_name: + span.set_data(SPANDATA.GEN_AI_AGENT_NAME, agent_name) + + span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "invoke_agent") + span.set_data(SPANDATA.GEN_AI_RESPONSE_STREAMING, True) + + _set_tools_on_span(span, tools) + + input = args[0].get("input") if len(args) >= 1 else None + if ( + input is not None + and should_send_default_pii() + and integration.include_prompts + ): + normalized_messages = normalize_message_roles([input]) + scope = sentry_sdk.get_current_scope() + messages_data = truncate_and_annotate_messages( + normalized_messages, span, scope + ) + if messages_data is not None: + set_data_normalized( + span, + SPANDATA.GEN_AI_REQUEST_MESSAGES, + messages_data, + unpack=False, + ) + + # Run the agent + result = f(self, *args, **kwargs) + + old_iterator = result + + def new_iterator() -> "Iterator[Any]": + exc_info: "tuple[Any, Any, Any]" = (None, None, None) + try: + for event in old_iterator: + yield event + + try: + output = event.get("output") + except Exception: + output = None + + if ( + output is not None + and should_send_default_pii() + and integration.include_prompts + ): + set_data_normalized(span, SPANDATA.GEN_AI_RESPONSE_TEXT, output) + except Exception: + exc_info = sys.exc_info() + set_span_errored(span) + raise + finally: + # Ensure cleanup happens even if iterator is abandoned or fails + _pop_agent() + span.__exit__(*exc_info) + + async def new_iterator_async() -> "AsyncIterator[Any]": + exc_info: "tuple[Any, Any, Any]" = (None, None, None) + try: + async for event in old_iterator: + yield event + + try: + output = event.get("output") + except Exception: + output = None + + if ( + output is not None + and should_send_default_pii() + and integration.include_prompts + ): + set_data_normalized(span, SPANDATA.GEN_AI_RESPONSE_TEXT, output) + except Exception: + exc_info = sys.exc_info() + set_span_errored(span) + raise + finally: + # Ensure cleanup happens even if iterator is abandoned or fails + _pop_agent() + span.__exit__(*exc_info) + + if str(type(result)) == "": + result = new_iterator_async() + else: + result = new_iterator() + + return result + + return new_stream + + +def _patch_embeddings_provider(provider_class: "Any") -> None: + """Patch an embeddings provider class with monitoring wrappers.""" + if provider_class is None: + return + + if hasattr(provider_class, "embed_documents"): + provider_class.embed_documents = _wrap_embedding_method( + provider_class.embed_documents + ) + if hasattr(provider_class, "embed_query"): + provider_class.embed_query = _wrap_embedding_method(provider_class.embed_query) + if hasattr(provider_class, "aembed_documents"): + provider_class.aembed_documents = _wrap_async_embedding_method( + provider_class.aembed_documents + ) + if hasattr(provider_class, "aembed_query"): + provider_class.aembed_query = _wrap_async_embedding_method( + provider_class.aembed_query + ) + + +def _wrap_embedding_method(f: "Callable[..., Any]") -> "Callable[..., Any]": + """Wrap sync embedding methods (embed_documents and embed_query).""" + + @wraps(f) + def new_embedding_method(self: "Any", *args: "Any", **kwargs: "Any") -> "Any": + integration = sentry_sdk.get_client().get_integration(LangchainIntegration) + if integration is None: + return f(self, *args, **kwargs) + + model_name = getattr(self, "model", None) or getattr(self, "model_name", None) + with sentry_sdk.start_span( + op=OP.GEN_AI_EMBEDDINGS, + name=f"embeddings {model_name}" if model_name else "embeddings", + origin=LangchainIntegration.origin, + ) as span: + span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "embeddings") + if model_name: + span.set_data(SPANDATA.GEN_AI_REQUEST_MODEL, model_name) + + # Capture input if PII is allowed + if ( + should_send_default_pii() + and integration.include_prompts + and len(args) > 0 + ): + input_data = args[0] + # Normalize to list format + texts = input_data if isinstance(input_data, list) else [input_data] + set_data_normalized( + span, SPANDATA.GEN_AI_EMBEDDINGS_INPUT, texts, unpack=False + ) + + result = f(self, *args, **kwargs) + return result + + return new_embedding_method + + +def _wrap_async_embedding_method(f: "Callable[..., Any]") -> "Callable[..., Any]": + """Wrap async embedding methods (aembed_documents and aembed_query).""" + + @wraps(f) + async def new_async_embedding_method( + self: "Any", *args: "Any", **kwargs: "Any" + ) -> "Any": + integration = sentry_sdk.get_client().get_integration(LangchainIntegration) + if integration is None: + return await f(self, *args, **kwargs) + + model_name = getattr(self, "model", None) or getattr(self, "model_name", None) + with sentry_sdk.start_span( + op=OP.GEN_AI_EMBEDDINGS, + name=f"embeddings {model_name}" if model_name else "embeddings", + origin=LangchainIntegration.origin, + ) as span: + span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "embeddings") + if model_name: + span.set_data(SPANDATA.GEN_AI_REQUEST_MODEL, model_name) + + # Capture input if PII is allowed + if ( + should_send_default_pii() + and integration.include_prompts + and len(args) > 0 + ): + input_data = args[0] + # Normalize to list format + texts = input_data if isinstance(input_data, list) else [input_data] + set_data_normalized( + span, SPANDATA.GEN_AI_EMBEDDINGS_INPUT, texts, unpack=False + ) + + result = await f(self, *args, **kwargs) + return result + + return new_async_embedding_method diff --git a/sentry_sdk/integrations/langgraph.py b/sentry_sdk/integrations/langgraph.py new file mode 100644 index 0000000000..e5ea12b90a --- /dev/null +++ b/sentry_sdk/integrations/langgraph.py @@ -0,0 +1,386 @@ +from functools import wraps +from typing import Any, Callable, List, Optional + +import sentry_sdk +from sentry_sdk.ai.utils import ( + set_data_normalized, + normalize_message_roles, + truncate_and_annotate_messages, +) +from sentry_sdk.consts import OP, SPANDATA +from sentry_sdk.integrations import DidNotEnable, Integration +from sentry_sdk.scope import should_send_default_pii +from sentry_sdk.utils import safe_serialize + + +try: + from langgraph.graph import StateGraph + from langgraph.pregel import Pregel +except ImportError: + raise DidNotEnable("langgraph not installed") + + +class LanggraphIntegration(Integration): + identifier = "langgraph" + origin = f"auto.ai.{identifier}" + + def __init__(self: "LanggraphIntegration", include_prompts: bool = True) -> None: + self.include_prompts = include_prompts + + @staticmethod + def setup_once() -> None: + # LangGraph lets users create agents using a StateGraph or the Functional API. + # StateGraphs are then compiled to a CompiledStateGraph. Both CompiledStateGraph and + # the functional API execute on a Pregel instance. Pregel is the runtime for the graph + # and the invocation happens on Pregel, so patching the invoke methods takes care of both. + # The streaming methods are not patched, because due to some internal reasons, LangGraph + # will automatically patch the streaming methods to run through invoke, and by doing this + # we prevent duplicate spans for invocations. + StateGraph.compile = _wrap_state_graph_compile(StateGraph.compile) + if hasattr(Pregel, "invoke"): + Pregel.invoke = _wrap_pregel_invoke(Pregel.invoke) + if hasattr(Pregel, "ainvoke"): + Pregel.ainvoke = _wrap_pregel_ainvoke(Pregel.ainvoke) + + +def _get_graph_name(graph_obj: "Any") -> "Optional[str]": + for attr in ["name", "graph_name", "__name__", "_name"]: + if hasattr(graph_obj, attr): + name = getattr(graph_obj, attr) + if name and isinstance(name, str): + return name + return None + + +def _normalize_langgraph_message(message: "Any") -> "Any": + if not hasattr(message, "content"): + return None + + parsed = {"role": getattr(message, "type", None), "content": message.content} + + for attr in [ + "name", + "tool_calls", + "function_call", + "tool_call_id", + "response_metadata", + ]: + if hasattr(message, attr): + value = getattr(message, attr) + if value is not None: + parsed[attr] = value + + return parsed + + +def _parse_langgraph_messages(state: "Any") -> "Optional[List[Any]]": + if not state: + return None + + messages = None + + if isinstance(state, dict): + messages = state.get("messages") + elif hasattr(state, "messages"): + messages = state.messages + elif hasattr(state, "get") and callable(state.get): + try: + messages = state.get("messages") + except Exception: + pass + + if not messages or not isinstance(messages, (list, tuple)): + return None + + normalized_messages = [] + for message in messages: + try: + normalized = _normalize_langgraph_message(message) + if normalized: + normalized_messages.append(normalized) + except Exception: + continue + + return normalized_messages if normalized_messages else None + + +def _wrap_state_graph_compile(f: "Callable[..., Any]") -> "Callable[..., Any]": + @wraps(f) + def new_compile(self: "Any", *args: "Any", **kwargs: "Any") -> "Any": + integration = sentry_sdk.get_client().get_integration(LanggraphIntegration) + if integration is None: + return f(self, *args, **kwargs) + with sentry_sdk.start_span( + op=OP.GEN_AI_CREATE_AGENT, + origin=LanggraphIntegration.origin, + ) as span: + compiled_graph = f(self, *args, **kwargs) + + compiled_graph_name = getattr(compiled_graph, "name", None) + span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "create_agent") + span.set_data(SPANDATA.GEN_AI_AGENT_NAME, compiled_graph_name) + + if compiled_graph_name: + span.description = f"create_agent {compiled_graph_name}" + else: + span.description = "create_agent" + + if kwargs.get("model", None) is not None: + span.set_data(SPANDATA.GEN_AI_REQUEST_MODEL, kwargs.get("model")) + + tools = None + get_graph = getattr(compiled_graph, "get_graph", None) + if get_graph and callable(get_graph): + graph_obj = compiled_graph.get_graph() + nodes = getattr(graph_obj, "nodes", None) + if nodes and isinstance(nodes, dict): + tools_node = nodes.get("tools") + if tools_node: + data = getattr(tools_node, "data", None) + if data and hasattr(data, "tools_by_name"): + tools = list(data.tools_by_name.keys()) + + if tools is not None: + span.set_data(SPANDATA.GEN_AI_REQUEST_AVAILABLE_TOOLS, tools) + + return compiled_graph + + return new_compile + + +def _wrap_pregel_invoke(f: "Callable[..., Any]") -> "Callable[..., Any]": + @wraps(f) + def new_invoke(self: "Any", *args: "Any", **kwargs: "Any") -> "Any": + integration = sentry_sdk.get_client().get_integration(LanggraphIntegration) + if integration is None: + return f(self, *args, **kwargs) + + graph_name = _get_graph_name(self) + span_name = ( + f"invoke_agent {graph_name}".strip() if graph_name else "invoke_agent" + ) + + with sentry_sdk.start_span( + op=OP.GEN_AI_INVOKE_AGENT, + name=span_name, + origin=LanggraphIntegration.origin, + ) as span: + if graph_name: + span.set_data(SPANDATA.GEN_AI_PIPELINE_NAME, graph_name) + span.set_data(SPANDATA.GEN_AI_AGENT_NAME, graph_name) + + span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "invoke_agent") + + # Store input messages to later compare with output + input_messages = None + if ( + len(args) > 0 + and should_send_default_pii() + and integration.include_prompts + ): + input_messages = _parse_langgraph_messages(args[0]) + if input_messages: + normalized_input_messages = normalize_message_roles(input_messages) + scope = sentry_sdk.get_current_scope() + messages_data = truncate_and_annotate_messages( + normalized_input_messages, span, scope + ) + if messages_data is not None: + set_data_normalized( + span, + SPANDATA.GEN_AI_REQUEST_MESSAGES, + messages_data, + unpack=False, + ) + + result = f(self, *args, **kwargs) + + _set_response_attributes(span, input_messages, result, integration) + + return result + + return new_invoke + + +def _wrap_pregel_ainvoke(f: "Callable[..., Any]") -> "Callable[..., Any]": + @wraps(f) + async def new_ainvoke(self: "Any", *args: "Any", **kwargs: "Any") -> "Any": + integration = sentry_sdk.get_client().get_integration(LanggraphIntegration) + if integration is None: + return await f(self, *args, **kwargs) + + graph_name = _get_graph_name(self) + span_name = ( + f"invoke_agent {graph_name}".strip() if graph_name else "invoke_agent" + ) + + with sentry_sdk.start_span( + op=OP.GEN_AI_INVOKE_AGENT, + name=span_name, + origin=LanggraphIntegration.origin, + ) as span: + if graph_name: + span.set_data(SPANDATA.GEN_AI_PIPELINE_NAME, graph_name) + span.set_data(SPANDATA.GEN_AI_AGENT_NAME, graph_name) + + span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "invoke_agent") + + input_messages = None + if ( + len(args) > 0 + and should_send_default_pii() + and integration.include_prompts + ): + input_messages = _parse_langgraph_messages(args[0]) + if input_messages: + normalized_input_messages = normalize_message_roles(input_messages) + scope = sentry_sdk.get_current_scope() + messages_data = truncate_and_annotate_messages( + normalized_input_messages, span, scope + ) + if messages_data is not None: + set_data_normalized( + span, + SPANDATA.GEN_AI_REQUEST_MESSAGES, + messages_data, + unpack=False, + ) + + result = await f(self, *args, **kwargs) + + _set_response_attributes(span, input_messages, result, integration) + + return result + + return new_ainvoke + + +def _get_new_messages( + input_messages: "Optional[List[Any]]", output_messages: "Optional[List[Any]]" +) -> "Optional[List[Any]]": + """Extract only the new messages added during this invocation.""" + if not output_messages: + return None + + if not input_messages: + return output_messages + + # only return the new messages, aka the output messages that are not in the input messages + input_count = len(input_messages) + new_messages = ( + output_messages[input_count:] if len(output_messages) > input_count else [] + ) + + return new_messages if new_messages else None + + +def _extract_llm_response_text(messages: "Optional[List[Any]]") -> "Optional[str]": + if not messages: + return None + + for message in reversed(messages): + if isinstance(message, dict): + role = message.get("role") + if role in ["assistant", "ai"]: + content = message.get("content") + if content and isinstance(content, str): + return content + + return None + + +def _extract_tool_calls(messages: "Optional[List[Any]]") -> "Optional[List[Any]]": + if not messages: + return None + + tool_calls = [] + for message in messages: + if isinstance(message, dict): + msg_tool_calls = message.get("tool_calls") + if msg_tool_calls and isinstance(msg_tool_calls, list): + tool_calls.extend(msg_tool_calls) + + return tool_calls if tool_calls else None + + +def _set_usage_data(span: "sentry_sdk.tracing.Span", messages: "Any") -> None: + input_tokens = 0 + output_tokens = 0 + total_tokens = 0 + + for message in messages: + response_metadata = message.get("response_metadata") + if response_metadata is None: + continue + + token_usage = response_metadata.get("token_usage") + if not token_usage: + continue + + input_tokens += int(token_usage.get("prompt_tokens", 0)) + output_tokens += int(token_usage.get("completion_tokens", 0)) + total_tokens += int(token_usage.get("total_tokens", 0)) + + if input_tokens > 0: + span.set_data(SPANDATA.GEN_AI_USAGE_INPUT_TOKENS, input_tokens) + + if output_tokens > 0: + span.set_data(SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS, output_tokens) + + if total_tokens > 0: + span.set_data( + SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS, + total_tokens, + ) + + +def _set_response_model_name(span: "sentry_sdk.tracing.Span", messages: "Any") -> None: + if len(messages) == 0: + return + + last_message = messages[-1] + response_metadata = last_message.get("response_metadata") + if response_metadata is None: + return + + model_name = response_metadata.get("model_name") + if model_name is None: + return + + set_data_normalized(span, SPANDATA.GEN_AI_RESPONSE_MODEL, model_name) + + +def _set_response_attributes( + span: "Any", + input_messages: "Optional[List[Any]]", + result: "Any", + integration: "LanggraphIntegration", +) -> None: + parsed_response_messages = _parse_langgraph_messages(result) + new_messages = _get_new_messages(input_messages, parsed_response_messages) + + if new_messages is None: + return + + _set_usage_data(span, new_messages) + _set_response_model_name(span, new_messages) + + if not (should_send_default_pii() and integration.include_prompts): + return + + llm_response_text = _extract_llm_response_text(new_messages) + if llm_response_text: + set_data_normalized(span, SPANDATA.GEN_AI_RESPONSE_TEXT, llm_response_text) + elif new_messages: + set_data_normalized(span, SPANDATA.GEN_AI_RESPONSE_TEXT, new_messages) + else: + set_data_normalized(span, SPANDATA.GEN_AI_RESPONSE_TEXT, result) + + tool_calls = _extract_tool_calls(new_messages) + if tool_calls: + set_data_normalized( + span, + SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS, + safe_serialize(tool_calls), + unpack=False, + ) diff --git a/sentry_sdk/integrations/launchdarkly.py b/sentry_sdk/integrations/launchdarkly.py new file mode 100644 index 0000000000..2d86fc5ca4 --- /dev/null +++ b/sentry_sdk/integrations/launchdarkly.py @@ -0,0 +1,63 @@ +from typing import TYPE_CHECKING + +from sentry_sdk.feature_flags import add_feature_flag +from sentry_sdk.integrations import DidNotEnable, Integration + +try: + import ldclient + from ldclient.hook import Hook, Metadata + + if TYPE_CHECKING: + from ldclient import LDClient + from ldclient.hook import EvaluationSeriesContext + from ldclient.evaluation import EvaluationDetail + + from typing import Any +except ImportError: + raise DidNotEnable("LaunchDarkly is not installed") + + +class LaunchDarklyIntegration(Integration): + identifier = "launchdarkly" + + def __init__(self, ld_client: "LDClient | None" = None) -> None: + """ + :param client: An initialized LDClient instance. If a client is not provided, this + integration will attempt to use the shared global instance. + """ + try: + client = ld_client or ldclient.get() + except Exception as exc: + raise DidNotEnable("Error getting LaunchDarkly client. " + repr(exc)) + + if not client.is_initialized(): + raise DidNotEnable("LaunchDarkly client is not initialized.") + + # Register the flag collection hook with the LD client. + client.add_hook(LaunchDarklyHook()) + + @staticmethod + def setup_once() -> None: + pass + + +class LaunchDarklyHook(Hook): + @property + def metadata(self) -> "Metadata": + return Metadata(name="sentry-flag-auditor") + + def after_evaluation( + self, + series_context: "EvaluationSeriesContext", + data: "dict[Any, Any]", + detail: "EvaluationDetail", + ) -> "dict[Any, Any]": + if isinstance(detail.value, bool): + add_feature_flag(series_context.key, detail.value) + + return data + + def before_evaluation( + self, series_context: "EvaluationSeriesContext", data: "dict[Any, Any]" + ) -> "dict[Any, Any]": + return data # No-op. diff --git a/sentry_sdk/integrations/litellm.py b/sentry_sdk/integrations/litellm.py new file mode 100644 index 0000000000..08cb217962 --- /dev/null +++ b/sentry_sdk/integrations/litellm.py @@ -0,0 +1,291 @@ +from typing import TYPE_CHECKING + +import sentry_sdk +from sentry_sdk import consts +from sentry_sdk.ai.monitoring import record_token_usage +from sentry_sdk.ai.utils import ( + get_start_span_function, + set_data_normalized, + truncate_and_annotate_messages, +) +from sentry_sdk.consts import SPANDATA +from sentry_sdk.integrations import DidNotEnable, Integration +from sentry_sdk.scope import should_send_default_pii +from sentry_sdk.utils import event_from_exception + +if TYPE_CHECKING: + from typing import Any, Dict + from datetime import datetime + +try: + import litellm # type: ignore[import-not-found] +except ImportError: + raise DidNotEnable("LiteLLM not installed") + + +def _get_metadata_dict(kwargs: "Dict[str, Any]") -> "Dict[str, Any]": + """Get the metadata dictionary from the kwargs.""" + litellm_params = kwargs.setdefault("litellm_params", {}) + + # we need this weird little dance, as metadata might be set but may be None initially + metadata = litellm_params.get("metadata") + if metadata is None: + metadata = {} + litellm_params["metadata"] = metadata + return metadata + + +def _input_callback(kwargs: "Dict[str, Any]") -> None: + """Handle the start of a request.""" + integration = sentry_sdk.get_client().get_integration(LiteLLMIntegration) + + if integration is None: + return + + # Get key parameters + full_model = kwargs.get("model", "") + try: + model, provider, _, _ = litellm.get_llm_provider(full_model) + except Exception: + model = full_model + provider = "unknown" + + call_type = kwargs.get("call_type", None) + if call_type == "embedding": + operation = "embeddings" + else: + operation = "chat" + + # Start a new span/transaction + span = get_start_span_function()( + op=( + consts.OP.GEN_AI_CHAT + if operation == "chat" + else consts.OP.GEN_AI_EMBEDDINGS + ), + name=f"{operation} {model}", + origin=LiteLLMIntegration.origin, + ) + span.__enter__() + + # Store span for later + _get_metadata_dict(kwargs)["_sentry_span"] = span + + # Set basic data + set_data_normalized(span, SPANDATA.GEN_AI_SYSTEM, provider) + set_data_normalized(span, SPANDATA.GEN_AI_OPERATION_NAME, operation) + + # Record input/messages if allowed + if should_send_default_pii() and integration.include_prompts: + if operation == "embeddings": + # For embeddings, look for the 'input' parameter + embedding_input = kwargs.get("input") + if embedding_input: + scope = sentry_sdk.get_current_scope() + # Normalize to list format + input_list = ( + embedding_input + if isinstance(embedding_input, list) + else [embedding_input] + ) + messages_data = truncate_and_annotate_messages(input_list, span, scope) + if messages_data is not None: + set_data_normalized( + span, + SPANDATA.GEN_AI_EMBEDDINGS_INPUT, + messages_data, + unpack=False, + ) + else: + # For chat, look for the 'messages' parameter + messages = kwargs.get("messages", []) + if messages: + scope = sentry_sdk.get_current_scope() + messages_data = truncate_and_annotate_messages(messages, span, scope) + if messages_data is not None: + set_data_normalized( + span, + SPANDATA.GEN_AI_REQUEST_MESSAGES, + messages_data, + unpack=False, + ) + + # Record other parameters + params = { + "model": SPANDATA.GEN_AI_REQUEST_MODEL, + "stream": SPANDATA.GEN_AI_RESPONSE_STREAMING, + "max_tokens": SPANDATA.GEN_AI_REQUEST_MAX_TOKENS, + "presence_penalty": SPANDATA.GEN_AI_REQUEST_PRESENCE_PENALTY, + "frequency_penalty": SPANDATA.GEN_AI_REQUEST_FREQUENCY_PENALTY, + "temperature": SPANDATA.GEN_AI_REQUEST_TEMPERATURE, + "top_p": SPANDATA.GEN_AI_REQUEST_TOP_P, + } + for key, attribute in params.items(): + value = kwargs.get(key) + if value is not None: + set_data_normalized(span, attribute, value) + + # Record LiteLLM-specific parameters + litellm_params = { + "api_base": kwargs.get("api_base"), + "api_version": kwargs.get("api_version"), + "custom_llm_provider": kwargs.get("custom_llm_provider"), + } + for key, value in litellm_params.items(): + if value is not None: + set_data_normalized(span, f"gen_ai.litellm.{key}", value) + + +def _success_callback( + kwargs: "Dict[str, Any]", + completion_response: "Any", + start_time: "datetime", + end_time: "datetime", +) -> None: + """Handle successful completion.""" + + span = _get_metadata_dict(kwargs).get("_sentry_span") + if span is None: + return + + integration = sentry_sdk.get_client().get_integration(LiteLLMIntegration) + if integration is None: + return + + try: + # Record model information + if hasattr(completion_response, "model"): + set_data_normalized( + span, SPANDATA.GEN_AI_RESPONSE_MODEL, completion_response.model + ) + + # Record response content if allowed + if should_send_default_pii() and integration.include_prompts: + if hasattr(completion_response, "choices"): + response_messages = [] + for choice in completion_response.choices: + if hasattr(choice, "message"): + if hasattr(choice.message, "model_dump"): + response_messages.append(choice.message.model_dump()) + elif hasattr(choice.message, "dict"): + response_messages.append(choice.message.dict()) + else: + # Fallback for basic message objects + msg = {} + if hasattr(choice.message, "role"): + msg["role"] = choice.message.role + if hasattr(choice.message, "content"): + msg["content"] = choice.message.content + if hasattr(choice.message, "tool_calls"): + msg["tool_calls"] = choice.message.tool_calls + response_messages.append(msg) + + if response_messages: + set_data_normalized( + span, SPANDATA.GEN_AI_RESPONSE_TEXT, response_messages + ) + + # Record token usage + if hasattr(completion_response, "usage"): + usage = completion_response.usage + record_token_usage( + span, + input_tokens=getattr(usage, "prompt_tokens", None), + output_tokens=getattr(usage, "completion_tokens", None), + total_tokens=getattr(usage, "total_tokens", None), + ) + + finally: + # Always finish the span and clean up + span.__exit__(None, None, None) + + +def _failure_callback( + kwargs: "Dict[str, Any]", + exception: Exception, + start_time: "datetime", + end_time: "datetime", +) -> None: + """Handle request failure.""" + span = _get_metadata_dict(kwargs).get("_sentry_span") + if span is None: + return + + try: + # Capture the exception + event, hint = event_from_exception( + exception, + client_options=sentry_sdk.get_client().options, + mechanism={"type": "litellm", "handled": False}, + ) + sentry_sdk.capture_event(event, hint=hint) + finally: + # Always finish the span and clean up + span.__exit__(type(exception), exception, None) + + +class LiteLLMIntegration(Integration): + """ + LiteLLM integration for Sentry. + + This integration automatically captures LiteLLM API calls and sends them to Sentry + for monitoring and error tracking. It supports all 100+ LLM providers that LiteLLM + supports, including OpenAI, Anthropic, Google, Cohere, and many others. + + Features: + - Automatic exception capture for all LiteLLM calls + - Token usage tracking across all providers + - Provider detection and attribution + - Input/output message capture (configurable) + - Streaming response support + - Cost tracking integration + + Usage: + + ```python + import litellm + import sentry_sdk + + # Initialize Sentry with the LiteLLM integration + sentry_sdk.init( + dsn="your-dsn", + send_default_pii=True + integrations=[ + sentry_sdk.integrations.LiteLLMIntegration( + include_prompts=True # Set to False to exclude message content + ) + ] + ) + + # All LiteLLM calls will now be monitored + response = litellm.completion( + model="gpt-3.5-turbo", + messages=[{"role": "user", "content": "Hello!"}] + ) + ``` + + Configuration: + - include_prompts (bool): Whether to include prompts and responses in spans. + Defaults to True. Set to False to exclude potentially sensitive data. + """ + + identifier = "litellm" + origin = f"auto.ai.{identifier}" + + def __init__(self: "LiteLLMIntegration", include_prompts: bool = True) -> None: + self.include_prompts = include_prompts + + @staticmethod + def setup_once() -> None: + """Set up LiteLLM callbacks for monitoring.""" + litellm.input_callback = litellm.input_callback or [] + if _input_callback not in litellm.input_callback: + litellm.input_callback.append(_input_callback) + + litellm.success_callback = litellm.success_callback or [] + if _success_callback not in litellm.success_callback: + litellm.success_callback.append(_success_callback) + + litellm.failure_callback = litellm.failure_callback or [] + if _failure_callback not in litellm.failure_callback: + litellm.failure_callback.append(_failure_callback) diff --git a/sentry_sdk/integrations/litestar.py b/sentry_sdk/integrations/litestar.py new file mode 100644 index 0000000000..e0baf7f591 --- /dev/null +++ b/sentry_sdk/integrations/litestar.py @@ -0,0 +1,311 @@ +from collections.abc import Set +from copy import deepcopy + +import sentry_sdk +from sentry_sdk.consts import OP +from sentry_sdk.integrations import ( + _DEFAULT_FAILED_REQUEST_STATUS_CODES, + DidNotEnable, + Integration, +) +from sentry_sdk.integrations.asgi import SentryAsgiMiddleware +from sentry_sdk.integrations.logging import ignore_logger +from sentry_sdk.scope import should_send_default_pii +from sentry_sdk.tracing import TransactionSource, SOURCE_FOR_STYLE +from sentry_sdk.utils import ( + ensure_integration_enabled, + event_from_exception, + transaction_from_function, +) + +try: + from litestar import Request, Litestar # type: ignore + from litestar.handlers.base import BaseRouteHandler # type: ignore + from litestar.middleware import DefineMiddleware # type: ignore + from litestar.routes.http import HTTPRoute # type: ignore + from litestar.data_extractors import ConnectionDataExtractor # type: ignore + from litestar.exceptions import HTTPException # type: ignore +except ImportError: + raise DidNotEnable("Litestar is not installed") + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Any, Optional, Union + from litestar.types.asgi_types import ASGIApp # type: ignore + from litestar.types import ( # type: ignore + HTTPReceiveMessage, + HTTPScope, + Message, + Middleware, + Receive, + Scope as LitestarScope, + Send, + WebSocketReceiveMessage, + ) + from litestar.middleware import MiddlewareProtocol + from sentry_sdk._types import Event, Hint + +_DEFAULT_TRANSACTION_NAME = "generic Litestar request" + + +class LitestarIntegration(Integration): + identifier = "litestar" + origin = f"auto.http.{identifier}" + + def __init__( + self, + failed_request_status_codes: "Set[int]" = _DEFAULT_FAILED_REQUEST_STATUS_CODES, + ) -> None: + self.failed_request_status_codes = failed_request_status_codes + + @staticmethod + def setup_once() -> None: + patch_app_init() + patch_middlewares() + patch_http_route_handle() + + # The following line follows the pattern found in other integrations such as `DjangoIntegration.setup_once`. + # The Litestar `ExceptionHandlerMiddleware.__call__` catches exceptions and does the following + # (among other things): + # 1. Logs them, some at least (such as 500s) as errors + # 2. Calls after_exception hooks + # The `LitestarIntegration`` provides an after_exception hook (see `patch_app_init` below) to create a Sentry event + # from an exception, which ends up being called during step 2 above. However, the Sentry `LoggingIntegration` will + # by default create a Sentry event from error logs made in step 1 if we do not prevent it from doing so. + ignore_logger("litestar") + + +class SentryLitestarASGIMiddleware(SentryAsgiMiddleware): + def __init__( + self, app: "ASGIApp", span_origin: str = LitestarIntegration.origin + ) -> None: + super().__init__( + app=app, + unsafe_context_data=False, + transaction_style="endpoint", + mechanism_type="asgi", + span_origin=span_origin, + asgi_version=3, + ) + + def _capture_request_exception(self, exc: Exception) -> None: + """Avoid catching exceptions from request handlers. + + Those exceptions are already handled in Litestar.after_exception handler. + We still catch exceptions from application lifespan handlers. + """ + pass + + +def patch_app_init() -> None: + """ + Replaces the Litestar class's `__init__` function in order to inject `after_exception` handlers and set the + `SentryLitestarASGIMiddleware` as the outmost middleware in the stack. + See: + - https://docs.litestar.dev/2/usage/applications.html#after-exception + - https://docs.litestar.dev/2/usage/middleware/using-middleware.html + """ + old__init__ = Litestar.__init__ + + @ensure_integration_enabled(LitestarIntegration, old__init__) + def injection_wrapper(self: "Litestar", *args: "Any", **kwargs: "Any") -> None: + kwargs["after_exception"] = [ + exception_handler, + *(kwargs.get("after_exception") or []), + ] + + middleware = kwargs.get("middleware") or [] + kwargs["middleware"] = [SentryLitestarASGIMiddleware, *middleware] + old__init__(self, *args, **kwargs) + + Litestar.__init__ = injection_wrapper + + +def patch_middlewares() -> None: + old_resolve_middleware_stack = BaseRouteHandler.resolve_middleware + + @ensure_integration_enabled(LitestarIntegration, old_resolve_middleware_stack) + def resolve_middleware_wrapper(self: "BaseRouteHandler") -> "list[Middleware]": + return [ + enable_span_for_middleware(middleware) + for middleware in old_resolve_middleware_stack(self) + ] + + BaseRouteHandler.resolve_middleware = resolve_middleware_wrapper + + +def enable_span_for_middleware(middleware: "Middleware") -> "Middleware": + if ( + not hasattr(middleware, "__call__") # noqa: B004 + or middleware is SentryLitestarASGIMiddleware + ): + return middleware + + if isinstance(middleware, DefineMiddleware): + old_call: "ASGIApp" = middleware.middleware.__call__ + else: + old_call = middleware.__call__ + + async def _create_span_call( + self: "MiddlewareProtocol", + scope: "LitestarScope", + receive: "Receive", + send: "Send", + ) -> None: + if sentry_sdk.get_client().get_integration(LitestarIntegration) is None: + return await old_call(self, scope, receive, send) + + middleware_name = self.__class__.__name__ + with sentry_sdk.start_span( + op=OP.MIDDLEWARE_LITESTAR, + name=middleware_name, + origin=LitestarIntegration.origin, + ) as middleware_span: + middleware_span.set_tag("litestar.middleware_name", middleware_name) + + # Creating spans for the "receive" callback + async def _sentry_receive( + *args: "Any", **kwargs: "Any" + ) -> "Union[HTTPReceiveMessage, WebSocketReceiveMessage]": + if sentry_sdk.get_client().get_integration(LitestarIntegration) is None: + return await receive(*args, **kwargs) + with sentry_sdk.start_span( + op=OP.MIDDLEWARE_LITESTAR_RECEIVE, + name=getattr(receive, "__qualname__", str(receive)), + origin=LitestarIntegration.origin, + ) as span: + span.set_tag("litestar.middleware_name", middleware_name) + return await receive(*args, **kwargs) + + receive_name = getattr(receive, "__name__", str(receive)) + receive_patched = receive_name == "_sentry_receive" + new_receive = _sentry_receive if not receive_patched else receive + + # Creating spans for the "send" callback + async def _sentry_send(message: "Message") -> None: + if sentry_sdk.get_client().get_integration(LitestarIntegration) is None: + return await send(message) + with sentry_sdk.start_span( + op=OP.MIDDLEWARE_LITESTAR_SEND, + name=getattr(send, "__qualname__", str(send)), + origin=LitestarIntegration.origin, + ) as span: + span.set_tag("litestar.middleware_name", middleware_name) + return await send(message) + + send_name = getattr(send, "__name__", str(send)) + send_patched = send_name == "_sentry_send" + new_send = _sentry_send if not send_patched else send + + return await old_call(self, scope, new_receive, new_send) + + not_yet_patched = old_call.__name__ not in ["_create_span_call"] + + if not_yet_patched: + if isinstance(middleware, DefineMiddleware): + middleware.middleware.__call__ = _create_span_call + else: + middleware.__call__ = _create_span_call + + return middleware + + +def patch_http_route_handle() -> None: + old_handle = HTTPRoute.handle + + async def handle_wrapper( + self: "HTTPRoute", scope: "HTTPScope", receive: "Receive", send: "Send" + ) -> None: + if sentry_sdk.get_client().get_integration(LitestarIntegration) is None: + return await old_handle(self, scope, receive, send) + + sentry_scope = sentry_sdk.get_isolation_scope() + request: "Request[Any, Any]" = scope["app"].request_class( + scope=scope, receive=receive, send=send + ) + extracted_request_data = ConnectionDataExtractor( + parse_body=True, parse_query=True + )(request) + body = extracted_request_data.pop("body") + + request_data = await body + + def event_processor(event: "Event", _: "Hint") -> "Event": + route_handler = scope.get("route_handler") + + request_info = event.get("request", {}) + request_info["content_length"] = len(scope.get("_body", b"")) + if should_send_default_pii(): + request_info["cookies"] = extracted_request_data["cookies"] + if request_data is not None: + request_info["data"] = request_data + + func = None + if route_handler.name is not None: + tx_name = route_handler.name + # Accounts for use of type `Ref` in earlier versions of litestar without the need to reference it as a type + elif hasattr(route_handler.fn, "value"): + func = route_handler.fn.value + else: + func = route_handler.fn + if func is not None: + tx_name = transaction_from_function(func) + + tx_info = {"source": SOURCE_FOR_STYLE["endpoint"]} + + if not tx_name: + tx_name = _DEFAULT_TRANSACTION_NAME + tx_info = {"source": TransactionSource.ROUTE} + + event.update( + { + "request": deepcopy(request_info), + "transaction": tx_name, + "transaction_info": tx_info, + } + ) + return event + + sentry_scope._name = LitestarIntegration.identifier + sentry_scope.add_event_processor(event_processor) + + return await old_handle(self, scope, receive, send) + + HTTPRoute.handle = handle_wrapper + + +def retrieve_user_from_scope(scope: "LitestarScope") -> "Optional[dict[str, Any]]": + scope_user = scope.get("user") + if isinstance(scope_user, dict): + return scope_user + if hasattr(scope_user, "asdict"): # dataclasses + return scope_user.asdict() + + return None + + +@ensure_integration_enabled(LitestarIntegration) +def exception_handler(exc: Exception, scope: "LitestarScope") -> None: + user_info: "Optional[dict[str, Any]]" = None + if should_send_default_pii(): + user_info = retrieve_user_from_scope(scope) + if user_info and isinstance(user_info, dict): + sentry_scope = sentry_sdk.get_isolation_scope() + sentry_scope.set_user(user_info) + + if isinstance(exc, HTTPException): + integration = sentry_sdk.get_client().get_integration(LitestarIntegration) + if ( + integration is not None + and exc.status_code not in integration.failed_request_status_codes + ): + return + + event, hint = event_from_exception( + exc, + client_options=sentry_sdk.get_client().options, + mechanism={"type": LitestarIntegration.identifier, "handled": False}, + ) + + sentry_sdk.capture_event(event, hint=hint) diff --git a/sentry_sdk/integrations/logging.py b/sentry_sdk/integrations/logging.py index 4162f90aef..42029c5a7a 100644 --- a/sentry_sdk/integrations/logging.py +++ b/sentry_sdk/integrations/logging.py @@ -1,21 +1,25 @@ -from __future__ import absolute_import - import logging +import sys +from datetime import datetime, timezone from fnmatch import fnmatch -from sentry_sdk.hub import Hub +import sentry_sdk +from sentry_sdk.client import BaseClient +from sentry_sdk.logger import _log_level_to_otel from sentry_sdk.utils import ( + safe_repr, to_string, event_from_exception, current_stacktrace, capture_internal_exceptions, + has_logs_enabled, ) from sentry_sdk.integrations import Integration -from sentry_sdk._compat import iteritems, utc_from_timestamp -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: + from collections.abc import MutableMapping from logging import LogRecord from typing import Any from typing import Dict @@ -34,6 +38,16 @@ logging.CRITICAL: "fatal", # CRITICAL is same as FATAL } +# Map logging level numbers to corresponding OTel level numbers +SEVERITY_TO_OTEL_SEVERITY = { + logging.CRITICAL: 21, # fatal + logging.ERROR: 17, # error + logging.WARNING: 13, # warn + logging.INFO: 9, # info + logging.DEBUG: 5, # debug +} + + # Capturing events from those loggers causes recursion errors. We cannot allow # the user to unconditionally create events from those loggers under any # circumstances. @@ -46,9 +60,8 @@ def ignore_logger( - name, # type: str -): - # type: (...) -> None + name: str, +) -> None: """This disables recording (both in breadcrumbs and as events) calls to a logger of a specific name. Among other uses, many of our integrations use this to prevent their actions being recorded as breadcrumbs. Exposed @@ -62,19 +75,26 @@ def ignore_logger( class LoggingIntegration(Integration): identifier = "logging" - def __init__(self, level=DEFAULT_LEVEL, event_level=DEFAULT_EVENT_LEVEL): - # type: (Optional[int], Optional[int]) -> None + def __init__( + self, + level: "Optional[int]" = DEFAULT_LEVEL, + event_level: "Optional[int]" = DEFAULT_EVENT_LEVEL, + sentry_logs_level: "Optional[int]" = DEFAULT_LEVEL, + ) -> None: self._handler = None self._breadcrumb_handler = None + self._sentry_logs_handler = None if level is not None: self._breadcrumb_handler = BreadcrumbHandler(level=level) + if sentry_logs_level is not None: + self._sentry_logs_handler = SentryLogsHandler(level=sentry_logs_level) + if event_level is not None: self._handler = EventHandler(level=event_level) - def _handle_record(self, record): - # type: (LogRecord) -> None + def _handle_record(self, record: "LogRecord") -> None: if self._handler is not None and record.levelno >= self._handler.level: self._handler.handle(record) @@ -84,13 +104,21 @@ def _handle_record(self, record): ): self._breadcrumb_handler.handle(record) + if ( + self._sentry_logs_handler is not None + and record.levelno >= self._sentry_logs_handler.level + ): + self._sentry_logs_handler.handle(record) + @staticmethod - def setup_once(): - # type: () -> None + def setup_once() -> None: old_callhandlers = logging.Logger.callHandlers - def sentry_patched_callhandlers(self, record): - # type: (Any, LogRecord) -> Any + def sentry_patched_callhandlers(self: "Any", record: "LogRecord") -> "Any": + # keeping a local reference because the + # global might be discarded on shutdown + ignored_loggers = _IGNORED_LOGGERS + try: return old_callhandlers(self, record) finally: @@ -98,15 +126,20 @@ def sentry_patched_callhandlers(self, record): # the integration. Otherwise we have a high chance of getting # into a recursion error when the integration is resolved # (this also is slower). - if record.name not in _IGNORED_LOGGERS: - integration = Hub.current.get_integration(LoggingIntegration) + if ( + ignored_loggers is not None + and record.name.strip() not in ignored_loggers + ): + integration = sentry_sdk.get_client().get_integration( + LoggingIntegration + ) if integration is not None: integration._handle_record(record) logging.Logger.callHandlers = sentry_patched_callhandlers # type: ignore -class _BaseHandler(logging.Handler, object): +class _BaseHandler(logging.Handler): COMMON_RECORD_ATTRS = frozenset( ( "args", @@ -130,31 +163,29 @@ class _BaseHandler(logging.Handler, object): "relativeCreated", "stack", "tags", + "taskName", "thread", "threadName", "stack_info", ) ) - def _can_record(self, record): - # type: (LogRecord) -> bool + def _can_record(self, record: "LogRecord") -> bool: """Prevents ignored loggers from recording""" for logger in _IGNORED_LOGGERS: - if fnmatch(record.name, logger): + if fnmatch(record.name.strip(), logger): return False return True - def _logging_to_event_level(self, record): - # type: (LogRecord) -> str + def _logging_to_event_level(self, record: "LogRecord") -> str: return LOGGING_TO_EVENT_LEVEL.get( record.levelno, record.levelname.lower() if record.levelname else "" ) - def _extra_from_record(self, record): - # type: (LogRecord) -> Dict[str, None] + def _extra_from_record(self, record: "LogRecord") -> "MutableMapping[str, object]": return { k: v - for k, v in iteritems(vars(record)) + for k, v in vars(record).items() if k not in self.COMMON_RECORD_ATTRS and (not isinstance(k, str) or not k.startswith("_")) } @@ -167,22 +198,20 @@ class EventHandler(_BaseHandler): Note that you do not have to use this class if the logging integration is enabled, which it is by default. """ - def emit(self, record): - # type: (LogRecord) -> Any + def emit(self, record: "LogRecord") -> "Any": with capture_internal_exceptions(): self.format(record) return self._emit(record) - def _emit(self, record): - # type: (LogRecord) -> None + def _emit(self, record: "LogRecord") -> None: if not self._can_record(record): return - hub = Hub.current - if hub.client is None: + client = sentry_sdk.get_client() + if not client.is_active(): return - client_options = hub.client.options + client_options = client.options # exc_info might be None or (None, None, None) # @@ -196,7 +225,7 @@ def _emit(self, record): client_options=client_options, mechanism={"type": "logging", "handled": True}, ) - elif record.exc_info and record.exc_info[0] is None: + elif (record.exc_info and record.exc_info[0] is None) or record.stack_info: event = {} hint = {} with capture_internal_exceptions(): @@ -220,32 +249,34 @@ def _emit(self, record): hint["log_record"] = record - event["level"] = self._logging_to_event_level(record) + level = self._logging_to_event_level(record) + if level in {"debug", "info", "warning", "error", "critical", "fatal"}: + event["level"] = level # type: ignore[typeddict-item] event["logger"] = record.name - # Log records from `warnings` module as separate issues - record_caputured_from_warnings_module = ( - record.name == "py.warnings" and record.msg == "%s" - ) - if record_caputured_from_warnings_module: - # use the actual message and not "%s" as the message - # this prevents grouping all warnings under one "%s" issue - msg = record.args[0] # type: ignore - - event["logentry"] = { - "message": msg, - "params": (), - } - + if ( + sys.version_info < (3, 11) + and record.name == "py.warnings" + and record.msg == "%s" + ): + # warnings module on Python 3.10 and below sets record.msg to "%s" + # and record.args[0] to the actual warning message. + # This was fixed in https://github.com/python/cpython/pull/30975. + message = record.args[0] + params = () else: - event["logentry"] = { - "message": to_string(record.msg), - "params": record.args, - } + message = record.msg + params = record.args + + event["logentry"] = { + "message": to_string(message), + "formatted": record.getMessage(), + "params": params, + } event["extra"] = self._extra_from_record(record) - hub.capture_event(event, hint=hint) + sentry_sdk.capture_event(event, hint=hint) # Legacy name @@ -259,28 +290,120 @@ class BreadcrumbHandler(_BaseHandler): Note that you do not have to use this class if the logging integration is enabled, which it is by default. """ - def emit(self, record): - # type: (LogRecord) -> Any + def emit(self, record: "LogRecord") -> "Any": with capture_internal_exceptions(): self.format(record) return self._emit(record) - def _emit(self, record): - # type: (LogRecord) -> None + def _emit(self, record: "LogRecord") -> None: if not self._can_record(record): return - Hub.current.add_breadcrumb( + sentry_sdk.add_breadcrumb( self._breadcrumb_from_record(record), hint={"log_record": record} ) - def _breadcrumb_from_record(self, record): - # type: (LogRecord) -> Dict[str, Any] + def _breadcrumb_from_record(self, record: "LogRecord") -> "Dict[str, Any]": return { "type": "log", "level": self._logging_to_event_level(record), "category": record.name, "message": record.message, - "timestamp": utc_from_timestamp(record.created), + "timestamp": datetime.fromtimestamp(record.created, timezone.utc), "data": self._extra_from_record(record), } + + +class SentryLogsHandler(_BaseHandler): + """ + A logging handler that records Sentry logs for each Python log record. + + Note that you do not have to use this class if the logging integration is enabled, which it is by default. + """ + + def emit(self, record: "LogRecord") -> "Any": + with capture_internal_exceptions(): + self.format(record) + if not self._can_record(record): + return + + client = sentry_sdk.get_client() + if not client.is_active(): + return + + if not has_logs_enabled(client.options): + return + + self._capture_log_from_record(client, record) + + def _capture_log_from_record( + self, client: "BaseClient", record: "LogRecord" + ) -> None: + otel_severity_number, otel_severity_text = _log_level_to_otel( + record.levelno, SEVERITY_TO_OTEL_SEVERITY + ) + project_root = client.options["project_root"] + + attrs: "Any" = self._extra_from_record(record) + attrs["sentry.origin"] = "auto.log.stdlib" + + parameters_set = False + if record.args is not None: + if isinstance(record.args, tuple): + parameters_set = bool(record.args) + for i, arg in enumerate(record.args): + attrs[f"sentry.message.parameter.{i}"] = ( + arg + if isinstance(arg, (str, float, int, bool)) + else safe_repr(arg) + ) + elif isinstance(record.args, dict): + parameters_set = bool(record.args) + for key, value in record.args.items(): + attrs[f"sentry.message.parameter.{key}"] = ( + value + if isinstance(value, (str, float, int, bool)) + else safe_repr(value) + ) + + if parameters_set and isinstance(record.msg, str): + # only include template if there is at least one + # sentry.message.parameter.X set + attrs["sentry.message.template"] = record.msg + + if record.lineno: + attrs["code.line.number"] = record.lineno + + if record.pathname: + if project_root is not None and record.pathname.startswith(project_root): + attrs["code.file.path"] = record.pathname[len(project_root) + 1 :] + else: + attrs["code.file.path"] = record.pathname + + if record.funcName: + attrs["code.function.name"] = record.funcName + + if record.thread: + attrs["thread.id"] = record.thread + if record.threadName: + attrs["thread.name"] = record.threadName + + if record.process: + attrs["process.pid"] = record.process + if record.processName: + attrs["process.executable.name"] = record.processName + if record.name: + attrs["logger.name"] = record.name + + # noinspection PyProtectedMember + sentry_sdk.get_current_scope()._capture_log( + { + "severity_text": otel_severity_text, + "severity_number": otel_severity_number, + "body": record.message, + "attributes": attrs, + "time_unix_nano": int(record.created * 1e9), + "trace_id": None, + "span_id": None, + }, + ) diff --git a/sentry_sdk/integrations/loguru.py b/sentry_sdk/integrations/loguru.py index b1ee2a681f..00bd3c022b 100644 --- a/sentry_sdk/integrations/loguru.py +++ b/sentry_sdk/integrations/loguru.py @@ -1,23 +1,28 @@ -from __future__ import absolute_import - import enum -from sentry_sdk._types import TYPE_CHECKING +import sentry_sdk from sentry_sdk.integrations import Integration, DidNotEnable from sentry_sdk.integrations.logging import ( BreadcrumbHandler, EventHandler, _BaseHandler, ) +from sentry_sdk.logger import _log_level_to_otel +from sentry_sdk.utils import has_logs_enabled, safe_repr + +from typing import TYPE_CHECKING if TYPE_CHECKING: from logging import LogRecord - from typing import Optional, Tuple + from typing import Any, Optional try: import loguru from loguru import logger from loguru._defaults import LOGURU_FORMAT as DEFAULT_FORMAT + + if TYPE_CHECKING: + from loguru import Message except ImportError: raise DidNotEnable("LOGURU is not installed") @@ -34,68 +39,171 @@ class LoggingLevels(enum.IntEnum): DEFAULT_LEVEL = LoggingLevels.INFO.value DEFAULT_EVENT_LEVEL = LoggingLevels.ERROR.value -# We need to save the handlers to be able to remove them later -# in tests (they call `LoguruIntegration.__init__` multiple times, -# and we can't use `setup_once` because it's called before -# than we get configuration). -_ADDED_HANDLERS = (None, None) # type: Tuple[Optional[int], Optional[int]] + + +SENTRY_LEVEL_FROM_LOGURU_LEVEL = { + "TRACE": "DEBUG", + "DEBUG": "DEBUG", + "INFO": "INFO", + "SUCCESS": "INFO", + "WARNING": "WARNING", + "ERROR": "ERROR", + "CRITICAL": "CRITICAL", +} + +# Map Loguru level numbers to corresponding OTel level numbers +SEVERITY_TO_OTEL_SEVERITY = { + LoggingLevels.CRITICAL: 21, # fatal + LoggingLevels.ERROR: 17, # error + LoggingLevels.WARNING: 13, # warn + LoggingLevels.SUCCESS: 11, # info + LoggingLevels.INFO: 9, # info + LoggingLevels.DEBUG: 5, # debug + LoggingLevels.TRACE: 1, # trace +} class LoguruIntegration(Integration): identifier = "loguru" + level: "Optional[int]" = DEFAULT_LEVEL + event_level: "Optional[int]" = DEFAULT_EVENT_LEVEL + breadcrumb_format = DEFAULT_FORMAT + event_format = DEFAULT_FORMAT + sentry_logs_level: "Optional[int]" = DEFAULT_LEVEL + def __init__( self, - level=DEFAULT_LEVEL, - event_level=DEFAULT_EVENT_LEVEL, - breadcrumb_format=DEFAULT_FORMAT, - event_format=DEFAULT_FORMAT, - ): - # type: (Optional[int], Optional[int], str | loguru.FormatFunction, str | loguru.FormatFunction) -> None - global _ADDED_HANDLERS - breadcrumb_handler, event_handler = _ADDED_HANDLERS - - if breadcrumb_handler is not None: - logger.remove(breadcrumb_handler) - breadcrumb_handler = None - if event_handler is not None: - logger.remove(event_handler) - event_handler = None - - if level is not None: - breadcrumb_handler = logger.add( - LoguruBreadcrumbHandler(level=level), - level=level, - format=breadcrumb_format, - ) + level: "Optional[int]" = DEFAULT_LEVEL, + event_level: "Optional[int]" = DEFAULT_EVENT_LEVEL, + breadcrumb_format: "str | loguru.FormatFunction" = DEFAULT_FORMAT, + event_format: "str | loguru.FormatFunction" = DEFAULT_FORMAT, + sentry_logs_level: "Optional[int]" = DEFAULT_LEVEL, + ) -> None: + LoguruIntegration.level = level + LoguruIntegration.event_level = event_level + LoguruIntegration.breadcrumb_format = breadcrumb_format + LoguruIntegration.event_format = event_format + LoguruIntegration.sentry_logs_level = sentry_logs_level - if event_level is not None: - event_handler = logger.add( - LoguruEventHandler(level=event_level), - level=event_level, - format=event_format, + @staticmethod + def setup_once() -> None: + if LoguruIntegration.level is not None: + logger.add( + LoguruBreadcrumbHandler(level=LoguruIntegration.level), + level=LoguruIntegration.level, + format=LoguruIntegration.breadcrumb_format, ) - _ADDED_HANDLERS = (breadcrumb_handler, event_handler) + if LoguruIntegration.event_level is not None: + logger.add( + LoguruEventHandler(level=LoguruIntegration.event_level), + level=LoguruIntegration.event_level, + format=LoguruIntegration.event_format, + ) - @staticmethod - def setup_once(): - # type: () -> None - pass # we do everything in __init__ + if LoguruIntegration.sentry_logs_level is not None: + logger.add( + loguru_sentry_logs_handler, + level=LoguruIntegration.sentry_logs_level, + ) class _LoguruBaseHandler(_BaseHandler): - def _logging_to_event_level(self, record): - # type: (LogRecord) -> str + def __init__(self, *args: "Any", **kwargs: "Any") -> None: + if kwargs.get("level"): + kwargs["level"] = SENTRY_LEVEL_FROM_LOGURU_LEVEL.get( + kwargs.get("level", ""), DEFAULT_LEVEL + ) + + super().__init__(*args, **kwargs) + + def _logging_to_event_level(self, record: "LogRecord") -> str: try: - return LoggingLevels(record.levelno).name.lower() - except ValueError: + return SENTRY_LEVEL_FROM_LOGURU_LEVEL[ + LoggingLevels(record.levelno).name + ].lower() + except (ValueError, KeyError): return record.levelname.lower() if record.levelname else "" class LoguruEventHandler(_LoguruBaseHandler, EventHandler): """Modified version of :class:`sentry_sdk.integrations.logging.EventHandler` to use loguru's level names.""" + pass + class LoguruBreadcrumbHandler(_LoguruBaseHandler, BreadcrumbHandler): """Modified version of :class:`sentry_sdk.integrations.logging.BreadcrumbHandler` to use loguru's level names.""" + + pass + + +def loguru_sentry_logs_handler(message: "Message") -> None: + # This is intentionally a callable sink instead of a standard logging handler + # since otherwise we wouldn't get direct access to message.record + client = sentry_sdk.get_client() + + if not client.is_active(): + return + + if not has_logs_enabled(client.options): + return + + record = message.record + + if ( + LoguruIntegration.sentry_logs_level is None + or record["level"].no < LoguruIntegration.sentry_logs_level + ): + return + + otel_severity_number, otel_severity_text = _log_level_to_otel( + record["level"].no, SEVERITY_TO_OTEL_SEVERITY + ) + + attrs: "dict[str, Any]" = {"sentry.origin": "auto.log.loguru"} + + project_root = client.options["project_root"] + if record.get("file"): + if project_root is not None and record["file"].path.startswith(project_root): + attrs["code.file.path"] = record["file"].path[len(project_root) + 1 :] + else: + attrs["code.file.path"] = record["file"].path + + if record.get("line") is not None: + attrs["code.line.number"] = record["line"] + + if record.get("function"): + attrs["code.function.name"] = record["function"] + + if record.get("thread"): + attrs["thread.name"] = record["thread"].name + attrs["thread.id"] = record["thread"].id + + if record.get("process"): + attrs["process.pid"] = record["process"].id + attrs["process.executable.name"] = record["process"].name + + if record.get("name"): + attrs["logger.name"] = record["name"] + + extra = record.get("extra") + if isinstance(extra, dict): + for key, value in extra.items(): + if isinstance(value, (str, int, float, bool)): + attrs[f"sentry.message.parameter.{key}"] = value + else: + attrs[f"sentry.message.parameter.{key}"] = safe_repr(value) + + sentry_sdk.get_current_scope()._capture_log( + { + "severity_text": otel_severity_text, + "severity_number": otel_severity_number, + "body": record["message"], + "attributes": attrs, + "time_unix_nano": int(record["time"].timestamp() * 1e9), + "trace_id": None, + "span_id": None, + } + ) diff --git a/sentry_sdk/integrations/mcp.py b/sentry_sdk/integrations/mcp.py new file mode 100644 index 0000000000..47fda272b7 --- /dev/null +++ b/sentry_sdk/integrations/mcp.py @@ -0,0 +1,662 @@ +""" +Sentry integration for MCP (Model Context Protocol) servers. + +This integration instruments MCP servers to create spans for tool, prompt, +and resource handler execution, and captures errors that occur during execution. + +Supports the low-level `mcp.server.lowlevel.Server` API. +""" + +import inspect +from functools import wraps +from typing import TYPE_CHECKING + +import sentry_sdk +from sentry_sdk.ai.utils import get_start_span_function +from sentry_sdk.consts import OP, SPANDATA +from sentry_sdk.integrations import Integration, DidNotEnable +from sentry_sdk.utils import safe_serialize +from sentry_sdk.scope import should_send_default_pii + +try: + from mcp.server.lowlevel import Server # type: ignore[import-not-found] + from mcp.server.lowlevel.server import request_ctx # type: ignore[import-not-found] +except ImportError: + raise DidNotEnable("MCP SDK not installed") + +try: + from fastmcp import FastMCP # type: ignore[import-not-found] +except ImportError: + FastMCP = None + + +if TYPE_CHECKING: + from typing import Any, Callable, Optional + + +class MCPIntegration(Integration): + identifier = "mcp" + origin = "auto.ai.mcp" + + def __init__(self, include_prompts: bool = True) -> None: + """ + Initialize the MCP integration. + + Args: + include_prompts: Whether to include prompts (tool results and prompt content) + in span data. Requires send_default_pii=True. Default is True. + """ + self.include_prompts = include_prompts + + @staticmethod + def setup_once() -> None: + """ + Patches MCP server classes to instrument handler execution. + """ + _patch_lowlevel_server() + + if FastMCP is not None: + _patch_fastmcp() + + +def _get_request_context_data() -> "tuple[Optional[str], Optional[str], str]": + """ + Extract request ID, session ID, and MCP transport type from the request context. + + Returns: + Tuple of (request_id, session_id, mcp_transport). + - request_id: May be None if not available + - session_id: May be None if not available + - mcp_transport: "http", "sse", "stdio" + """ + request_id: "Optional[str]" = None + session_id: "Optional[str]" = None + mcp_transport: str = "stdio" + + try: + ctx = request_ctx.get() + + if ctx is not None: + request_id = ctx.request_id + if hasattr(ctx, "request") and ctx.request is not None: + request = ctx.request + # Detect transport type by checking request characteristics + if hasattr(request, "query_params") and request.query_params.get( + "session_id" + ): + # SSE transport uses query parameter + mcp_transport = "sse" + session_id = request.query_params.get("session_id") + elif hasattr(request, "headers") and request.headers.get( + "mcp-session-id" + ): + # StreamableHTTP transport uses header + mcp_transport = "http" + session_id = request.headers.get("mcp-session-id") + + except LookupError: + # No request context available - default to stdio + pass + + return request_id, session_id, mcp_transport + + +def _get_span_config( + handler_type: str, item_name: str +) -> "tuple[str, str, str, Optional[str]]": + """ + Get span configuration based on handler type. + + Returns: + Tuple of (span_data_key, span_name, mcp_method_name, result_data_key) + Note: result_data_key is None for resources + """ + if handler_type == "tool": + span_data_key = SPANDATA.MCP_TOOL_NAME + mcp_method_name = "tools/call" + result_data_key = SPANDATA.MCP_TOOL_RESULT_CONTENT + elif handler_type == "prompt": + span_data_key = SPANDATA.MCP_PROMPT_NAME + mcp_method_name = "prompts/get" + result_data_key = SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT + else: # resource + span_data_key = SPANDATA.MCP_RESOURCE_URI + mcp_method_name = "resources/read" + result_data_key = None # Resources don't capture result content + + span_name = f"{mcp_method_name} {item_name}" + return span_data_key, span_name, mcp_method_name, result_data_key + + +def _set_span_input_data( + span: "Any", + handler_name: str, + span_data_key: str, + mcp_method_name: str, + arguments: "dict[str, Any]", + request_id: "Optional[str]", + session_id: "Optional[str]", + mcp_transport: str, +) -> None: + """Set input span data for MCP handlers.""" + + # Set handler identifier + span.set_data(span_data_key, handler_name) + span.set_data(SPANDATA.MCP_METHOD_NAME, mcp_method_name) + + # Set transport/MCP transport type + span.set_data( + SPANDATA.NETWORK_TRANSPORT, "pipe" if mcp_transport == "stdio" else "tcp" + ) + span.set_data(SPANDATA.MCP_TRANSPORT, mcp_transport) + + # Set request_id if provided + if request_id: + span.set_data(SPANDATA.MCP_REQUEST_ID, request_id) + + # Set session_id if provided + if session_id: + span.set_data(SPANDATA.MCP_SESSION_ID, session_id) + + # Set request arguments (excluding common request context objects) + for k, v in arguments.items(): + span.set_data(f"mcp.request.argument.{k}", safe_serialize(v)) + + +def _extract_tool_result_content(result: "Any") -> "Any": + """ + Extract meaningful content from MCP tool result. + + Tool handlers can return: + - tuple (UnstructuredContent, StructuredContent): Return the structured content (dict) + - dict (StructuredContent): Return as-is + - Iterable (UnstructuredContent): Extract text from content blocks + """ + if result is None: + return None + + # Handle CombinationContent: tuple of (UnstructuredContent, StructuredContent) + if isinstance(result, tuple) and len(result) == 2: + # Return the structured content (2nd element) + return result[1] + + # Handle StructuredContent: dict + if isinstance(result, dict): + return result + + # Handle UnstructuredContent: iterable of ContentBlock objects + # Try to extract text content + if hasattr(result, "__iter__") and not isinstance(result, (str, bytes, dict)): + texts = [] + try: + for item in result: + # Try to get text attribute from ContentBlock objects + if hasattr(item, "text"): + texts.append(item.text) + elif isinstance(item, dict) and "text" in item: + texts.append(item["text"]) + except Exception: + # If extraction fails, return the original + return result + return " ".join(texts) if texts else result + + return result + + +def _set_span_output_data( + span: "Any", result: "Any", result_data_key: "Optional[str]", handler_type: str +) -> None: + """Set output span data for MCP handlers.""" + if result is None: + return + + # Get integration to check PII settings + integration = sentry_sdk.get_client().get_integration(MCPIntegration) + if integration is None: + return + + # Check if we should include sensitive data + should_include_data = should_send_default_pii() and integration.include_prompts + + # For tools, extract the meaningful content + if handler_type == "tool": + extracted = _extract_tool_result_content(result) + if extracted is not None and should_include_data: + span.set_data(result_data_key, safe_serialize(extracted)) + # Set content count if result is a dict + if isinstance(extracted, dict): + span.set_data(SPANDATA.MCP_TOOL_RESULT_CONTENT_COUNT, len(extracted)) + elif handler_type == "prompt": + # For prompts, count messages and set role/content only for single-message prompts + try: + messages: "Optional[list[str]]" = None + message_count = 0 + + # Check if result has messages attribute (GetPromptResult) + if hasattr(result, "messages") and result.messages: + messages = result.messages + message_count = len(messages) + # Also check if result is a dict with messages + elif isinstance(result, dict) and result.get("messages"): + messages = result["messages"] + message_count = len(messages) + + # Always set message count if we found messages + if message_count > 0: + span.set_data(SPANDATA.MCP_PROMPT_RESULT_MESSAGE_COUNT, message_count) + + # Only set role and content for single-message prompts if PII is allowed + if message_count == 1 and should_include_data and messages: + first_message = messages[0] + # Extract role + role = None + if hasattr(first_message, "role"): + role = first_message.role + elif isinstance(first_message, dict) and "role" in first_message: + role = first_message["role"] + + if role: + span.set_data(SPANDATA.MCP_PROMPT_RESULT_MESSAGE_ROLE, role) + + # Extract content text + content_text = None + if hasattr(first_message, "content"): + msg_content = first_message.content + # Content can be a TextContent object or similar + if hasattr(msg_content, "text"): + content_text = msg_content.text + elif isinstance(msg_content, dict) and "text" in msg_content: + content_text = msg_content["text"] + elif isinstance(msg_content, str): + content_text = msg_content + elif isinstance(first_message, dict) and "content" in first_message: + msg_content = first_message["content"] + if isinstance(msg_content, dict) and "text" in msg_content: + content_text = msg_content["text"] + elif isinstance(msg_content, str): + content_text = msg_content + + if content_text: + span.set_data(result_data_key, content_text) + except Exception: + # Silently ignore if we can't extract message info + pass + # Resources don't capture result content (result_data_key is None) + + +# Handler data preparation and wrapping + + +def _prepare_handler_data( + handler_type: str, + original_args: "tuple[Any, ...]", + original_kwargs: "Optional[dict[str, Any]]" = None, +) -> "tuple[str, dict[str, Any], str, str, str, Optional[str]]": + """ + Prepare common handler data for both async and sync wrappers. + + Returns: + Tuple of (handler_name, arguments, span_data_key, span_name, mcp_method_name, result_data_key) + """ + original_kwargs = original_kwargs or {} + + # Extract handler-specific data based on handler type + if handler_type == "tool": + if original_args: + handler_name = original_args[0] + elif original_kwargs.get("name"): + handler_name = original_kwargs["name"] + + arguments = {} + if len(original_args) > 1: + arguments = original_args[1] + elif original_kwargs.get("arguments"): + arguments = original_kwargs["arguments"] + + elif handler_type == "prompt": + if original_args: + handler_name = original_args[0] + elif original_kwargs.get("name"): + handler_name = original_kwargs["name"] + + arguments = {} + if len(original_args) > 1: + arguments = original_args[1] + elif original_kwargs.get("arguments"): + arguments = original_kwargs["arguments"] + + # Include name in arguments dict for span data + arguments = {"name": handler_name, **(arguments or {})} + + else: # resource + handler_name = "unknown" + if original_args: + handler_name = str(original_args[0]) + elif original_kwargs.get("uri"): + handler_name = str(original_kwargs["uri"]) + + arguments = {} + + # Get span configuration + span_data_key, span_name, mcp_method_name, result_data_key = _get_span_config( + handler_type, handler_name + ) + + return ( + handler_name, + arguments, + span_data_key, + span_name, + mcp_method_name, + result_data_key, + ) + + +async def _async_handler_wrapper( + handler_type: str, + func: "Callable[..., Any]", + original_args: "tuple[Any, ...]", + original_kwargs: "Optional[dict[str, Any]]" = None, + self: "Optional[Any]" = None, +) -> "Any": + """ + Async wrapper for MCP handlers. + + Args: + handler_type: "tool", "prompt", or "resource" + func: The async handler function to wrap + original_args: Original arguments passed to the handler + original_kwargs: Original keyword arguments passed to the handler + self: Optional instance for bound methods + """ + if original_kwargs is None: + original_kwargs = {} + + ( + handler_name, + arguments, + span_data_key, + span_name, + mcp_method_name, + result_data_key, + ) = _prepare_handler_data(handler_type, original_args, original_kwargs) + + # Start span and execute + with get_start_span_function()( + op=OP.MCP_SERVER, + name=span_name, + origin=MCPIntegration.origin, + ) as span: + # Get request ID, session ID, and transport from context + request_id, session_id, mcp_transport = _get_request_context_data() + + # Set input span data + _set_span_input_data( + span, + handler_name, + span_data_key, + mcp_method_name, + arguments, + request_id, + session_id, + mcp_transport, + ) + + # For resources, extract and set protocol + if handler_type == "resource": + if original_args: + uri = original_args[0] + else: + uri = original_kwargs.get("uri") + + protocol = None + if hasattr(uri, "scheme"): + protocol = uri.scheme + elif handler_name and "://" in handler_name: + protocol = handler_name.split("://")[0] + if protocol: + span.set_data(SPANDATA.MCP_RESOURCE_PROTOCOL, protocol) + + try: + # Execute the async handler + if self is not None: + original_args = (self, *original_args) + result = await func(*original_args, **original_kwargs) + except Exception as e: + # Set error flag for tools + if handler_type == "tool": + span.set_data(SPANDATA.MCP_TOOL_RESULT_IS_ERROR, True) + sentry_sdk.capture_exception(e) + raise + + _set_span_output_data(span, result, result_data_key, handler_type) + return result + + +def _sync_handler_wrapper( + handler_type: str, func: "Callable[..., Any]", original_args: "tuple[Any, ...]" +) -> "Any": + """ + Sync wrapper for MCP handlers. + + Args: + handler_type: "tool", "prompt", or "resource" + func: The sync handler function to wrap + original_args: Original arguments passed to the handler + """ + ( + handler_name, + arguments, + span_data_key, + span_name, + mcp_method_name, + result_data_key, + ) = _prepare_handler_data(handler_type, original_args) + + # Start span and execute + with get_start_span_function()( + op=OP.MCP_SERVER, + name=span_name, + origin=MCPIntegration.origin, + ) as span: + # Get request ID, session ID, and transport from context + request_id, session_id, mcp_transport = _get_request_context_data() + + # Set input span data + _set_span_input_data( + span, + handler_name, + span_data_key, + mcp_method_name, + arguments, + request_id, + session_id, + mcp_transport, + ) + + # For resources, extract and set protocol + if handler_type == "resource": + uri = original_args[0] + protocol = None + if hasattr(uri, "scheme"): + protocol = uri.scheme + elif handler_name and "://" in handler_name: + protocol = handler_name.split("://")[0] + if protocol: + span.set_data(SPANDATA.MCP_RESOURCE_PROTOCOL, protocol) + + try: + # Execute the sync handler + result = func(*original_args) + except Exception as e: + # Set error flag for tools + if handler_type == "tool": + span.set_data(SPANDATA.MCP_TOOL_RESULT_IS_ERROR, True) + sentry_sdk.capture_exception(e) + raise + + _set_span_output_data(span, result, result_data_key, handler_type) + return result + + +def _create_instrumented_handler( + handler_type: str, func: "Callable[..., Any]" +) -> "Callable[..., Any]": + """ + Create an instrumented version of a handler function (async or sync). + + This function wraps the user's handler with a runtime wrapper that will create + Sentry spans and capture metrics when the handler is actually called. + + The wrapper preserves the async/sync nature of the original function, which is + critical for Python's async/await to work correctly. + + Args: + handler_type: "tool", "prompt", or "resource" - determines span configuration + func: The handler function to instrument (async or sync) + + Returns: + A wrapped version of func that creates Sentry spans on execution + """ + if inspect.iscoroutinefunction(func): + + @wraps(func) + async def async_wrapper(*args: "Any") -> "Any": + return await _async_handler_wrapper(handler_type, func, args) + + return async_wrapper + else: + + @wraps(func) + def sync_wrapper(*args: "Any") -> "Any": + return _sync_handler_wrapper(handler_type, func, args) + + return sync_wrapper + + +def _create_instrumented_decorator( + original_decorator: "Callable[..., Any]", + handler_type: str, + *decorator_args: "Any", + **decorator_kwargs: "Any", +) -> "Callable[..., Any]": + """ + Create an instrumented version of an MCP decorator. + + This function intercepts MCP decorators (like @server.call_tool()) and injects + Sentry instrumentation into the handler registration flow. The returned decorator + will: + 1. Receive the user's handler function + 2. Wrap it with instrumentation via _create_instrumented_handler + 3. Pass the instrumented version to the original MCP decorator + + This ensures that when the handler is called at runtime, it's already wrapped + with Sentry spans and metrics collection. + + Args: + original_decorator: The original MCP decorator method (e.g., Server.call_tool) + handler_type: "tool", "prompt", or "resource" - determines span configuration + decorator_args: Positional arguments to pass to the original decorator (e.g., self) + decorator_kwargs: Keyword arguments to pass to the original decorator + + Returns: + A decorator function that instruments handlers before registering them + """ + + def instrumented_decorator(func: "Callable[..., Any]") -> "Callable[..., Any]": + # First wrap the handler with instrumentation + instrumented_func = _create_instrumented_handler(handler_type, func) + # Then register it with the original MCP decorator + return original_decorator(*decorator_args, **decorator_kwargs)( + instrumented_func + ) + + return instrumented_decorator + + +def _patch_lowlevel_server() -> None: + """ + Patches the mcp.server.lowlevel.Server class to instrument handler execution. + """ + # Patch call_tool decorator + original_call_tool = Server.call_tool + + def patched_call_tool( + self: "Server", **kwargs: "Any" + ) -> "Callable[[Callable[..., Any]], Callable[..., Any]]": + """Patched version of Server.call_tool that adds Sentry instrumentation.""" + return lambda func: _create_instrumented_decorator( + original_call_tool, "tool", self, **kwargs + )(func) + + Server.call_tool = patched_call_tool + + # Patch get_prompt decorator + original_get_prompt = Server.get_prompt + + def patched_get_prompt( + self: "Server", + ) -> "Callable[[Callable[..., Any]], Callable[..., Any]]": + """Patched version of Server.get_prompt that adds Sentry instrumentation.""" + return lambda func: _create_instrumented_decorator( + original_get_prompt, "prompt", self + )(func) + + Server.get_prompt = patched_get_prompt + + # Patch read_resource decorator + original_read_resource = Server.read_resource + + def patched_read_resource( + self: "Server", + ) -> "Callable[[Callable[..., Any]], Callable[..., Any]]": + """Patched version of Server.read_resource that adds Sentry instrumentation.""" + return lambda func: _create_instrumented_decorator( + original_read_resource, "resource", self + )(func) + + Server.read_resource = patched_read_resource + + +def _patch_fastmcp() -> None: + """ + Patches the standalone fastmcp package's FastMCP class. + + The standalone fastmcp package (v2.14.0+) registers its own handlers for + prompts and resources directly, bypassing the Server decorators we patch. + This function patches the _get_prompt_mcp and _read_resource_mcp methods + to add instrumentation for those handlers. + """ + if hasattr(FastMCP, "_get_prompt_mcp"): + original_get_prompt_mcp = FastMCP._get_prompt_mcp + + @wraps(original_get_prompt_mcp) + async def patched_get_prompt_mcp( + self: "Any", *args: "Any", **kwargs: "Any" + ) -> "Any": + return await _async_handler_wrapper( + "prompt", + original_get_prompt_mcp, + args, + kwargs, + self, + ) + + FastMCP._get_prompt_mcp = patched_get_prompt_mcp + + if hasattr(FastMCP, "_read_resource_mcp"): + original_read_resource_mcp = FastMCP._read_resource_mcp + + @wraps(original_read_resource_mcp) + async def patched_read_resource_mcp( + self: "Any", *args: "Any", **kwargs: "Any" + ) -> "Any": + return await _async_handler_wrapper( + "resource", + original_read_resource_mcp, + args, + kwargs, + self, + ) + + FastMCP._read_resource_mcp = patched_read_resource_mcp diff --git a/sentry_sdk/integrations/modules.py b/sentry_sdk/integrations/modules.py index 3f9f356eed..086f537030 100644 --- a/sentry_sdk/integrations/modules.py +++ b/sentry_sdk/integrations/modules.py @@ -1,76 +1,26 @@ -from __future__ import absolute_import - -from sentry_sdk.hub import Hub +import sentry_sdk from sentry_sdk.integrations import Integration from sentry_sdk.scope import add_global_event_processor +from sentry_sdk.utils import _get_installed_modules -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any - from typing import Dict - from typing import Tuple - from typing import Iterator - from sentry_sdk._types import Event -_installed_modules = None - - -def _normalize_module_name(name): - # type: (str) -> str - return name.lower() - - -def _generate_installed_modules(): - # type: () -> Iterator[Tuple[str, str]] - try: - from importlib import metadata - - for dist in metadata.distributions(): - name = dist.metadata["Name"] - # `metadata` values may be `None`, see: - # https://github.com/python/cpython/issues/91216 - # and - # https://github.com/python/importlib_metadata/issues/371 - if name is not None: - version = metadata.version(name) - if version is not None: - yield _normalize_module_name(name), version - - except ImportError: - # < py3.8 - try: - import pkg_resources - except ImportError: - return - - for info in pkg_resources.working_set: - yield _normalize_module_name(info.key), info.version - - -def _get_installed_modules(): - # type: () -> Dict[str, str] - global _installed_modules - if _installed_modules is None: - _installed_modules = dict(_generate_installed_modules()) - return _installed_modules - - class ModulesIntegration(Integration): identifier = "modules" @staticmethod - def setup_once(): - # type: () -> None + def setup_once() -> None: @add_global_event_processor - def processor(event, hint): - # type: (Event, Any) -> Dict[str, Any] + def processor(event: "Event", hint: "Any") -> "Event": if event.get("type") == "transaction": return event - if Hub.current.get_integration(ModulesIntegration) is None: + if sentry_sdk.get_client().get_integration(ModulesIntegration) is None: return event event["modules"] = _get_installed_modules() diff --git a/sentry_sdk/integrations/openai.py b/sentry_sdk/integrations/openai.py new file mode 100644 index 0000000000..53d464c3c4 --- /dev/null +++ b/sentry_sdk/integrations/openai.py @@ -0,0 +1,712 @@ +from functools import wraps + +import sentry_sdk +from sentry_sdk import consts +from sentry_sdk.ai.monitoring import record_token_usage +from sentry_sdk.ai.utils import ( + set_data_normalized, + normalize_message_roles, + truncate_and_annotate_messages, +) +from sentry_sdk.consts import SPANDATA +from sentry_sdk.integrations import DidNotEnable, Integration +from sentry_sdk.scope import should_send_default_pii +from sentry_sdk.tracing_utils import set_span_errored +from sentry_sdk.utils import ( + capture_internal_exceptions, + event_from_exception, + safe_serialize, +) + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Any, Iterable, List, Optional, Callable, AsyncIterator, Iterator + from sentry_sdk.tracing import Span + +try: + try: + from openai import NotGiven + except ImportError: + NotGiven = None + + try: + from openai import Omit + except ImportError: + Omit = None + + from openai.resources.chat.completions import Completions, AsyncCompletions + from openai.resources import Embeddings, AsyncEmbeddings + + if TYPE_CHECKING: + from openai.types.chat import ChatCompletionMessageParam, ChatCompletionChunk +except ImportError: + raise DidNotEnable("OpenAI not installed") + +RESPONSES_API_ENABLED = True +try: + # responses API support was introduced in v1.66.0 + from openai.resources.responses import Responses, AsyncResponses + from openai.types.responses.response_completed_event import ResponseCompletedEvent +except ImportError: + RESPONSES_API_ENABLED = False + + +class OpenAIIntegration(Integration): + identifier = "openai" + origin = f"auto.ai.{identifier}" + + def __init__( + self: "OpenAIIntegration", + include_prompts: bool = True, + tiktoken_encoding_name: "Optional[str]" = None, + ) -> None: + self.include_prompts = include_prompts + + self.tiktoken_encoding = None + if tiktoken_encoding_name is not None: + import tiktoken # type: ignore + + self.tiktoken_encoding = tiktoken.get_encoding(tiktoken_encoding_name) + + @staticmethod + def setup_once() -> None: + Completions.create = _wrap_chat_completion_create(Completions.create) + AsyncCompletions.create = _wrap_async_chat_completion_create( + AsyncCompletions.create + ) + + Embeddings.create = _wrap_embeddings_create(Embeddings.create) + AsyncEmbeddings.create = _wrap_async_embeddings_create(AsyncEmbeddings.create) + + if RESPONSES_API_ENABLED: + Responses.create = _wrap_responses_create(Responses.create) + AsyncResponses.create = _wrap_async_responses_create(AsyncResponses.create) + + def count_tokens(self: "OpenAIIntegration", s: str) -> int: + if self.tiktoken_encoding is not None: + return len(self.tiktoken_encoding.encode_ordinary(s)) + return 0 + + +def _capture_exception(exc: "Any", manual_span_cleanup: bool = True) -> None: + # Close an eventually open span + # We need to do this by hand because we are not using the start_span context manager + current_span = sentry_sdk.get_current_span() + set_span_errored(current_span) + + if manual_span_cleanup and current_span is not None: + current_span.__exit__(None, None, None) + + event, hint = event_from_exception( + exc, + client_options=sentry_sdk.get_client().options, + mechanism={"type": "openai", "handled": False}, + ) + sentry_sdk.capture_event(event, hint=hint) + + +def _get_usage(usage: "Any", names: "List[str]") -> int: + for name in names: + if hasattr(usage, name) and isinstance(getattr(usage, name), int): + return getattr(usage, name) + return 0 + + +def _calculate_token_usage( + messages: "Optional[Iterable[ChatCompletionMessageParam]]", + response: "Any", + span: "Span", + streaming_message_responses: "Optional[List[str]]", + count_tokens: "Callable[..., Any]", +) -> None: + input_tokens: "Optional[int]" = 0 + input_tokens_cached: "Optional[int]" = 0 + output_tokens: "Optional[int]" = 0 + output_tokens_reasoning: "Optional[int]" = 0 + total_tokens: "Optional[int]" = 0 + + if hasattr(response, "usage"): + input_tokens = _get_usage(response.usage, ["input_tokens", "prompt_tokens"]) + if hasattr(response.usage, "input_tokens_details"): + input_tokens_cached = _get_usage( + response.usage.input_tokens_details, ["cached_tokens"] + ) + + output_tokens = _get_usage( + response.usage, ["output_tokens", "completion_tokens"] + ) + if hasattr(response.usage, "output_tokens_details"): + output_tokens_reasoning = _get_usage( + response.usage.output_tokens_details, ["reasoning_tokens"] + ) + + total_tokens = _get_usage(response.usage, ["total_tokens"]) + + # Manually count tokens + if input_tokens == 0: + for message in messages or []: + if isinstance(message, dict) and "content" in message: + input_tokens += count_tokens(message["content"]) + elif isinstance(message, str): + input_tokens += count_tokens(message) + + if output_tokens == 0: + if streaming_message_responses is not None: + for message in streaming_message_responses: + output_tokens += count_tokens(message) + elif hasattr(response, "choices"): + for choice in response.choices: + if hasattr(choice, "message"): + output_tokens += count_tokens(choice.message) + + # Do not set token data if it is 0 + input_tokens = input_tokens or None + input_tokens_cached = input_tokens_cached or None + output_tokens = output_tokens or None + output_tokens_reasoning = output_tokens_reasoning or None + total_tokens = total_tokens or None + + record_token_usage( + span, + input_tokens=input_tokens, + input_tokens_cached=input_tokens_cached, + output_tokens=output_tokens, + output_tokens_reasoning=output_tokens_reasoning, + total_tokens=total_tokens, + ) + + +def _set_input_data( + span: "Span", + kwargs: "dict[str, Any]", + operation: str, + integration: "OpenAIIntegration", +) -> None: + # Input messages (the prompt or data sent to the model) + messages = kwargs.get("messages") + if messages is None: + messages = kwargs.get("input") + + if isinstance(messages, str): + messages = [messages] + + if ( + messages is not None + and len(messages) > 0 + and should_send_default_pii() + and integration.include_prompts + ): + normalized_messages = normalize_message_roles(messages) + scope = sentry_sdk.get_current_scope() + messages_data = truncate_and_annotate_messages(normalized_messages, span, scope) + if messages_data is not None: + # Use appropriate field based on operation type + if operation == "embeddings": + set_data_normalized( + span, SPANDATA.GEN_AI_EMBEDDINGS_INPUT, messages_data, unpack=False + ) + else: + set_data_normalized( + span, SPANDATA.GEN_AI_REQUEST_MESSAGES, messages_data, unpack=False + ) + + # Input attributes: Common + set_data_normalized(span, SPANDATA.GEN_AI_SYSTEM, "openai") + set_data_normalized(span, SPANDATA.GEN_AI_OPERATION_NAME, operation) + + # Input attributes: Optional + kwargs_keys_to_attributes = { + "model": SPANDATA.GEN_AI_REQUEST_MODEL, + "stream": SPANDATA.GEN_AI_RESPONSE_STREAMING, + "max_tokens": SPANDATA.GEN_AI_REQUEST_MAX_TOKENS, + "presence_penalty": SPANDATA.GEN_AI_REQUEST_PRESENCE_PENALTY, + "frequency_penalty": SPANDATA.GEN_AI_REQUEST_FREQUENCY_PENALTY, + "temperature": SPANDATA.GEN_AI_REQUEST_TEMPERATURE, + "top_p": SPANDATA.GEN_AI_REQUEST_TOP_P, + } + for key, attribute in kwargs_keys_to_attributes.items(): + value = kwargs.get(key) + + if value is not None and _is_given(value): + set_data_normalized(span, attribute, value) + + # Input attributes: Tools + tools = kwargs.get("tools") + if tools is not None and _is_given(tools) and len(tools) > 0: + set_data_normalized( + span, SPANDATA.GEN_AI_REQUEST_AVAILABLE_TOOLS, safe_serialize(tools) + ) + + +def _set_output_data( + span: "Span", + response: "Any", + kwargs: "dict[str, Any]", + integration: "OpenAIIntegration", + finish_span: bool = True, +) -> None: + if hasattr(response, "model"): + set_data_normalized(span, SPANDATA.GEN_AI_RESPONSE_MODEL, response.model) + + # Input messages (the prompt or data sent to the model) + # used for the token usage calculation + messages = kwargs.get("messages") + if messages is None: + messages = kwargs.get("input") + + if messages is not None and isinstance(messages, str): + messages = [messages] + + if hasattr(response, "choices"): + if should_send_default_pii() and integration.include_prompts: + response_text = [ + choice.message.model_dump() + for choice in response.choices + if choice.message is not None + ] + if len(response_text) > 0: + set_data_normalized(span, SPANDATA.GEN_AI_RESPONSE_TEXT, response_text) + + _calculate_token_usage(messages, response, span, None, integration.count_tokens) + + if finish_span: + span.__exit__(None, None, None) + + elif hasattr(response, "output"): + if should_send_default_pii() and integration.include_prompts: + output_messages: "dict[str, list[Any]]" = { + "response": [], + "tool": [], + } + + for output in response.output: + if output.type == "function_call": + output_messages["tool"].append(output.dict()) + elif output.type == "message": + for output_message in output.content: + try: + output_messages["response"].append(output_message.text) + except AttributeError: + # Unknown output message type, just return the json + output_messages["response"].append(output_message.dict()) + + if len(output_messages["tool"]) > 0: + set_data_normalized( + span, + SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS, + output_messages["tool"], + unpack=False, + ) + + if len(output_messages["response"]) > 0: + set_data_normalized( + span, SPANDATA.GEN_AI_RESPONSE_TEXT, output_messages["response"] + ) + + _calculate_token_usage(messages, response, span, None, integration.count_tokens) + + if finish_span: + span.__exit__(None, None, None) + + elif hasattr(response, "_iterator"): + data_buf: "list[list[str]]" = [] # one for each choice + + old_iterator = response._iterator + + def new_iterator() -> "Iterator[ChatCompletionChunk]": + count_tokens_manually = True + for x in old_iterator: + with capture_internal_exceptions(): + # OpenAI chat completion API + if hasattr(x, "choices"): + choice_index = 0 + for choice in x.choices: + if hasattr(choice, "delta") and hasattr( + choice.delta, "content" + ): + content = choice.delta.content + if len(data_buf) <= choice_index: + data_buf.append([]) + data_buf[choice_index].append(content or "") + choice_index += 1 + + # OpenAI responses API + elif hasattr(x, "delta"): + if len(data_buf) == 0: + data_buf.append([]) + data_buf[0].append(x.delta or "") + + # OpenAI responses API end of streaming response + if RESPONSES_API_ENABLED and isinstance(x, ResponseCompletedEvent): + _calculate_token_usage( + messages, + x.response, + span, + None, + integration.count_tokens, + ) + count_tokens_manually = False + + yield x + + with capture_internal_exceptions(): + if len(data_buf) > 0: + all_responses = ["".join(chunk) for chunk in data_buf] + if should_send_default_pii() and integration.include_prompts: + set_data_normalized( + span, SPANDATA.GEN_AI_RESPONSE_TEXT, all_responses + ) + if count_tokens_manually: + _calculate_token_usage( + messages, + response, + span, + all_responses, + integration.count_tokens, + ) + + if finish_span: + span.__exit__(None, None, None) + + async def new_iterator_async() -> "AsyncIterator[ChatCompletionChunk]": + count_tokens_manually = True + async for x in old_iterator: + with capture_internal_exceptions(): + # OpenAI chat completion API + if hasattr(x, "choices"): + choice_index = 0 + for choice in x.choices: + if hasattr(choice, "delta") and hasattr( + choice.delta, "content" + ): + content = choice.delta.content + if len(data_buf) <= choice_index: + data_buf.append([]) + data_buf[choice_index].append(content or "") + choice_index += 1 + + # OpenAI responses API + elif hasattr(x, "delta"): + if len(data_buf) == 0: + data_buf.append([]) + data_buf[0].append(x.delta or "") + + # OpenAI responses API end of streaming response + if RESPONSES_API_ENABLED and isinstance(x, ResponseCompletedEvent): + _calculate_token_usage( + messages, + x.response, + span, + None, + integration.count_tokens, + ) + count_tokens_manually = False + + yield x + + with capture_internal_exceptions(): + if len(data_buf) > 0: + all_responses = ["".join(chunk) for chunk in data_buf] + if should_send_default_pii() and integration.include_prompts: + set_data_normalized( + span, SPANDATA.GEN_AI_RESPONSE_TEXT, all_responses + ) + if count_tokens_manually: + _calculate_token_usage( + messages, + response, + span, + all_responses, + integration.count_tokens, + ) + if finish_span: + span.__exit__(None, None, None) + + if str(type(response._iterator)) == "": + response._iterator = new_iterator_async() + else: + response._iterator = new_iterator() + else: + _calculate_token_usage(messages, response, span, None, integration.count_tokens) + if finish_span: + span.__exit__(None, None, None) + + +def _new_chat_completion_common(f: "Any", *args: "Any", **kwargs: "Any") -> "Any": + integration = sentry_sdk.get_client().get_integration(OpenAIIntegration) + if integration is None: + return f(*args, **kwargs) + + if "messages" not in kwargs: + # invalid call (in all versions of openai), let it return error + return f(*args, **kwargs) + + try: + iter(kwargs["messages"]) + except TypeError: + # invalid call (in all versions), messages must be iterable + return f(*args, **kwargs) + + model = kwargs.get("model") + operation = "chat" + + span = sentry_sdk.start_span( + op=consts.OP.GEN_AI_CHAT, + name=f"{operation} {model}", + origin=OpenAIIntegration.origin, + ) + span.__enter__() + + _set_input_data(span, kwargs, operation, integration) + + response = yield f, args, kwargs + + _set_output_data(span, response, kwargs, integration, finish_span=True) + + return response + + +def _wrap_chat_completion_create(f: "Callable[..., Any]") -> "Callable[..., Any]": + def _execute_sync(f: "Any", *args: "Any", **kwargs: "Any") -> "Any": + gen = _new_chat_completion_common(f, *args, **kwargs) + + try: + f, args, kwargs = next(gen) + except StopIteration as e: + return e.value + + try: + try: + result = f(*args, **kwargs) + except Exception as e: + _capture_exception(e) + raise e from None + + return gen.send(result) + except StopIteration as e: + return e.value + + @wraps(f) + def _sentry_patched_create_sync(*args: "Any", **kwargs: "Any") -> "Any": + integration = sentry_sdk.get_client().get_integration(OpenAIIntegration) + if integration is None or "messages" not in kwargs: + # no "messages" means invalid call (in all versions of openai), let it return error + return f(*args, **kwargs) + + return _execute_sync(f, *args, **kwargs) + + return _sentry_patched_create_sync + + +def _wrap_async_chat_completion_create(f: "Callable[..., Any]") -> "Callable[..., Any]": + async def _execute_async(f: "Any", *args: "Any", **kwargs: "Any") -> "Any": + gen = _new_chat_completion_common(f, *args, **kwargs) + + try: + f, args, kwargs = next(gen) + except StopIteration as e: + return await e.value + + try: + try: + result = await f(*args, **kwargs) + except Exception as e: + _capture_exception(e) + raise e from None + + return gen.send(result) + except StopIteration as e: + return e.value + + @wraps(f) + async def _sentry_patched_create_async(*args: "Any", **kwargs: "Any") -> "Any": + integration = sentry_sdk.get_client().get_integration(OpenAIIntegration) + if integration is None or "messages" not in kwargs: + # no "messages" means invalid call (in all versions of openai), let it return error + return await f(*args, **kwargs) + + return await _execute_async(f, *args, **kwargs) + + return _sentry_patched_create_async + + +def _new_embeddings_create_common(f: "Any", *args: "Any", **kwargs: "Any") -> "Any": + integration = sentry_sdk.get_client().get_integration(OpenAIIntegration) + if integration is None: + return f(*args, **kwargs) + + model = kwargs.get("model") + operation = "embeddings" + + with sentry_sdk.start_span( + op=consts.OP.GEN_AI_EMBEDDINGS, + name=f"{operation} {model}", + origin=OpenAIIntegration.origin, + ) as span: + _set_input_data(span, kwargs, operation, integration) + + response = yield f, args, kwargs + + _set_output_data(span, response, kwargs, integration, finish_span=False) + + return response + + +def _wrap_embeddings_create(f: "Any") -> "Any": + def _execute_sync(f: "Any", *args: "Any", **kwargs: "Any") -> "Any": + gen = _new_embeddings_create_common(f, *args, **kwargs) + + try: + f, args, kwargs = next(gen) + except StopIteration as e: + return e.value + + try: + try: + result = f(*args, **kwargs) + except Exception as e: + _capture_exception(e, manual_span_cleanup=False) + raise e from None + + return gen.send(result) + except StopIteration as e: + return e.value + + @wraps(f) + def _sentry_patched_create_sync(*args: "Any", **kwargs: "Any") -> "Any": + integration = sentry_sdk.get_client().get_integration(OpenAIIntegration) + if integration is None: + return f(*args, **kwargs) + + return _execute_sync(f, *args, **kwargs) + + return _sentry_patched_create_sync + + +def _wrap_async_embeddings_create(f: "Any") -> "Any": + async def _execute_async(f: "Any", *args: "Any", **kwargs: "Any") -> "Any": + gen = _new_embeddings_create_common(f, *args, **kwargs) + + try: + f, args, kwargs = next(gen) + except StopIteration as e: + return await e.value + + try: + try: + result = await f(*args, **kwargs) + except Exception as e: + _capture_exception(e, manual_span_cleanup=False) + raise e from None + + return gen.send(result) + except StopIteration as e: + return e.value + + @wraps(f) + async def _sentry_patched_create_async(*args: "Any", **kwargs: "Any") -> "Any": + integration = sentry_sdk.get_client().get_integration(OpenAIIntegration) + if integration is None: + return await f(*args, **kwargs) + + return await _execute_async(f, *args, **kwargs) + + return _sentry_patched_create_async + + +def _new_responses_create_common(f: "Any", *args: "Any", **kwargs: "Any") -> "Any": + integration = sentry_sdk.get_client().get_integration(OpenAIIntegration) + if integration is None: + return f(*args, **kwargs) + + model = kwargs.get("model") + operation = "responses" + + span = sentry_sdk.start_span( + op=consts.OP.GEN_AI_RESPONSES, + name=f"{operation} {model}", + origin=OpenAIIntegration.origin, + ) + span.__enter__() + + _set_input_data(span, kwargs, operation, integration) + + response = yield f, args, kwargs + + _set_output_data(span, response, kwargs, integration, finish_span=True) + + return response + + +def _wrap_responses_create(f: "Any") -> "Any": + def _execute_sync(f: "Any", *args: "Any", **kwargs: "Any") -> "Any": + gen = _new_responses_create_common(f, *args, **kwargs) + + try: + f, args, kwargs = next(gen) + except StopIteration as e: + return e.value + + try: + try: + result = f(*args, **kwargs) + except Exception as e: + _capture_exception(e) + raise e from None + + return gen.send(result) + except StopIteration as e: + return e.value + + @wraps(f) + def _sentry_patched_create_sync(*args: "Any", **kwargs: "Any") -> "Any": + integration = sentry_sdk.get_client().get_integration(OpenAIIntegration) + if integration is None: + return f(*args, **kwargs) + + return _execute_sync(f, *args, **kwargs) + + return _sentry_patched_create_sync + + +def _wrap_async_responses_create(f: "Any") -> "Any": + async def _execute_async(f: "Any", *args: "Any", **kwargs: "Any") -> "Any": + gen = _new_responses_create_common(f, *args, **kwargs) + + try: + f, args, kwargs = next(gen) + except StopIteration as e: + return await e.value + + try: + try: + result = await f(*args, **kwargs) + except Exception as e: + _capture_exception(e) + raise e from None + + return gen.send(result) + except StopIteration as e: + return e.value + + @wraps(f) + async def _sentry_patched_responses_async(*args: "Any", **kwargs: "Any") -> "Any": + integration = sentry_sdk.get_client().get_integration(OpenAIIntegration) + if integration is None: + return await f(*args, **kwargs) + + return await _execute_async(f, *args, **kwargs) + + return _sentry_patched_responses_async + + +def _is_given(obj: "Any") -> bool: + """ + Check for givenness safely across different openai versions. + """ + if NotGiven is not None and isinstance(obj, NotGiven): + return False + if Omit is not None and isinstance(obj, Omit): + return False + return True diff --git a/sentry_sdk/integrations/openai_agents/__init__.py b/sentry_sdk/integrations/openai_agents/__init__.py new file mode 100644 index 0000000000..3ad7d2ee8d --- /dev/null +++ b/sentry_sdk/integrations/openai_agents/__init__.py @@ -0,0 +1,57 @@ +from sentry_sdk.integrations import DidNotEnable, Integration + +from .patches import ( + _create_get_model_wrapper, + _create_get_all_tools_wrapper, + _create_run_wrapper, + _patch_agent_run, + _patch_error_tracing, +) + +try: + # "agents" is too generic. If someone has an agents.py file in their project + # or another package that's importable via "agents", no ImportError would not + # be thrown and the integration would enable itself even if openai-agents is + # not installed. That's why we're adding the second, more specific import + # after it, even if we don't use it. + import agents + from agents.run import DEFAULT_AGENT_RUNNER + +except ImportError: + raise DidNotEnable("OpenAI Agents not installed") + + +def _patch_runner() -> None: + # Create the root span for one full agent run (including eventual handoffs) + # Note agents.run.DEFAULT_AGENT_RUNNER.run_sync is a wrapper around + # agents.run.DEFAULT_AGENT_RUNNER.run. It does not need to be wrapped separately. + # TODO-anton: Also patch streaming runner: agents.Runner.run_streamed + agents.run.DEFAULT_AGENT_RUNNER.run = _create_run_wrapper( + agents.run.DEFAULT_AGENT_RUNNER.run + ) + + # Creating the actual spans for each agent run. + _patch_agent_run() + + +def _patch_model() -> None: + agents.run.AgentRunner._get_model = classmethod( + _create_get_model_wrapper(agents.run.AgentRunner._get_model), + ) + + +def _patch_tools() -> None: + agents.run.AgentRunner._get_all_tools = classmethod( + _create_get_all_tools_wrapper(agents.run.AgentRunner._get_all_tools), + ) + + +class OpenAIAgentsIntegration(Integration): + identifier = "openai_agents" + + @staticmethod + def setup_once() -> None: + _patch_error_tracing() + _patch_tools() + _patch_model() + _patch_runner() diff --git a/sentry_sdk/integrations/openai_agents/consts.py b/sentry_sdk/integrations/openai_agents/consts.py new file mode 100644 index 0000000000..f5de978be0 --- /dev/null +++ b/sentry_sdk/integrations/openai_agents/consts.py @@ -0,0 +1 @@ +SPAN_ORIGIN = "auto.ai.openai_agents" diff --git a/sentry_sdk/integrations/openai_agents/patches/__init__.py b/sentry_sdk/integrations/openai_agents/patches/__init__.py new file mode 100644 index 0000000000..33058f01a1 --- /dev/null +++ b/sentry_sdk/integrations/openai_agents/patches/__init__.py @@ -0,0 +1,5 @@ +from .models import _create_get_model_wrapper # noqa: F401 +from .tools import _create_get_all_tools_wrapper # noqa: F401 +from .runner import _create_run_wrapper # noqa: F401 +from .agent_run import _patch_agent_run # noqa: F401 +from .error_tracing import _patch_error_tracing # noqa: F401 diff --git a/sentry_sdk/integrations/openai_agents/patches/agent_run.py b/sentry_sdk/integrations/openai_agents/patches/agent_run.py new file mode 100644 index 0000000000..29649af945 --- /dev/null +++ b/sentry_sdk/integrations/openai_agents/patches/agent_run.py @@ -0,0 +1,157 @@ +import sys +from functools import wraps + +from sentry_sdk.integrations import DidNotEnable +from sentry_sdk.utils import reraise +from ..spans import ( + invoke_agent_span, + end_invoke_agent_span, + handoff_span, +) +from ..utils import _record_exception_on_span + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Any, Optional + + from sentry_sdk.tracing import Span + +try: + import agents +except ImportError: + raise DidNotEnable("OpenAI Agents not installed") + + +def _patch_agent_run() -> None: + """ + Patches AgentRunner methods to create agent invocation spans. + This directly patches the execution flow to track when agents start and stop. + """ + + # Store original methods + original_run_single_turn = agents.run.AgentRunner._run_single_turn + original_execute_handoffs = agents._run_impl.RunImpl.execute_handoffs + original_execute_final_output = agents._run_impl.RunImpl.execute_final_output + + def _start_invoke_agent_span( + context_wrapper: "agents.RunContextWrapper", + agent: "agents.Agent", + kwargs: "dict[str, Any]", + ) -> "Span": + """Start an agent invocation span""" + # Store the agent on the context wrapper so we can access it later + context_wrapper._sentry_current_agent = agent + span = invoke_agent_span(context_wrapper, agent, kwargs) + context_wrapper._sentry_agent_span = span + + return span + + def _has_active_agent_span(context_wrapper: "agents.RunContextWrapper") -> bool: + """Check if there's an active agent span for this context""" + return getattr(context_wrapper, "_sentry_current_agent", None) is not None + + def _get_current_agent( + context_wrapper: "agents.RunContextWrapper", + ) -> "Optional[agents.Agent]": + """Get the current agent from context wrapper""" + return getattr(context_wrapper, "_sentry_current_agent", None) + + @wraps( + original_run_single_turn.__func__ + if hasattr(original_run_single_turn, "__func__") + else original_run_single_turn + ) + async def patched_run_single_turn( + cls: "agents.Runner", *args: "Any", **kwargs: "Any" + ) -> "Any": + """Patched _run_single_turn that creates agent invocation spans""" + agent = kwargs.get("agent") + context_wrapper = kwargs.get("context_wrapper") + should_run_agent_start_hooks = kwargs.get("should_run_agent_start_hooks") + + span = getattr(context_wrapper, "_sentry_agent_span", None) + # Start agent span when agent starts (but only once per agent) + if should_run_agent_start_hooks and agent and context_wrapper: + # End any existing span for a different agent + if _has_active_agent_span(context_wrapper): + current_agent = _get_current_agent(context_wrapper) + if current_agent and current_agent != agent: + end_invoke_agent_span(context_wrapper, current_agent) + + span = _start_invoke_agent_span(context_wrapper, agent, kwargs) + agent._sentry_agent_span = span + + # Call original method with all the correct parameters + try: + result = await original_run_single_turn(*args, **kwargs) + except Exception as exc: + if span is not None and span.timestamp is None: + _record_exception_on_span(span, exc) + end_invoke_agent_span(context_wrapper, agent) + + reraise(*sys.exc_info()) + + return result + + @wraps( + original_execute_handoffs.__func__ + if hasattr(original_execute_handoffs, "__func__") + else original_execute_handoffs + ) + async def patched_execute_handoffs( + cls: "agents.Runner", *args: "Any", **kwargs: "Any" + ) -> "Any": + """Patched execute_handoffs that creates handoff spans and ends agent span for handoffs""" + + context_wrapper = kwargs.get("context_wrapper") + run_handoffs = kwargs.get("run_handoffs") + agent = kwargs.get("agent") + + # Create Sentry handoff span for the first handoff (agents library only processes the first one) + if run_handoffs: + first_handoff = run_handoffs[0] + handoff_agent_name = first_handoff.handoff.agent_name + handoff_span(context_wrapper, agent, handoff_agent_name) + + # Call original method with all parameters + try: + result = await original_execute_handoffs(*args, **kwargs) + + finally: + # End span for current agent after handoff processing is complete + if agent and context_wrapper and _has_active_agent_span(context_wrapper): + end_invoke_agent_span(context_wrapper, agent) + + return result + + @wraps( + original_execute_final_output.__func__ + if hasattr(original_execute_final_output, "__func__") + else original_execute_final_output + ) + async def patched_execute_final_output( + cls: "agents.Runner", *args: "Any", **kwargs: "Any" + ) -> "Any": + """Patched execute_final_output that ends agent span for final outputs""" + + agent = kwargs.get("agent") + context_wrapper = kwargs.get("context_wrapper") + final_output = kwargs.get("final_output") + + # Call original method with all parameters + try: + result = await original_execute_final_output(*args, **kwargs) + finally: + # End span for current agent after final output processing is complete + if agent and context_wrapper and _has_active_agent_span(context_wrapper): + end_invoke_agent_span(context_wrapper, agent, final_output) + + return result + + # Apply patches + agents.run.AgentRunner._run_single_turn = classmethod(patched_run_single_turn) + agents._run_impl.RunImpl.execute_handoffs = classmethod(patched_execute_handoffs) + agents._run_impl.RunImpl.execute_final_output = classmethod( + patched_execute_final_output + ) diff --git a/sentry_sdk/integrations/openai_agents/patches/error_tracing.py b/sentry_sdk/integrations/openai_agents/patches/error_tracing.py new file mode 100644 index 0000000000..8598d9c4fd --- /dev/null +++ b/sentry_sdk/integrations/openai_agents/patches/error_tracing.py @@ -0,0 +1,69 @@ +from functools import wraps + +import sentry_sdk +from sentry_sdk.consts import SPANSTATUS +from sentry_sdk.tracing_utils import set_span_errored +from ..utils import _record_exception_on_span + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Any, Callable, Optional + + +def _patch_error_tracing() -> None: + """ + Patches agents error tracing function to inject our span error logic + when a tool execution fails. + + In newer versions, the function is at: agents.util._error_tracing.attach_error_to_current_span + In older versions, it was at: agents._utils.attach_error_to_current_span + + This works even when the module or function doesn't exist. + """ + error_tracing_module = None + + # Try newer location first (agents.util._error_tracing) + try: + from agents.util import _error_tracing + + error_tracing_module = _error_tracing + except (ImportError, AttributeError): + pass + + # Try older location (agents._utils) + if error_tracing_module is None: + try: + import agents._utils + + error_tracing_module = agents._utils + except (ImportError, AttributeError): + # Module doesn't exist in either location, nothing to patch + return + + # Check if the function exists + if not hasattr(error_tracing_module, "attach_error_to_current_span"): + return + + original_attach_error = error_tracing_module.attach_error_to_current_span + + @wraps(original_attach_error) + def sentry_attach_error_to_current_span( + error: "Any", *args: "Any", **kwargs: "Any" + ) -> "Any": + """ + Wraps agents' error attachment to also set Sentry span status to error. + This allows us to properly track tool execution errors even though + the agents library swallows exceptions. + """ + # Set the current Sentry span to errored + current_span = sentry_sdk.get_current_span() + if current_span is not None: + _record_exception_on_span(current_span, error) + + # Call the original function + return original_attach_error(error, *args, **kwargs) + + error_tracing_module.attach_error_to_current_span = ( + sentry_attach_error_to_current_span + ) diff --git a/sentry_sdk/integrations/openai_agents/patches/models.py b/sentry_sdk/integrations/openai_agents/patches/models.py new file mode 100644 index 0000000000..a9b3c16a22 --- /dev/null +++ b/sentry_sdk/integrations/openai_agents/patches/models.py @@ -0,0 +1,78 @@ +import copy +from functools import wraps + +from sentry_sdk.integrations import DidNotEnable + +from ..spans import ai_client_span, update_ai_client_span +from sentry_sdk.consts import SPANDATA + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Any, Callable + + +try: + import agents +except ImportError: + raise DidNotEnable("OpenAI Agents not installed") + + +def _create_get_model_wrapper( + original_get_model: "Callable[..., Any]", +) -> "Callable[..., Any]": + """ + Wraps the agents.Runner._get_model method to wrap the get_response method of the model to create a AI client span. + """ + + @wraps( + original_get_model.__func__ + if hasattr(original_get_model, "__func__") + else original_get_model + ) + def wrapped_get_model( + cls: "agents.Runner", agent: "agents.Agent", run_config: "agents.RunConfig" + ) -> "agents.Model": + # copy the model to double patching its methods. We use copy on purpose here (instead of deepcopy) + # because we only patch its direct methods, all underlying data can remain unchanged. + model = copy.copy(original_get_model(agent, run_config)) + + # Wrap _fetch_response if it exists (for OpenAI models) to capture raw response model + if hasattr(model, "_fetch_response"): + original_fetch_response = model._fetch_response + + @wraps(original_fetch_response) + async def wrapped_fetch_response(*args: "Any", **kwargs: "Any") -> "Any": + response = await original_fetch_response(*args, **kwargs) + if hasattr(response, "model"): + agent._sentry_raw_response_model = str(response.model) + return response + + model._fetch_response = wrapped_fetch_response + + original_get_response = model.get_response + + @wraps(original_get_response) + async def wrapped_get_response(*args: "Any", **kwargs: "Any") -> "Any": + with ai_client_span(agent, kwargs) as span: + result = await original_get_response(*args, **kwargs) + + response_model = getattr(agent, "_sentry_raw_response_model", None) + if response_model: + agent_span = getattr(agent, "_sentry_agent_span", None) + if agent_span: + agent_span.set_data( + SPANDATA.GEN_AI_RESPONSE_MODEL, response_model + ) + + delattr(agent, "_sentry_raw_response_model") + + update_ai_client_span(span, agent, kwargs, result, response_model) + + return result + + model.get_response = wrapped_get_response + + return model + + return wrapped_get_model diff --git a/sentry_sdk/integrations/openai_agents/patches/runner.py b/sentry_sdk/integrations/openai_agents/patches/runner.py new file mode 100644 index 0000000000..1d3bbc894b --- /dev/null +++ b/sentry_sdk/integrations/openai_agents/patches/runner.py @@ -0,0 +1,66 @@ +from functools import wraps + +import sentry_sdk +from sentry_sdk.integrations import DidNotEnable + +from ..spans import agent_workflow_span, end_invoke_agent_span +from ..utils import _capture_exception, _record_exception_on_span + +try: + from agents.exceptions import AgentsException +except ImportError: + raise DidNotEnable("OpenAI Agents not installed") + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Any, Callable + + +def _create_run_wrapper(original_func: "Callable[..., Any]") -> "Callable[..., Any]": + """ + Wraps the agents.Runner.run methods to create a root span for the agent workflow runs. + + Note agents.Runner.run_sync() is a wrapper around agents.Runner.run(), + so it does not need to be wrapped separately. + """ + + @wraps(original_func) + async def wrapper(*args: "Any", **kwargs: "Any") -> "Any": + # Isolate each workflow so that when agents are run in asyncio tasks they + # don't touch each other's scopes + with sentry_sdk.isolation_scope(): + # Clone agent because agent invocation spans are attached per run. + agent = args[0].clone() + with agent_workflow_span(agent): + args = (agent, *args[1:]) + try: + run_result = await original_func(*args, **kwargs) + except AgentsException as exc: + _capture_exception(exc) + + context_wrapper = getattr(exc.run_data, "context_wrapper", None) + if context_wrapper is not None: + invoke_agent_span = getattr( + context_wrapper, "_sentry_agent_span", None + ) + + if ( + invoke_agent_span is not None + and invoke_agent_span.timestamp is None + ): + _record_exception_on_span(invoke_agent_span, exc) + end_invoke_agent_span(context_wrapper, agent) + + raise exc from None + except Exception as exc: + # Invoke agent span is not finished in this case. + # This is much less likely to occur than other cases because + # AgentRunner.run() is "just" a while loop around _run_single_turn. + _capture_exception(exc) + raise exc from None + + end_invoke_agent_span(run_result.context_wrapper, agent) + return run_result + + return wrapper diff --git a/sentry_sdk/integrations/openai_agents/patches/tools.py b/sentry_sdk/integrations/openai_agents/patches/tools.py new file mode 100644 index 0000000000..d14a3019aa --- /dev/null +++ b/sentry_sdk/integrations/openai_agents/patches/tools.py @@ -0,0 +1,82 @@ +from functools import wraps + +from sentry_sdk.integrations import DidNotEnable + +from ..spans import execute_tool_span, update_execute_tool_span + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Any, Callable + +try: + import agents +except ImportError: + raise DidNotEnable("OpenAI Agents not installed") + + +def _create_get_all_tools_wrapper( + original_get_all_tools: "Callable[..., Any]", +) -> "Callable[..., Any]": + """ + Wraps the agents.Runner._get_all_tools method of the Runner class to wrap all function tools with Sentry instrumentation. + """ + + @wraps( + original_get_all_tools.__func__ + if hasattr(original_get_all_tools, "__func__") + else original_get_all_tools + ) + async def wrapped_get_all_tools( + cls: "agents.Runner", + agent: "agents.Agent", + context_wrapper: "agents.RunContextWrapper", + ) -> "list[agents.Tool]": + # Get the original tools + tools = await original_get_all_tools(agent, context_wrapper) + + wrapped_tools = [] + for tool in tools: + # Wrap only the function tools (for now) + if tool.__class__.__name__ != "FunctionTool": + wrapped_tools.append(tool) + continue + + # Create a new FunctionTool with our wrapped invoke method + original_on_invoke = tool.on_invoke_tool + + def create_wrapped_invoke( + current_tool: "agents.Tool", current_on_invoke: "Callable[..., Any]" + ) -> "Callable[..., Any]": + @wraps(current_on_invoke) + async def sentry_wrapped_on_invoke_tool( + *args: "Any", **kwargs: "Any" + ) -> "Any": + with execute_tool_span(current_tool, *args, **kwargs) as span: + # We can not capture exceptions in tool execution here because + # `_on_invoke_tool` is swallowing the exception here: + # https://github.com/openai/openai-agents-python/blob/main/src/agents/tool.py#L409-L422 + # And because function_tool is a decorator with `default_tool_error_function` set as a default parameter + # I was unable to monkey patch it because those are evaluated at module import time + # and the SDK is too late to patch it. I was also unable to patch `_on_invoke_tool_impl` + # because it is nested inside this import time code. As if they made it hard to patch on purpose... + result = await current_on_invoke(*args, **kwargs) + update_execute_tool_span(span, agent, current_tool, result) + + return result + + return sentry_wrapped_on_invoke_tool + + wrapped_tool = agents.FunctionTool( + name=tool.name, + description=tool.description, + params_json_schema=tool.params_json_schema, + on_invoke_tool=create_wrapped_invoke(tool, original_on_invoke), + strict_json_schema=tool.strict_json_schema, + is_enabled=tool.is_enabled, + ) + wrapped_tools.append(wrapped_tool) + + return wrapped_tools + + return wrapped_get_all_tools diff --git a/sentry_sdk/integrations/openai_agents/spans/__init__.py b/sentry_sdk/integrations/openai_agents/spans/__init__.py new file mode 100644 index 0000000000..64b979fc25 --- /dev/null +++ b/sentry_sdk/integrations/openai_agents/spans/__init__.py @@ -0,0 +1,9 @@ +from .agent_workflow import agent_workflow_span # noqa: F401 +from .ai_client import ai_client_span, update_ai_client_span # noqa: F401 +from .execute_tool import execute_tool_span, update_execute_tool_span # noqa: F401 +from .handoff import handoff_span # noqa: F401 +from .invoke_agent import ( + invoke_agent_span, + update_invoke_agent_span, + end_invoke_agent_span, +) # noqa: F401 diff --git a/sentry_sdk/integrations/openai_agents/spans/agent_workflow.py b/sentry_sdk/integrations/openai_agents/spans/agent_workflow.py new file mode 100644 index 0000000000..1734595f8e --- /dev/null +++ b/sentry_sdk/integrations/openai_agents/spans/agent_workflow.py @@ -0,0 +1,19 @@ +import sentry_sdk +from sentry_sdk.ai.utils import get_start_span_function + +from ..consts import SPAN_ORIGIN + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import agents + + +def agent_workflow_span(agent: "agents.Agent") -> "sentry_sdk.tracing.Span": + # Create a transaction or a span if an transaction is already active + span = get_start_span_function()( + name=f"{agent.name} workflow", + origin=SPAN_ORIGIN, + ) + + return span diff --git a/sentry_sdk/integrations/openai_agents/spans/ai_client.py b/sentry_sdk/integrations/openai_agents/spans/ai_client.py new file mode 100644 index 0000000000..1e188aa097 --- /dev/null +++ b/sentry_sdk/integrations/openai_agents/spans/ai_client.py @@ -0,0 +1,52 @@ +import sentry_sdk +from sentry_sdk.consts import OP, SPANDATA + +from ..consts import SPAN_ORIGIN +from ..utils import ( + _set_agent_data, + _set_input_data, + _set_output_data, + _set_usage_data, + _create_mcp_execute_tool_spans, +) + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from agents import Agent + from typing import Any, Optional + + +def ai_client_span( + agent: "Agent", get_response_kwargs: "dict[str, Any]" +) -> "sentry_sdk.tracing.Span": + # TODO-anton: implement other types of operations. Now "chat" is hardcoded. + model_name = agent.model.model if hasattr(agent.model, "model") else agent.model + span = sentry_sdk.start_span( + op=OP.GEN_AI_CHAT, + description=f"chat {model_name}", + origin=SPAN_ORIGIN, + ) + # TODO-anton: remove hardcoded stuff and replace something that also works for embedding and so on + span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "chat") + + _set_agent_data(span, agent) + _set_input_data(span, get_response_kwargs) + + return span + + +def update_ai_client_span( + span: "sentry_sdk.tracing.Span", + agent: "Agent", + get_response_kwargs: "dict[str, Any]", + result: "Any", + response_model: "Optional[str]" = None, +) -> None: + _set_usage_data(span, result.usage) + _set_output_data(span, result) + _create_mcp_execute_tool_spans(span, result) + + # Set response model if captured from raw response + if response_model is not None: + span.set_data(SPANDATA.GEN_AI_RESPONSE_MODEL, response_model) diff --git a/sentry_sdk/integrations/openai_agents/spans/execute_tool.py b/sentry_sdk/integrations/openai_agents/spans/execute_tool.py new file mode 100644 index 0000000000..aa89da1610 --- /dev/null +++ b/sentry_sdk/integrations/openai_agents/spans/execute_tool.py @@ -0,0 +1,53 @@ +import sentry_sdk +from sentry_sdk.consts import OP, SPANDATA, SPANSTATUS +from sentry_sdk.scope import should_send_default_pii + +from ..consts import SPAN_ORIGIN +from ..utils import _set_agent_data + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import agents + from typing import Any + + +def execute_tool_span( + tool: "agents.Tool", *args: "Any", **kwargs: "Any" +) -> "sentry_sdk.tracing.Span": + span = sentry_sdk.start_span( + op=OP.GEN_AI_EXECUTE_TOOL, + name=f"execute_tool {tool.name}", + origin=SPAN_ORIGIN, + ) + + span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "execute_tool") + + if tool.__class__.__name__ == "FunctionTool": + span.set_data(SPANDATA.GEN_AI_TOOL_TYPE, "function") + + span.set_data(SPANDATA.GEN_AI_TOOL_NAME, tool.name) + span.set_data(SPANDATA.GEN_AI_TOOL_DESCRIPTION, tool.description) + + if should_send_default_pii(): + input = args[1] + span.set_data(SPANDATA.GEN_AI_TOOL_INPUT, input) + + return span + + +def update_execute_tool_span( + span: "sentry_sdk.tracing.Span", + agent: "agents.Agent", + tool: "agents.Tool", + result: "Any", +) -> None: + _set_agent_data(span, agent) + + if isinstance(result, str) and result.startswith( + "An error occurred while running the tool" + ): + span.set_status(SPANSTATUS.INTERNAL_ERROR) + + if should_send_default_pii(): + span.set_data(SPANDATA.GEN_AI_TOOL_OUTPUT, result) diff --git a/sentry_sdk/integrations/openai_agents/spans/handoff.py b/sentry_sdk/integrations/openai_agents/spans/handoff.py new file mode 100644 index 0000000000..c514505b17 --- /dev/null +++ b/sentry_sdk/integrations/openai_agents/spans/handoff.py @@ -0,0 +1,20 @@ +import sentry_sdk +from sentry_sdk.consts import OP, SPANDATA + +from ..consts import SPAN_ORIGIN + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import agents + + +def handoff_span( + context: "agents.RunContextWrapper", from_agent: "agents.Agent", to_agent_name: str +) -> None: + with sentry_sdk.start_span( + op=OP.GEN_AI_HANDOFF, + name=f"handoff from {from_agent.name} to {to_agent_name}", + origin=SPAN_ORIGIN, + ) as span: + span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "handoff") diff --git a/sentry_sdk/integrations/openai_agents/spans/invoke_agent.py b/sentry_sdk/integrations/openai_agents/spans/invoke_agent.py new file mode 100644 index 0000000000..c3a3a04dc9 --- /dev/null +++ b/sentry_sdk/integrations/openai_agents/spans/invoke_agent.py @@ -0,0 +1,112 @@ +import sentry_sdk +from sentry_sdk.ai.utils import ( + get_start_span_function, + set_data_normalized, + normalize_message_roles, + truncate_and_annotate_messages, +) +from sentry_sdk.consts import OP, SPANDATA +from sentry_sdk.scope import should_send_default_pii +from sentry_sdk.utils import safe_serialize + +from ..consts import SPAN_ORIGIN +from ..utils import _set_agent_data, _set_usage_data + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import agents + from typing import Any, Optional + + +def invoke_agent_span( + context: "agents.RunContextWrapper", agent: "agents.Agent", kwargs: "dict[str, Any]" +) -> "sentry_sdk.tracing.Span": + start_span_function = get_start_span_function() + span = start_span_function( + op=OP.GEN_AI_INVOKE_AGENT, + name=f"invoke_agent {agent.name}", + origin=SPAN_ORIGIN, + ) + span.__enter__() + + span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "invoke_agent") + + if should_send_default_pii(): + messages = [] + if agent.instructions: + message = ( + agent.instructions + if isinstance(agent.instructions, str) + else safe_serialize(agent.instructions) + ) + messages.append( + { + "content": [{"text": message, "type": "text"}], + "role": "system", + } + ) + + original_input = kwargs.get("original_input") + if original_input is not None: + message = ( + original_input + if isinstance(original_input, str) + else safe_serialize(original_input) + ) + messages.append( + { + "content": [{"text": message, "type": "text"}], + "role": "user", + } + ) + + if len(messages) > 0: + normalized_messages = normalize_message_roles(messages) + scope = sentry_sdk.get_current_scope() + messages_data = truncate_and_annotate_messages( + normalized_messages, span, scope + ) + if messages_data is not None: + set_data_normalized( + span, + SPANDATA.GEN_AI_REQUEST_MESSAGES, + messages_data, + unpack=False, + ) + + _set_agent_data(span, agent) + + return span + + +def update_invoke_agent_span( + context: "agents.RunContextWrapper", agent: "agents.Agent", output: "Any" +) -> None: + span = getattr(context, "_sentry_agent_span", None) + + if span: + # Add aggregated usage data from context_wrapper + if hasattr(context, "usage"): + _set_usage_data(span, context.usage) + + if should_send_default_pii(): + set_data_normalized( + span, SPANDATA.GEN_AI_RESPONSE_TEXT, output, unpack=False + ) + + span.__exit__(None, None, None) + delattr(context, "_sentry_agent_span") + + +def end_invoke_agent_span( + context_wrapper: "agents.RunContextWrapper", + agent: "agents.Agent", + output: "Optional[Any]" = None, +) -> None: + """End the agent invocation span""" + # Clear the stored agent + if hasattr(context_wrapper, "_sentry_current_agent"): + delattr(context_wrapper, "_sentry_current_agent") + + update_invoke_agent_span(context_wrapper, agent, output) diff --git a/sentry_sdk/integrations/openai_agents/utils.py b/sentry_sdk/integrations/openai_agents/utils.py new file mode 100644 index 0000000000..a24d0e909d --- /dev/null +++ b/sentry_sdk/integrations/openai_agents/utils.py @@ -0,0 +1,222 @@ +import sentry_sdk +from sentry_sdk.ai.utils import ( + GEN_AI_ALLOWED_MESSAGE_ROLES, + normalize_message_roles, + set_data_normalized, + normalize_message_role, + truncate_and_annotate_messages, +) +from sentry_sdk.consts import SPANDATA, SPANSTATUS, OP +from sentry_sdk.integrations import DidNotEnable +from sentry_sdk.scope import should_send_default_pii +from sentry_sdk.tracing_utils import set_span_errored +from sentry_sdk.utils import event_from_exception, safe_serialize + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Any + from agents import Usage + + from sentry_sdk.tracing import Span + +try: + import agents + +except ImportError: + raise DidNotEnable("OpenAI Agents not installed") + + +def _capture_exception(exc: "Any") -> None: + set_span_errored() + + event, hint = event_from_exception( + exc, + client_options=sentry_sdk.get_client().options, + mechanism={"type": "openai_agents", "handled": False}, + ) + sentry_sdk.capture_event(event, hint=hint) + + +def _record_exception_on_span(span: "Span", error: Exception) -> "Any": + set_span_errored(span) + span.set_data("span.status", "error") + + # Optionally capture the error details if we have them + if hasattr(error, "__class__"): + span.set_data("error.type", error.__class__.__name__) + if hasattr(error, "__str__"): + error_message = str(error) + if error_message: + span.set_data("error.message", error_message) + + +def _set_agent_data(span: "sentry_sdk.tracing.Span", agent: "agents.Agent") -> None: + span.set_data( + SPANDATA.GEN_AI_SYSTEM, "openai" + ) # See footnote for https://opentelemetry.io/docs/specs/semconv/registry/attributes/gen-ai/#gen-ai-system for explanation why. + + span.set_data(SPANDATA.GEN_AI_AGENT_NAME, agent.name) + + if agent.model_settings.max_tokens: + span.set_data( + SPANDATA.GEN_AI_REQUEST_MAX_TOKENS, agent.model_settings.max_tokens + ) + + if agent.model: + model_name = agent.model.model if hasattr(agent.model, "model") else agent.model + span.set_data(SPANDATA.GEN_AI_REQUEST_MODEL, model_name) + + if agent.model_settings.presence_penalty: + span.set_data( + SPANDATA.GEN_AI_REQUEST_PRESENCE_PENALTY, + agent.model_settings.presence_penalty, + ) + + if agent.model_settings.temperature: + span.set_data( + SPANDATA.GEN_AI_REQUEST_TEMPERATURE, agent.model_settings.temperature + ) + + if agent.model_settings.top_p: + span.set_data(SPANDATA.GEN_AI_REQUEST_TOP_P, agent.model_settings.top_p) + + if agent.model_settings.frequency_penalty: + span.set_data( + SPANDATA.GEN_AI_REQUEST_FREQUENCY_PENALTY, + agent.model_settings.frequency_penalty, + ) + + if len(agent.tools) > 0: + span.set_data( + SPANDATA.GEN_AI_REQUEST_AVAILABLE_TOOLS, + safe_serialize([vars(tool) for tool in agent.tools]), + ) + + +def _set_usage_data(span: "sentry_sdk.tracing.Span", usage: "Usage") -> None: + span.set_data(SPANDATA.GEN_AI_USAGE_INPUT_TOKENS, usage.input_tokens) + span.set_data( + SPANDATA.GEN_AI_USAGE_INPUT_TOKENS_CACHED, + usage.input_tokens_details.cached_tokens, + ) + span.set_data(SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS, usage.output_tokens) + span.set_data( + SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS_REASONING, + usage.output_tokens_details.reasoning_tokens, + ) + span.set_data(SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS, usage.total_tokens) + + +def _set_input_data( + span: "sentry_sdk.tracing.Span", get_response_kwargs: "dict[str, Any]" +) -> None: + if not should_send_default_pii(): + return + request_messages = [] + + system_instructions = get_response_kwargs.get("system_instructions") + if system_instructions: + request_messages.append( + { + "role": GEN_AI_ALLOWED_MESSAGE_ROLES.SYSTEM, + "content": [{"type": "text", "text": system_instructions}], + } + ) + + for message in get_response_kwargs.get("input", []): + if "role" in message: + normalized_role = normalize_message_role(message.get("role")) + content = message.get("content") + request_messages.append( + { + "role": normalized_role, + "content": ( + [{"type": "text", "text": content}] + if isinstance(content, str) + else content + ), + } + ) + else: + if message.get("type") == "function_call": + request_messages.append( + { + "role": GEN_AI_ALLOWED_MESSAGE_ROLES.ASSISTANT, + "content": [message], + } + ) + elif message.get("type") == "function_call_output": + request_messages.append( + { + "role": GEN_AI_ALLOWED_MESSAGE_ROLES.TOOL, + "content": [message], + } + ) + + normalized_messages = normalize_message_roles(request_messages) + scope = sentry_sdk.get_current_scope() + messages_data = truncate_and_annotate_messages(normalized_messages, span, scope) + if messages_data is not None: + set_data_normalized( + span, + SPANDATA.GEN_AI_REQUEST_MESSAGES, + messages_data, + unpack=False, + ) + + +def _set_output_data(span: "sentry_sdk.tracing.Span", result: "Any") -> None: + if not should_send_default_pii(): + return + + output_messages: "dict[str, list[Any]]" = { + "response": [], + "tool": [], + } + + for output in result.output: + if output.type == "function_call": + output_messages["tool"].append(output.dict()) + elif output.type == "message": + for output_message in output.content: + try: + output_messages["response"].append(output_message.text) + except AttributeError: + # Unknown output message type, just return the json + output_messages["response"].append(output_message.dict()) + + if len(output_messages["tool"]) > 0: + span.set_data( + SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS, safe_serialize(output_messages["tool"]) + ) + + if len(output_messages["response"]) > 0: + set_data_normalized( + span, SPANDATA.GEN_AI_RESPONSE_TEXT, output_messages["response"] + ) + + +def _create_mcp_execute_tool_spans( + span: "sentry_sdk.tracing.Span", result: "agents.Result" +) -> None: + for output in result.output: + if output.__class__.__name__ == "McpCall": + with sentry_sdk.start_span( + op=OP.GEN_AI_EXECUTE_TOOL, + description=f"execute_tool {output.name}", + start_timestamp=span.start_timestamp, + ) as execute_tool_span: + set_data_normalized(execute_tool_span, SPANDATA.GEN_AI_TOOL_TYPE, "mcp") + set_data_normalized( + execute_tool_span, SPANDATA.GEN_AI_TOOL_NAME, output.name + ) + if should_send_default_pii(): + execute_tool_span.set_data( + SPANDATA.GEN_AI_TOOL_INPUT, output.arguments + ) + execute_tool_span.set_data( + SPANDATA.GEN_AI_TOOL_OUTPUT, output.output + ) + if output.error: + execute_tool_span.set_status(SPANSTATUS.INTERNAL_ERROR) diff --git a/sentry_sdk/integrations/openfeature.py b/sentry_sdk/integrations/openfeature.py new file mode 100644 index 0000000000..281604fe38 --- /dev/null +++ b/sentry_sdk/integrations/openfeature.py @@ -0,0 +1,34 @@ +from typing import TYPE_CHECKING, Any + +from sentry_sdk.feature_flags import add_feature_flag +from sentry_sdk.integrations import DidNotEnable, Integration + +try: + from openfeature import api + from openfeature.hook import Hook + + if TYPE_CHECKING: + from openfeature.hook import HookContext, HookHints +except ImportError: + raise DidNotEnable("OpenFeature is not installed") + + +class OpenFeatureIntegration(Integration): + identifier = "openfeature" + + @staticmethod + def setup_once() -> None: + # Register the hook within the global openfeature hooks list. + api.add_hooks(hooks=[OpenFeatureHook()]) + + +class OpenFeatureHook(Hook): + def after(self, hook_context: "Any", details: "Any", hints: "Any") -> None: + if isinstance(details.value, bool): + add_feature_flag(details.flag_key, details.value) + + def error( + self, hook_context: "HookContext", exception: Exception, hints: "HookHints" + ) -> None: + if isinstance(hook_context.default_value, bool): + add_feature_flag(hook_context.flag_key, hook_context.default_value) diff --git a/sentry_sdk/integrations/opentelemetry/__init__.py b/sentry_sdk/integrations/opentelemetry/__init__.py index e0020204d5..3c4c1a683d 100644 --- a/sentry_sdk/integrations/opentelemetry/__init__.py +++ b/sentry_sdk/integrations/opentelemetry/__init__.py @@ -1,7 +1,7 @@ -from sentry_sdk.integrations.opentelemetry.span_processor import ( # noqa: F401 - SentrySpanProcessor, -) +from sentry_sdk.integrations.opentelemetry.span_processor import SentrySpanProcessor +from sentry_sdk.integrations.opentelemetry.propagator import SentryPropagator -from sentry_sdk.integrations.opentelemetry.propagator import ( # noqa: F401 - SentryPropagator, -) +__all__ = [ + "SentryPropagator", + "SentrySpanProcessor", +] diff --git a/sentry_sdk/integrations/opentelemetry/consts.py b/sentry_sdk/integrations/opentelemetry/consts.py index 79663dd670..d6733036ea 100644 --- a/sentry_sdk/integrations/opentelemetry/consts.py +++ b/sentry_sdk/integrations/opentelemetry/consts.py @@ -1,6 +1,9 @@ -from opentelemetry.context import ( # type: ignore - create_key, -) +from sentry_sdk.integrations import DidNotEnable + +try: + from opentelemetry.context import create_key +except ImportError: + raise DidNotEnable("opentelemetry not installed") SENTRY_TRACE_KEY = create_key("sentry-trace") SENTRY_BAGGAGE_KEY = create_key("sentry-baggage") diff --git a/sentry_sdk/integrations/opentelemetry/integration.py b/sentry_sdk/integrations/opentelemetry/integration.py index 20dc4625df..83588a2b38 100644 --- a/sentry_sdk/integrations/opentelemetry/integration.py +++ b/sentry_sdk/integrations/opentelemetry/integration.py @@ -3,37 +3,27 @@ are experimental and not suitable for production use. They may be changed or removed at any time without prior notice. """ -import sys -from importlib import import_module from sentry_sdk.integrations import DidNotEnable, Integration -from sentry_sdk.integrations.opentelemetry.span_processor import SentrySpanProcessor from sentry_sdk.integrations.opentelemetry.propagator import SentryPropagator -from sentry_sdk.integrations.modules import _get_installed_modules +from sentry_sdk.integrations.opentelemetry.span_processor import SentrySpanProcessor from sentry_sdk.utils import logger -from sentry_sdk._types import TYPE_CHECKING try: - from opentelemetry import trace # type: ignore - from opentelemetry.instrumentation.auto_instrumentation._load import ( # type: ignore - _load_distro, - _load_instrumentors, - ) - from opentelemetry.propagate import set_global_textmap # type: ignore - from opentelemetry.sdk.trace import TracerProvider # type: ignore + from opentelemetry import trace + from opentelemetry.propagate import set_global_textmap + from opentelemetry.sdk.trace import TracerProvider except ImportError: raise DidNotEnable("opentelemetry not installed") -if TYPE_CHECKING: - from typing import Dict +try: + from opentelemetry.instrumentation.django import DjangoInstrumentor # type: ignore[import-not-found] +except ImportError: + DjangoInstrumentor = None -CLASSES_TO_INSTRUMENT = { - # A mapping of packages to their entry point class that will be instrumented. - # This is used to post-instrument any classes that were imported before OTel - # instrumentation took place. - "fastapi": "fastapi.FastAPI", - "flask": "flask.Flask", +CONFIGURABLE_INSTRUMENTATIONS = { + DjangoInstrumentor: {"is_sql_commentor_enabled": True}, } @@ -41,134 +31,25 @@ class OpenTelemetryIntegration(Integration): identifier = "opentelemetry" @staticmethod - def setup_once(): - # type: () -> None + def setup_once() -> None: logger.warning( "[OTel] Initializing highly experimental OpenTelemetry support. " "Use at your own risk." ) - original_classes = _record_unpatched_classes() - - try: - distro = _load_distro() - distro.configure() - _load_instrumentors(distro) - except Exception: - logger.exception("[OTel] Failed to auto-initialize OpenTelemetry") - - try: - _patch_remaining_classes(original_classes) - except Exception: - logger.exception( - "[OTel] Failed to post-patch instrumented classes. " - "You might have to make sure sentry_sdk.init() is called before importing anything else." - ) - _setup_sentry_tracing() + # _setup_instrumentors() logger.debug("[OTel] Finished setting up OpenTelemetry integration") -def _record_unpatched_classes(): - # type: () -> Dict[str, type] - """ - Keep references to classes that are about to be instrumented. - - Used to search for unpatched classes after the instrumentation has run so - that they can be patched manually. - """ - installed_packages = _get_installed_modules() - - original_classes = {} - - for package, orig_path in CLASSES_TO_INSTRUMENT.items(): - if package in installed_packages: - try: - original_cls = _import_by_path(orig_path) - except (AttributeError, ImportError): - logger.debug("[OTel] Failed to import %s", orig_path) - continue - - original_classes[package] = original_cls - - return original_classes - - -def _patch_remaining_classes(original_classes): - # type: (Dict[str, type]) -> None - """ - Best-effort attempt to patch any uninstrumented classes in sys.modules. - - This enables us to not care about the order of imports and sentry_sdk.init() - in user code. If e.g. the Flask class had been imported before sentry_sdk - was init()ed (and therefore before the OTel instrumentation ran), it would - not be instrumented. This function goes over remaining uninstrumented - occurrences of the class in sys.modules and replaces them with the - instrumented class. - - Since this is looking for exact matches, it will not work in some scenarios - (e.g. if someone is not using the specific class explicitly, but rather - inheriting from it). In those cases it's still necessary to sentry_sdk.init() - before importing anything that's supposed to be instrumented. - """ - # check which classes have actually been instrumented - instrumented_classes = {} - - for package in list(original_classes.keys()): - original_path = CLASSES_TO_INSTRUMENT[package] - - try: - cls = _import_by_path(original_path) - except (AttributeError, ImportError): - logger.debug( - "[OTel] Failed to check if class has been instrumented: %s", - original_path, - ) - del original_classes[package] - continue - - if not cls.__module__.startswith("opentelemetry."): - del original_classes[package] - continue - - instrumented_classes[package] = cls - - if not instrumented_classes: - return - - # replace occurrences of the original unpatched class in sys.modules - for module_name, module in sys.modules.copy().items(): - if ( - module_name.startswith("sentry_sdk") - or module_name in sys.builtin_module_names - ): - continue - - for package, original_cls in original_classes.items(): - for var_name, var in vars(module).copy().items(): - if var == original_cls: - logger.debug( - "[OTel] Additionally patching %s from %s", - original_cls, - module_name, - ) - - setattr(module, var_name, instrumented_classes[package]) - - -def _import_by_path(path): - # type: (str) -> type - parts = path.rsplit(".", maxsplit=1) - return getattr(import_module(parts[0]), parts[-1]) - - -def _setup_sentry_tracing(): - # type: () -> None +def _setup_sentry_tracing() -> None: provider = TracerProvider() - provider.add_span_processor(SentrySpanProcessor()) - trace.set_tracer_provider(provider) - set_global_textmap(SentryPropagator()) + + +def _setup_instrumentors() -> None: + for instrumentor, kwargs in CONFIGURABLE_INSTRUMENTATIONS.items(): + instrumentor().instrument(**kwargs) diff --git a/sentry_sdk/integrations/opentelemetry/propagator.py b/sentry_sdk/integrations/opentelemetry/propagator.py index e1bcc3b13e..a40f038ffa 100644 --- a/sentry_sdk/integrations/opentelemetry/propagator.py +++ b/sentry_sdk/integrations/opentelemetry/propagator.py @@ -1,22 +1,4 @@ -from opentelemetry import trace # type: ignore -from opentelemetry.context import ( # type: ignore - Context, - get_current, - set_value, -) -from opentelemetry.propagators.textmap import ( # type: ignore - CarrierT, - Getter, - Setter, - TextMapPropagator, - default_getter, - default_setter, -) -from opentelemetry.trace import ( # type: ignore - NonRecordingSpan, - SpanContext, - TraceFlags, -) +from sentry_sdk.integrations import DidNotEnable from sentry_sdk.integrations.opentelemetry.consts import ( SENTRY_BAGGAGE_KEY, SENTRY_TRACE_KEY, @@ -24,26 +6,52 @@ from sentry_sdk.integrations.opentelemetry.span_processor import ( SentrySpanProcessor, ) - from sentry_sdk.tracing import ( BAGGAGE_HEADER_NAME, SENTRY_TRACE_HEADER_NAME, ) from sentry_sdk.tracing_utils import Baggage, extract_sentrytrace_data -from sentry_sdk._types import TYPE_CHECKING + +try: + from opentelemetry import trace + from opentelemetry.context import ( + Context, + get_current, + set_value, + ) + from opentelemetry.propagators.textmap import ( + CarrierT, + Getter, + Setter, + TextMapPropagator, + default_getter, + default_setter, + ) + from opentelemetry.trace import ( + NonRecordingSpan, + SpanContext, + TraceFlags, + ) +except ImportError: + raise DidNotEnable("opentelemetry not installed") + +from typing import TYPE_CHECKING if TYPE_CHECKING: - from typing import Optional - from typing import Set + from typing import Optional, Set -class SentryPropagator(TextMapPropagator): # type: ignore +class SentryPropagator(TextMapPropagator): """ Propagates tracing headers for Sentry's tracing system in a way OTel understands. """ - def extract(self, carrier, context=None, getter=default_getter): - # type: (CarrierT, Optional[Context], Getter) -> Context + def extract( + self, + carrier: "CarrierT", + context: "Optional[Context]" = None, + getter: "Getter[CarrierT]" = default_getter, + ) -> "Context": if context is None: context = get_current() @@ -84,8 +92,12 @@ def extract(self, carrier, context=None, getter=default_getter): modified_context = trace.set_span_in_context(span, context) return modified_context - def inject(self, carrier, context=None, setter=default_setter): - # type: (CarrierT, Optional[Context], Setter) -> None + def inject( + self, + carrier: "CarrierT", + context: "Optional[Context]" = None, + setter: "Setter[CarrierT]" = default_setter, + ) -> None: if context is None: context = get_current() @@ -107,9 +119,10 @@ def inject(self, carrier, context=None, setter=default_setter): if sentry_span.containing_transaction: baggage = sentry_span.containing_transaction.get_baggage() if baggage: - setter.set(carrier, BAGGAGE_HEADER_NAME, baggage.serialize()) + baggage_data = baggage.serialize() + if baggage_data: + setter.set(carrier, BAGGAGE_HEADER_NAME, baggage_data) @property - def fields(self): - # type: () -> Set[str] + def fields(self) -> "Set[str]": return {SENTRY_TRACE_HEADER_NAME, BAGGAGE_HEADER_NAME} diff --git a/sentry_sdk/integrations/opentelemetry/span_processor.py b/sentry_sdk/integrations/opentelemetry/span_processor.py index 9dd15bfb3e..407baef61c 100644 --- a/sentry_sdk/integrations/opentelemetry/span_processor.py +++ b/sentry_sdk/integrations/opentelemetry/span_processor.py @@ -1,48 +1,52 @@ -from datetime import datetime - -from opentelemetry.context import get_value # type: ignore -from opentelemetry.sdk.trace import SpanProcessor # type: ignore -from opentelemetry.semconv.trace import SpanAttributes # type: ignore -from opentelemetry.trace import ( # type: ignore - format_span_id, - format_trace_id, - get_current_span, - SpanContext, - Span as OTelSpan, - SpanKind, -) -from opentelemetry.trace.span import ( # type: ignore - INVALID_SPAN_ID, - INVALID_TRACE_ID, -) -from sentry_sdk.consts import INSTRUMENTER -from sentry_sdk.hub import Hub +from datetime import datetime, timezone +from time import time +from typing import TYPE_CHECKING, cast + +from sentry_sdk import get_client, start_transaction +from sentry_sdk.consts import INSTRUMENTER, SPANSTATUS +from sentry_sdk.integrations import DidNotEnable from sentry_sdk.integrations.opentelemetry.consts import ( SENTRY_BAGGAGE_KEY, SENTRY_TRACE_KEY, ) from sentry_sdk.scope import add_global_event_processor from sentry_sdk.tracing import Transaction, Span as SentrySpan -from sentry_sdk.utils import Dsn -from sentry_sdk._types import TYPE_CHECKING from urllib3.util import parse_url as urlparse -if TYPE_CHECKING: - from typing import Any, Dict, Optional, Union +try: + from opentelemetry.context import get_value + from opentelemetry.sdk.trace import SpanProcessor, ReadableSpan as OTelSpan + from opentelemetry.semconv.trace import SpanAttributes + from opentelemetry.trace import ( + format_span_id, + format_trace_id, + get_current_span, + SpanKind, + ) + from opentelemetry.trace.span import ( + INVALID_SPAN_ID, + INVALID_TRACE_ID, + ) +except ImportError: + raise DidNotEnable("opentelemetry not installed") +if TYPE_CHECKING: + from typing import Any, Optional, Union + from opentelemetry import context as context_api from sentry_sdk._types import Event, Hint OPEN_TELEMETRY_CONTEXT = "otel" +SPAN_MAX_TIME_OPEN_MINUTES = 10 +SPAN_ORIGIN = "auto.otel" -def link_trace_context_to_error_event(event, otel_span_map): - # type: (Event, Dict[str, Union[Transaction, SentrySpan]]) -> Event - hub = Hub.current - if not hub: - return event +def link_trace_context_to_error_event( + event: "Event", otel_span_map: "dict[str, Union[Transaction, SentrySpan]]" +) -> "Event": + client = get_client() - if hub.client and hub.client.options["instrumenter"] != INSTRUMENTER.OTEL: + if client.options["instrumenter"] != INSTRUMENTER.OTEL: return event if hasattr(event, "type") and event["type"] == "transaction": @@ -53,13 +57,11 @@ def link_trace_context_to_error_event(event, otel_span_map): return event ctx = otel_span.get_span_context() - trace_id = format_trace_id(ctx.trace_id) - span_id = format_span_id(ctx.span_id) - if trace_id == INVALID_TRACE_ID or span_id == INVALID_SPAN_ID: + if ctx.trace_id == INVALID_TRACE_ID or ctx.span_id == INVALID_SPAN_ID: return event - sentry_span = otel_span_map.get(span_id, None) + sentry_span = otel_span_map.get(format_span_id(ctx.span_id), None) if not sentry_span: return event @@ -69,86 +71,114 @@ def link_trace_context_to_error_event(event, otel_span_map): return event -class SentrySpanProcessor(SpanProcessor): # type: ignore +class SentrySpanProcessor(SpanProcessor): """ Converts OTel spans into Sentry spans so they can be sent to the Sentry backend. """ # The mapping from otel span ids to sentry spans - otel_span_map = {} # type: Dict[str, Union[Transaction, SentrySpan]] + otel_span_map: "dict[str, Union[Transaction, SentrySpan]]" = {} - def __new__(cls): - # type: () -> SentrySpanProcessor + # The currently open spans. Elements will be discarded after SPAN_MAX_TIME_OPEN_MINUTES + open_spans: "dict[int, set[str]]" = {} + + def __new__(cls) -> "SentrySpanProcessor": if not hasattr(cls, "instance"): - cls.instance = super(SentrySpanProcessor, cls).__new__(cls) + cls.instance = super().__new__(cls) return cls.instance - def __init__(self): - # type: () -> None + def __init__(self) -> None: @add_global_event_processor - def global_event_processor(event, hint): - # type: (Event, Hint) -> Event + def global_event_processor(event: "Event", hint: "Hint") -> "Event": return link_trace_context_to_error_event(event, self.otel_span_map) - def on_start(self, otel_span, parent_context=None): - # type: (OTelSpan, Optional[SpanContext]) -> None - hub = Hub.current - if not hub: - return - - if not hub.client or (hub.client and not hub.client.dsn): - return - - try: - _ = Dsn(hub.client.dsn or "") - except Exception: + def _prune_old_spans(self: "SentrySpanProcessor") -> None: + """ + Prune spans that have been open for too long. + """ + current_time_minutes = int(time() / 60) + for span_start_minutes in list( + self.open_spans.keys() + ): # making a list because we change the dict + # prune empty open spans buckets + if self.open_spans[span_start_minutes] == set(): + self.open_spans.pop(span_start_minutes) + + # prune old buckets + elif current_time_minutes - span_start_minutes > SPAN_MAX_TIME_OPEN_MINUTES: + for span_id in self.open_spans.pop(span_start_minutes): + self.otel_span_map.pop(span_id, None) + + def on_start( + self, + otel_span: "OTelSpan", + parent_context: "Optional[context_api.Context]" = None, + ) -> None: + client = get_client() + + if not client.parsed_dsn: return - if hub.client and hub.client.options["instrumenter"] != INSTRUMENTER.OTEL: + if client.options["instrumenter"] != INSTRUMENTER.OTEL: return if not otel_span.get_span_context().is_valid: return - if self._is_sentry_span(hub, otel_span): + if self._is_sentry_span(otel_span): return trace_data = self._get_trace_data(otel_span, parent_context) parent_span_id = trace_data["parent_span_id"] sentry_parent_span = ( - self.otel_span_map.get(parent_span_id, None) if parent_span_id else None + self.otel_span_map.get(parent_span_id) if parent_span_id else None ) + start_timestamp = None + if otel_span.start_time is not None: + start_timestamp = datetime.fromtimestamp( + otel_span.start_time / 1e9, timezone.utc + ) # OTel spans have nanosecond precision + sentry_span = None if sentry_parent_span: sentry_span = sentry_parent_span.start_child( span_id=trace_data["span_id"], - description=otel_span.name, - start_timestamp=datetime.fromtimestamp(otel_span.start_time / 1e9), + name=otel_span.name, + start_timestamp=start_timestamp, instrumenter=INSTRUMENTER.OTEL, + origin=SPAN_ORIGIN, ) else: - sentry_span = hub.start_transaction( + sentry_span = start_transaction( name=otel_span.name, span_id=trace_data["span_id"], parent_span_id=parent_span_id, trace_id=trace_data["trace_id"], baggage=trace_data["baggage"], - start_timestamp=datetime.fromtimestamp(otel_span.start_time / 1e9), + start_timestamp=start_timestamp, instrumenter=INSTRUMENTER.OTEL, + origin=SPAN_ORIGIN, ) self.otel_span_map[trace_data["span_id"]] = sentry_span - def on_end(self, otel_span): - # type: (OTelSpan) -> None - hub = Hub.current - if not hub: - return + if otel_span.start_time is not None: + span_start_in_minutes = int( + otel_span.start_time / 1e9 / 60 + ) # OTel spans have nanosecond precision + self.open_spans.setdefault(span_start_in_minutes, set()).add( + trace_data["span_id"] + ) - if hub.client and hub.client.options["instrumenter"] != INSTRUMENTER.OTEL: + self._prune_old_spans() + + def on_end(self, otel_span: "OTelSpan") -> None: + client = get_client() + + if client.options["instrumenter"] != INSTRUMENTER.OTEL: return span_context = otel_span.get_span_context() @@ -174,26 +204,41 @@ def on_end(self, otel_span): else: self._update_span_with_otel_data(sentry_span, otel_span) - sentry_span.finish( - end_timestamp=datetime.fromtimestamp(otel_span.end_time / 1e9) - ) + end_timestamp = None + if otel_span.end_time is not None: + end_timestamp = datetime.fromtimestamp( + otel_span.end_time / 1e9, timezone.utc + ) # OTel spans have nanosecond precision + + sentry_span.finish(end_timestamp=end_timestamp) + + if otel_span.start_time is not None: + span_start_in_minutes = int( + otel_span.start_time / 1e9 / 60 + ) # OTel spans have nanosecond precision + self.open_spans.setdefault(span_start_in_minutes, set()).discard(span_id) - def _is_sentry_span(self, hub, otel_span): - # type: (Hub, OTelSpan) -> bool + self._prune_old_spans() + + def _is_sentry_span(self, otel_span: "OTelSpan") -> bool: """ Break infinite loop: HTTP requests to Sentry are caught by OTel and send again to Sentry. """ - otel_span_url = otel_span.attributes.get(SpanAttributes.HTTP_URL, None) - dsn_url = hub.client and Dsn(hub.client.dsn or "").netloc + otel_span_url = None + if otel_span.attributes is not None: + otel_span_url = otel_span.attributes.get(SpanAttributes.HTTP_URL) + otel_span_url = cast("Optional[str]", otel_span_url) + + parsed_dsn = get_client().parsed_dsn + dsn_url = parsed_dsn.netloc if parsed_dsn else None - if otel_span_url and dsn_url in otel_span_url: + if otel_span_url and dsn_url and dsn_url in otel_span_url: return True return False - def _get_otel_context(self, otel_span): - # type: (OTelSpan) -> Dict[str, Any] + def _get_otel_context(self, otel_span: "OTelSpan") -> "dict[str, Any]": """ Returns the OTel context for Sentry. See: https://develop.sentry.dev/sdk/performance/opentelemetry/#step-5-add-opentelemetry-context @@ -208,12 +253,13 @@ def _get_otel_context(self, otel_span): return ctx - def _get_trace_data(self, otel_span, parent_context): - # type: (OTelSpan, SpanContext) -> Dict[str, Any] + def _get_trace_data( + self, otel_span: "OTelSpan", parent_context: "Optional[context_api.Context]" + ) -> "dict[str, Any]": """ Extracts tracing information from one OTel span and its parent OTel context. """ - trace_data = {} + trace_data: "dict[str, Any]" = {} span_context = otel_span.get_span_context() span_id = format_span_id(span_context.span_id) @@ -228,6 +274,7 @@ def _get_trace_data(self, otel_span, parent_context): trace_data["parent_span_id"] = parent_span_id sentry_trace_data = get_value(SENTRY_TRACE_KEY, parent_context) + sentry_trace_data = cast("dict[str, Union[str, bool, None]]", sentry_trace_data) trace_data["parent_sampled"] = ( sentry_trace_data["parent_sampled"] if sentry_trace_data else None ) @@ -237,8 +284,9 @@ def _get_trace_data(self, otel_span, parent_context): return trace_data - def _update_span_with_otel_status(self, sentry_span, otel_span): - # type: (SentrySpan, OTelSpan) -> None + def _update_span_with_otel_status( + self, sentry_span: "SentrySpan", otel_span: "OTelSpan" + ) -> None: """ Set the Sentry span status from the OTel span """ @@ -246,74 +294,88 @@ def _update_span_with_otel_status(self, sentry_span, otel_span): return if otel_span.status.is_ok: - sentry_span.set_status("ok") + sentry_span.set_status(SPANSTATUS.OK) return - sentry_span.set_status("internal_error") + sentry_span.set_status(SPANSTATUS.INTERNAL_ERROR) - def _update_span_with_otel_data(self, sentry_span, otel_span): - # type: (SentrySpan, OTelSpan) -> None + def _update_span_with_otel_data( + self, sentry_span: "SentrySpan", otel_span: "OTelSpan" + ) -> None: """ Convert OTel span data and update the Sentry span with it. This should eventually happen on the server when ingesting the spans. """ - for key, val in otel_span.attributes.items(): - sentry_span.set_data(key, val) - sentry_span.set_data("otel.kind", otel_span.kind) op = otel_span.name description = otel_span.name - http_method = otel_span.attributes.get(SpanAttributes.HTTP_METHOD, None) - db_query = otel_span.attributes.get(SpanAttributes.DB_SYSTEM, None) - - if http_method: - op = "http" - - if otel_span.kind == SpanKind.SERVER: - op += ".server" - elif otel_span.kind == SpanKind.CLIENT: - op += ".client" - - description = http_method - - peer_name = otel_span.attributes.get(SpanAttributes.NET_PEER_NAME, None) - if peer_name: - description += " {}".format(peer_name) - - target = otel_span.attributes.get(SpanAttributes.HTTP_TARGET, None) - if target: - description += " {}".format(target) - - if not peer_name and not target: - url = otel_span.attributes.get(SpanAttributes.HTTP_URL, None) - if url: - parsed_url = urlparse(url) - url = f"{parsed_url.scheme}://{parsed_url.netloc}{parsed_url.path}" - description += " {}".format(url) - - status_code = otel_span.attributes.get( - SpanAttributes.HTTP_STATUS_CODE, None - ) - if status_code: - sentry_span.set_http_status(status_code) - - elif db_query: - op = "db" - statement = otel_span.attributes.get(SpanAttributes.DB_STATEMENT, None) - if statement: - description = statement + if otel_span.attributes is not None: + for key, val in otel_span.attributes.items(): + sentry_span.set_data(key, val) + + http_method = otel_span.attributes.get(SpanAttributes.HTTP_METHOD) + http_method = cast("Optional[str]", http_method) + + db_query = otel_span.attributes.get(SpanAttributes.DB_SYSTEM) + + if http_method: + op = "http" + + if otel_span.kind == SpanKind.SERVER: + op += ".server" + elif otel_span.kind == SpanKind.CLIENT: + op += ".client" + + description = http_method + + peer_name = otel_span.attributes.get(SpanAttributes.NET_PEER_NAME, None) + if peer_name: + description += " {}".format(peer_name) + + target = otel_span.attributes.get(SpanAttributes.HTTP_TARGET, None) + if target: + description += " {}".format(target) + + if not peer_name and not target: + url = otel_span.attributes.get(SpanAttributes.HTTP_URL, None) + url = cast("Optional[str]", url) + if url: + parsed_url = urlparse(url) + url = "{}://{}{}".format( + parsed_url.scheme, parsed_url.netloc, parsed_url.path + ) + description += " {}".format(url) + + status_code = otel_span.attributes.get( + SpanAttributes.HTTP_STATUS_CODE, None + ) + status_code = cast("Optional[int]", status_code) + if status_code: + sentry_span.set_http_status(status_code) + + elif db_query: + op = "db" + statement = otel_span.attributes.get(SpanAttributes.DB_STATEMENT, None) + statement = cast("Optional[str]", statement) + if statement: + description = statement sentry_span.op = op sentry_span.description = description - def _update_transaction_with_otel_data(self, sentry_span, otel_span): - # type: (SentrySpan, OTelSpan) -> None + def _update_transaction_with_otel_data( + self, sentry_span: "SentrySpan", otel_span: "OTelSpan" + ) -> None: + if otel_span.attributes is None: + return + http_method = otel_span.attributes.get(SpanAttributes.HTTP_METHOD) if http_method: status_code = otel_span.attributes.get(SpanAttributes.HTTP_STATUS_CODE) + status_code = cast("Optional[int]", status_code) if status_code: sentry_span.set_http_status(status_code) diff --git a/sentry_sdk/integrations/otlp.py b/sentry_sdk/integrations/otlp.py new file mode 100644 index 0000000000..19c6099970 --- /dev/null +++ b/sentry_sdk/integrations/otlp.py @@ -0,0 +1,217 @@ +from sentry_sdk import get_client, capture_event +from sentry_sdk.integrations import Integration, DidNotEnable +from sentry_sdk.scope import register_external_propagation_context +from sentry_sdk.utils import ( + Dsn, + logger, + event_from_exception, + capture_internal_exceptions, +) +from sentry_sdk.consts import VERSION, EndpointType +from sentry_sdk.tracing_utils import Baggage +from sentry_sdk.tracing import ( + BAGGAGE_HEADER_NAME, + SENTRY_TRACE_HEADER_NAME, +) + +try: + from opentelemetry.propagate import set_global_textmap + from opentelemetry.sdk.trace import TracerProvider, Span + from opentelemetry.sdk.trace.export import BatchSpanProcessor + from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter + + from opentelemetry.trace import ( + get_current_span, + get_tracer_provider, + set_tracer_provider, + format_trace_id, + format_span_id, + SpanContext, + INVALID_SPAN_ID, + INVALID_TRACE_ID, + ) + + from opentelemetry.context import ( + Context, + get_current, + get_value, + ) + + from opentelemetry.propagators.textmap import ( + CarrierT, + Setter, + default_setter, + ) + + from sentry_sdk.integrations.opentelemetry.propagator import SentryPropagator + from sentry_sdk.integrations.opentelemetry.consts import SENTRY_BAGGAGE_KEY +except ImportError: + raise DidNotEnable("opentelemetry-distro[otlp] is not installed") + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Optional, Dict, Any, Tuple + + +def otel_propagation_context() -> "Optional[Tuple[str, str]]": + """ + Get the (trace_id, span_id) from opentelemetry if exists. + """ + ctx = get_current_span().get_span_context() + + if ctx.trace_id == INVALID_TRACE_ID or ctx.span_id == INVALID_SPAN_ID: + return None + + return (format_trace_id(ctx.trace_id), format_span_id(ctx.span_id)) + + +def setup_otlp_traces_exporter(dsn: "Optional[str]" = None) -> None: + tracer_provider = get_tracer_provider() + + if not isinstance(tracer_provider, TracerProvider): + logger.debug("[OTLP] No TracerProvider configured by user, creating a new one") + tracer_provider = TracerProvider() + set_tracer_provider(tracer_provider) + + endpoint = None + headers = None + if dsn: + auth = Dsn(dsn).to_auth(f"sentry.python/{VERSION}") + endpoint = auth.get_api_url(EndpointType.OTLP_TRACES) + headers = {"X-Sentry-Auth": auth.to_header()} + logger.debug(f"[OTLP] Sending traces to {endpoint}") + + otlp_exporter = OTLPSpanExporter(endpoint=endpoint, headers=headers) + span_processor = BatchSpanProcessor(otlp_exporter) + tracer_provider.add_span_processor(span_processor) + + +_sentry_patched_exception = False + + +def setup_capture_exceptions() -> None: + """ + Intercept otel's Span.record_exception to automatically capture those exceptions in Sentry. + """ + global _sentry_patched_exception + _original_record_exception = Span.record_exception + + if _sentry_patched_exception: + return + + def _sentry_patched_record_exception( + self: "Span", exception: "BaseException", *args: "Any", **kwargs: "Any" + ) -> None: + otlp_integration = get_client().get_integration(OTLPIntegration) + if otlp_integration and otlp_integration.capture_exceptions: + with capture_internal_exceptions(): + event, hint = event_from_exception( + exception, + client_options=get_client().options, + mechanism={"type": OTLPIntegration.identifier, "handled": False}, + ) + capture_event(event, hint=hint) + + _original_record_exception(self, exception, *args, **kwargs) + + Span.record_exception = _sentry_patched_record_exception # type: ignore[method-assign] + _sentry_patched_exception = True + + +class SentryOTLPPropagator(SentryPropagator): + """ + We need to override the inject of the older propagator since that + is SpanProcessor based. + + !!! Note regarding baggage: + We cannot meaningfully populate a new baggage as a head SDK + when we are using OTLP since we don't have any sort of transaction semantic to + track state across a group of spans. + + For incoming baggage, we just pass it on as is so that case is correctly handled. + """ + + def inject( + self, + carrier: "CarrierT", + context: "Optional[Context]" = None, + setter: "Setter[CarrierT]" = default_setter, + ) -> None: + otlp_integration = get_client().get_integration(OTLPIntegration) + if otlp_integration is None: + return + + if context is None: + context = get_current() + + current_span = get_current_span(context) + current_span_context = current_span.get_span_context() + + if not current_span_context.is_valid: + return + + sentry_trace = _to_traceparent(current_span_context) + setter.set(carrier, SENTRY_TRACE_HEADER_NAME, sentry_trace) + + baggage = get_value(SENTRY_BAGGAGE_KEY, context) + if baggage is not None and isinstance(baggage, Baggage): + baggage_data = baggage.serialize() + if baggage_data: + setter.set(carrier, BAGGAGE_HEADER_NAME, baggage_data) + + +def _to_traceparent(span_context: "SpanContext") -> str: + """ + Helper method to generate the sentry-trace header. + """ + span_id = format_span_id(span_context.span_id) + trace_id = format_trace_id(span_context.trace_id) + sampled = span_context.trace_flags.sampled + + return f"{trace_id}-{span_id}-{'1' if sampled else '0'}" + + +class OTLPIntegration(Integration): + """ + Automatically setup OTLP ingestion from the DSN. + + :param setup_otlp_traces_exporter: Automatically configure an Exporter to send OTLP traces from the DSN, defaults to True. + Set to False if using a custom collector or to setup the TracerProvider manually. + :param setup_propagator: Automatically configure the Sentry Propagator for Distributed Tracing, defaults to True. + Set to False to configure propagators manually or to disable propagation. + :param capture_exceptions: Intercept and capture exceptions on the OpenTelemetry Span in Sentry as well, defaults to False. + Set to True to turn on capturing but be aware that since Sentry captures most exceptions, duplicate exceptions might be dropped by DedupeIntegration in many cases. + """ + + identifier = "otlp" + + def __init__( + self, + setup_otlp_traces_exporter: bool = True, + setup_propagator: bool = True, + capture_exceptions: bool = False, + ) -> None: + self.setup_otlp_traces_exporter = setup_otlp_traces_exporter + self.setup_propagator = setup_propagator + self.capture_exceptions = capture_exceptions + + @staticmethod + def setup_once() -> None: + logger.debug("[OTLP] Setting up trace linking for all events") + register_external_propagation_context(otel_propagation_context) + + def setup_once_with_options( + self, options: "Optional[Dict[str, Any]]" = None + ) -> None: + if self.setup_otlp_traces_exporter: + logger.debug("[OTLP] Setting up OTLP exporter") + dsn: "Optional[str]" = options.get("dsn") if options else None + setup_otlp_traces_exporter(dsn) + + if self.setup_propagator: + logger.debug("[OTLP] Setting up propagator for distributed tracing") + # TODO-neel better propagator support, chain with existing ones if possible instead of replacing + set_global_textmap(SentryOTLPPropagator()) + + setup_capture_exceptions() diff --git a/sentry_sdk/integrations/pure_eval.py b/sentry_sdk/integrations/pure_eval.py index 5a2419c267..1f3a1f4ea1 100644 --- a/sentry_sdk/integrations/pure_eval.py +++ b/sentry_sdk/integrations/pure_eval.py @@ -1,13 +1,13 @@ -from __future__ import absolute_import - import ast -from sentry_sdk import Hub, serializer -from sentry_sdk._types import TYPE_CHECKING +import sentry_sdk +from sentry_sdk import serializer from sentry_sdk.integrations import Integration, DidNotEnable from sentry_sdk.scope import add_global_event_processor from sentry_sdk.utils import walk_exception_chain, iter_stacks +from typing import TYPE_CHECKING + if TYPE_CHECKING: from typing import Optional, Dict, Any, Tuple, List from types import FrameType @@ -35,13 +35,12 @@ class PureEvalIntegration(Integration): identifier = "pure_eval" @staticmethod - def setup_once(): - # type: () -> None - + def setup_once() -> None: @add_global_event_processor - def add_executing_info(event, hint): - # type: (Event, Optional[Hint]) -> Optional[Event] - if Hub.current.get_integration(PureEvalIntegration) is None: + def add_executing_info( + event: "Event", hint: "Optional[Hint]" + ) -> "Optional[Event]": + if sentry_sdk.get_client().get_integration(PureEvalIntegration) is None: return event if hint is None: @@ -81,8 +80,7 @@ def add_executing_info(event, hint): return event -def pure_eval_frame(frame): - # type: (FrameType) -> Dict[str, Any] +def pure_eval_frame(frame: "FrameType") -> "Dict[str, Any]": source = executing.Source.for_frame(frame) if not source.tree: return {} @@ -103,20 +101,20 @@ def pure_eval_frame(frame): evaluator = pure_eval.Evaluator.from_frame(frame) expressions = evaluator.interesting_expressions_grouped(scope) - def closeness(expression): - # type: (Tuple[List[Any], Any]) -> Tuple[int, int] + def closeness(expression: "Tuple[List[Any], Any]") -> "Tuple[int, int]": # Prioritise expressions with a node closer to the statement executed # without being after that statement # A higher return value is better - the expression will appear # earlier in the list of values and is less likely to be trimmed nodes, _value = expression - def start(n): - # type: (ast.expr) -> Tuple[int, int] + def start(n: "ast.expr") -> "Tuple[int, int]": return (n.lineno, n.col_offset) nodes_before_stmt = [ - node for node in nodes if start(node) < stmt.last_token.end # type: ignore + node + for node in nodes + if start(node) < stmt.last_token.end # type: ignore ] if nodes_before_stmt: # The position of the last node before or in the statement @@ -132,7 +130,8 @@ def start(n): atok = source.asttokens() expressions.sort(key=closeness, reverse=True) - return { + vars = { atok.get_text(nodes[0]): value for nodes, value in expressions[: serializer.MAX_DATABAG_BREADTH] } + return serializer.serialize(vars, is_vars=True) diff --git a/sentry_sdk/integrations/pydantic_ai/__init__.py b/sentry_sdk/integrations/pydantic_ai/__init__.py new file mode 100644 index 0000000000..2f1808d14f --- /dev/null +++ b/sentry_sdk/integrations/pydantic_ai/__init__.py @@ -0,0 +1,50 @@ +from sentry_sdk.integrations import DidNotEnable, Integration + + +try: + import pydantic_ai # type: ignore +except ImportError: + raise DidNotEnable("pydantic-ai not installed") + + +from .patches import ( + _patch_agent_run, + _patch_graph_nodes, + _patch_model_request, + _patch_tool_execution, +) + + +class PydanticAIIntegration(Integration): + identifier = "pydantic_ai" + origin = f"auto.ai.{identifier}" + + def __init__( + self, include_prompts: bool = True, handled_tool_call_exceptions: bool = True + ) -> None: + """ + Initialize the Pydantic AI integration. + + Args: + include_prompts: Whether to include prompts and messages in span data. + Requires send_default_pii=True. Defaults to True. + handled_tool_exceptions: Capture tool call exceptions that Pydantic AI + internally prevents from bubbling up. + """ + self.include_prompts = include_prompts + self.handled_tool_call_exceptions = handled_tool_call_exceptions + + @staticmethod + def setup_once() -> None: + """ + Set up the pydantic-ai integration. + + This patches the key methods in pydantic-ai to create Sentry spans for: + - Agent invocations (Agent.run methods) + - Model requests (AI client calls) + - Tool executions + """ + _patch_agent_run() + _patch_graph_nodes() + _patch_model_request() + _patch_tool_execution() diff --git a/sentry_sdk/integrations/pydantic_ai/consts.py b/sentry_sdk/integrations/pydantic_ai/consts.py new file mode 100644 index 0000000000..afa66dc47d --- /dev/null +++ b/sentry_sdk/integrations/pydantic_ai/consts.py @@ -0,0 +1 @@ +SPAN_ORIGIN = "auto.ai.pydantic_ai" diff --git a/sentry_sdk/integrations/pydantic_ai/patches/__init__.py b/sentry_sdk/integrations/pydantic_ai/patches/__init__.py new file mode 100644 index 0000000000..de28780728 --- /dev/null +++ b/sentry_sdk/integrations/pydantic_ai/patches/__init__.py @@ -0,0 +1,4 @@ +from .agent_run import _patch_agent_run # noqa: F401 +from .graph_nodes import _patch_graph_nodes # noqa: F401 +from .model_request import _patch_model_request # noqa: F401 +from .tools import _patch_tool_execution # noqa: F401 diff --git a/sentry_sdk/integrations/pydantic_ai/patches/agent_run.py b/sentry_sdk/integrations/pydantic_ai/patches/agent_run.py new file mode 100644 index 0000000000..d158d892d5 --- /dev/null +++ b/sentry_sdk/integrations/pydantic_ai/patches/agent_run.py @@ -0,0 +1,206 @@ +from functools import wraps + +import sentry_sdk +from sentry_sdk.integrations import DidNotEnable + +from ..spans import invoke_agent_span, update_invoke_agent_span +from ..utils import _capture_exception, pop_agent, push_agent + +from typing import TYPE_CHECKING + +try: + from pydantic_ai.agent import Agent # type: ignore +except ImportError: + raise DidNotEnable("pydantic-ai not installed") + +if TYPE_CHECKING: + from typing import Any, Callable, Optional + + +class _StreamingContextManagerWrapper: + """Wrapper for streaming methods that return async context managers.""" + + def __init__( + self, + agent: "Any", + original_ctx_manager: "Any", + user_prompt: "Any", + model: "Any", + model_settings: "Any", + is_streaming: bool = True, + ) -> None: + self.agent = agent + self.original_ctx_manager = original_ctx_manager + self.user_prompt = user_prompt + self.model = model + self.model_settings = model_settings + self.is_streaming = is_streaming + self._isolation_scope: "Any" = None + self._span: "Optional[sentry_sdk.tracing.Span]" = None + self._result: "Any" = None + + async def __aenter__(self) -> "Any": + # Set up isolation scope and invoke_agent span + self._isolation_scope = sentry_sdk.isolation_scope() + self._isolation_scope.__enter__() + + # Create invoke_agent span (will be closed in __aexit__) + self._span = invoke_agent_span( + self.user_prompt, + self.agent, + self.model, + self.model_settings, + self.is_streaming, + ) + self._span.__enter__() + + # Push agent to contextvar stack after span is successfully created and entered + # This ensures proper pairing with pop_agent() in __aexit__ even if exceptions occur + push_agent(self.agent, self.is_streaming) + + # Enter the original context manager + result = await self.original_ctx_manager.__aenter__() + self._result = result + return result + + async def __aexit__(self, exc_type: "Any", exc_val: "Any", exc_tb: "Any") -> None: + try: + # Exit the original context manager first + await self.original_ctx_manager.__aexit__(exc_type, exc_val, exc_tb) + + # Update span with result if successful + if exc_type is None and self._result and self._span is not None: + update_invoke_agent_span(self._span, self._result) + finally: + # Pop agent from contextvar stack + pop_agent() + + # Clean up invoke span + if self._span: + self._span.__exit__(exc_type, exc_val, exc_tb) + + # Clean up isolation scope + if self._isolation_scope: + self._isolation_scope.__exit__(exc_type, exc_val, exc_tb) + + +def _create_run_wrapper( + original_func: "Callable[..., Any]", is_streaming: bool = False +) -> "Callable[..., Any]": + """ + Wraps the Agent.run method to create an invoke_agent span. + + Args: + original_func: The original run method + is_streaming: Whether this is a streaming method (for future use) + """ + + @wraps(original_func) + async def wrapper(self: "Any", *args: "Any", **kwargs: "Any") -> "Any": + # Isolate each workflow so that when agents are run in asyncio tasks they + # don't touch each other's scopes + with sentry_sdk.isolation_scope(): + # Extract parameters for the span + user_prompt = kwargs.get("user_prompt") or (args[0] if args else None) + model = kwargs.get("model") + model_settings = kwargs.get("model_settings") + + # Create invoke_agent span + with invoke_agent_span( + user_prompt, self, model, model_settings, is_streaming + ) as span: + # Push agent to contextvar stack after span is successfully created and entered + # This ensures proper pairing with pop_agent() in finally even if exceptions occur + push_agent(self, is_streaming) + + try: + result = await original_func(self, *args, **kwargs) + + # Update span with result + update_invoke_agent_span(span, result) + + return result + except Exception as exc: + _capture_exception(exc) + raise exc from None + finally: + # Pop agent from contextvar stack + pop_agent() + + return wrapper + + +def _create_streaming_wrapper( + original_func: "Callable[..., Any]", +) -> "Callable[..., Any]": + """ + Wraps run_stream method that returns an async context manager. + """ + + @wraps(original_func) + def wrapper(self: "Any", *args: "Any", **kwargs: "Any") -> "Any": + # Extract parameters for the span + user_prompt = kwargs.get("user_prompt") or (args[0] if args else None) + model = kwargs.get("model") + model_settings = kwargs.get("model_settings") + + # Call original function to get the context manager + original_ctx_manager = original_func(self, *args, **kwargs) + + # Wrap it with our instrumentation + return _StreamingContextManagerWrapper( + agent=self, + original_ctx_manager=original_ctx_manager, + user_prompt=user_prompt, + model=model, + model_settings=model_settings, + is_streaming=True, + ) + + return wrapper + + +def _create_streaming_events_wrapper( + original_func: "Callable[..., Any]", +) -> "Callable[..., Any]": + """ + Wraps run_stream_events method - no span needed as it delegates to run(). + + Note: run_stream_events internally calls self.run() with an event_stream_handler, + so the invoke_agent span will be created by the run() wrapper. + """ + + @wraps(original_func) + async def wrapper(self: "Any", *args: "Any", **kwargs: "Any") -> "Any": + # Just call the original generator - it will call run() which has the instrumentation + try: + async for event in original_func(self, *args, **kwargs): + yield event + except Exception as exc: + _capture_exception(exc) + raise exc from None + + return wrapper + + +def _patch_agent_run() -> None: + """ + Patches the Agent run methods to create spans for agent execution. + + This patches both non-streaming (run, run_sync) and streaming + (run_stream, run_stream_events) methods. + """ + + # Store original methods + original_run = Agent.run + original_run_stream = Agent.run_stream + original_run_stream_events = Agent.run_stream_events + + # Wrap and apply patches for non-streaming methods + Agent.run = _create_run_wrapper(original_run, is_streaming=False) + + # Wrap and apply patches for streaming methods + Agent.run_stream = _create_streaming_wrapper(original_run_stream) + Agent.run_stream_events = _create_streaming_events_wrapper( + original_run_stream_events + ) diff --git a/sentry_sdk/integrations/pydantic_ai/patches/graph_nodes.py b/sentry_sdk/integrations/pydantic_ai/patches/graph_nodes.py new file mode 100644 index 0000000000..56e46d869f --- /dev/null +++ b/sentry_sdk/integrations/pydantic_ai/patches/graph_nodes.py @@ -0,0 +1,107 @@ +from contextlib import asynccontextmanager +from functools import wraps + +import sentry_sdk +from sentry_sdk.integrations import DidNotEnable + +from ..spans import ( + ai_client_span, + update_ai_client_span, +) + +try: + from pydantic_ai._agent_graph import ModelRequestNode # type: ignore +except ImportError: + raise DidNotEnable("pydantic-ai not installed") + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Any, Callable + + +def _extract_span_data(node: "Any", ctx: "Any") -> "tuple[list[Any], Any, Any]": + """Extract common data needed for creating chat spans. + + Returns: + Tuple of (messages, model, model_settings) + """ + # Extract model and settings from context + model = None + model_settings = None + if hasattr(ctx, "deps"): + model = getattr(ctx.deps, "model", None) + model_settings = getattr(ctx.deps, "model_settings", None) + + # Build full message list: history + current request + messages = [] + if hasattr(ctx, "state") and hasattr(ctx.state, "message_history"): + messages.extend(ctx.state.message_history) + + current_request = getattr(node, "request", None) + if current_request: + messages.append(current_request) + + return messages, model, model_settings + + +def _patch_graph_nodes() -> None: + """ + Patches the graph node execution to create appropriate spans. + + ModelRequestNode -> Creates ai_client span for model requests + CallToolsNode -> Handles tool calls (spans created in tool patching) + """ + + # Patch ModelRequestNode to create ai_client spans + original_model_request_run = ModelRequestNode.run + + @wraps(original_model_request_run) + async def wrapped_model_request_run(self: "Any", ctx: "Any") -> "Any": + messages, model, model_settings = _extract_span_data(self, ctx) + + with ai_client_span(messages, None, model, model_settings) as span: + result = await original_model_request_run(self, ctx) + + # Extract response from result if available + model_response = None + if hasattr(result, "model_response"): + model_response = result.model_response + + update_ai_client_span(span, model_response) + return result + + ModelRequestNode.run = wrapped_model_request_run + + # Patch ModelRequestNode.stream for streaming requests + original_model_request_stream = ModelRequestNode.stream + + def create_wrapped_stream( + original_stream_method: "Callable[..., Any]", + ) -> "Callable[..., Any]": + """Create a wrapper for ModelRequestNode.stream that creates chat spans.""" + + @asynccontextmanager + @wraps(original_stream_method) + async def wrapped_model_request_stream(self: "Any", ctx: "Any") -> "Any": + messages, model, model_settings = _extract_span_data(self, ctx) + + # Create chat span for streaming request + with ai_client_span(messages, None, model, model_settings) as span: + # Call the original stream method + async with original_stream_method(self, ctx) as stream: + yield stream + + # After streaming completes, update span with response data + # The ModelRequestNode stores the final response in _result + model_response = None + if hasattr(self, "_result") and self._result is not None: + # _result is a NextNode containing the model_response + if hasattr(self._result, "model_response"): + model_response = self._result.model_response + + update_ai_client_span(span, model_response) + + return wrapped_model_request_stream + + ModelRequestNode.stream = create_wrapped_stream(original_model_request_stream) diff --git a/sentry_sdk/integrations/pydantic_ai/patches/model_request.py b/sentry_sdk/integrations/pydantic_ai/patches/model_request.py new file mode 100644 index 0000000000..94a96161f3 --- /dev/null +++ b/sentry_sdk/integrations/pydantic_ai/patches/model_request.py @@ -0,0 +1,40 @@ +from functools import wraps +from typing import TYPE_CHECKING + +from sentry_sdk.integrations import DidNotEnable + +try: + from pydantic_ai import models # type: ignore +except ImportError: + raise DidNotEnable("pydantic-ai not installed") + +from ..spans import ai_client_span, update_ai_client_span + + +if TYPE_CHECKING: + from typing import Any + + +def _patch_model_request() -> None: + """ + Patches model request execution to create AI client spans. + + In pydantic-ai, model requests are handled through the Model interface. + We need to patch the request method on models to create spans. + """ + + # Patch the base Model class's request method + if hasattr(models, "Model"): + original_request = models.Model.request + + @wraps(original_request) + async def wrapped_request( + self: "Any", messages: "Any", *args: "Any", **kwargs: "Any" + ) -> "Any": + # Pass all messages (full conversation history) + with ai_client_span(messages, None, self, None) as span: + result = await original_request(self, messages, *args, **kwargs) + update_ai_client_span(span, result) + return result + + models.Model.request = wrapped_request diff --git a/sentry_sdk/integrations/pydantic_ai/patches/tools.py b/sentry_sdk/integrations/pydantic_ai/patches/tools.py new file mode 100644 index 0000000000..b826a543fc --- /dev/null +++ b/sentry_sdk/integrations/pydantic_ai/patches/tools.py @@ -0,0 +1,108 @@ +from functools import wraps + +from sentry_sdk.integrations import DidNotEnable +import sentry_sdk + +from ..spans import execute_tool_span, update_execute_tool_span +from ..utils import _capture_exception, get_current_agent + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Any + +try: + from pydantic_ai.mcp import MCPServer # type: ignore + + HAS_MCP = True +except ImportError: + HAS_MCP = False + +try: + from pydantic_ai._tool_manager import ToolManager # type: ignore + from pydantic_ai.exceptions import ToolRetryError # type: ignore +except ImportError: + raise DidNotEnable("pydantic-ai not installed") + + +def _patch_tool_execution() -> None: + """ + Patch ToolManager._call_tool to create execute_tool spans. + + This is the single point where ALL tool calls flow through in pydantic_ai, + regardless of toolset type (function, MCP, combined, wrapper, etc.). + + By patching here, we avoid: + - Patching multiple toolset classes + - Dealing with signature mismatches from instrumented MCP servers + - Complex nested toolset handling + """ + + original_call_tool = ToolManager._call_tool + + @wraps(original_call_tool) + async def wrapped_call_tool( + self: "Any", call: "Any", *args: "Any", **kwargs: "Any" + ) -> "Any": + # Extract tool info before calling original + name = call.tool_name + tool = self.tools.get(name) if self.tools else None + + # Determine tool type by checking tool.toolset + tool_type = "function" # default + if tool and HAS_MCP and isinstance(tool.toolset, MCPServer): + tool_type = "mcp" + + # Get agent from contextvar + agent = get_current_agent() + + if agent and tool: + try: + args_dict = call.args_as_dict() + except Exception: + args_dict = call.args if isinstance(call.args, dict) else {} + + # Create execute_tool span + # Nesting is handled by isolation_scope() to ensure proper parent-child relationships + with sentry_sdk.isolation_scope(): + with execute_tool_span( + name, + args_dict, + agent, + tool_type=tool_type, + ) as span: + try: + result = await original_call_tool( + self, + call, + *args, + **kwargs, + ) + update_execute_tool_span(span, result) + return result + except ToolRetryError as exc: + # Avoid circular import due to multi-file integration structure + from sentry_sdk.integrations.pydantic_ai import ( + PydanticAIIntegration, + ) + + integration = sentry_sdk.get_client().get_integration( + PydanticAIIntegration + ) + if ( + integration is None + or not integration.handled_tool_call_exceptions + ): + raise exc from None + _capture_exception(exc, handled=True) + raise exc from None + + # No span context - just call original + return await original_call_tool( + self, + call, + *args, + **kwargs, + ) + + ToolManager._call_tool = wrapped_call_tool diff --git a/sentry_sdk/integrations/pydantic_ai/spans/__init__.py b/sentry_sdk/integrations/pydantic_ai/spans/__init__.py new file mode 100644 index 0000000000..574046d645 --- /dev/null +++ b/sentry_sdk/integrations/pydantic_ai/spans/__init__.py @@ -0,0 +1,3 @@ +from .ai_client import ai_client_span, update_ai_client_span # noqa: F401 +from .execute_tool import execute_tool_span, update_execute_tool_span # noqa: F401 +from .invoke_agent import invoke_agent_span, update_invoke_agent_span # noqa: F401 diff --git a/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py b/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py new file mode 100644 index 0000000000..cb34f36e4f --- /dev/null +++ b/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py @@ -0,0 +1,231 @@ +import sentry_sdk +from sentry_sdk.ai.utils import set_data_normalized +from sentry_sdk.consts import OP, SPANDATA +from sentry_sdk.utils import safe_serialize + +from ..consts import SPAN_ORIGIN +from ..utils import ( + _set_agent_data, + _set_available_tools, + _set_model_data, + _should_send_prompts, + _get_model_name, + get_current_agent, + get_is_streaming, +) +from .utils import _set_usage_data + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Any, List, Dict + from pydantic_ai.usage import RequestUsage # type: ignore + +try: + from pydantic_ai.messages import ( # type: ignore + BaseToolCallPart, + BaseToolReturnPart, + SystemPromptPart, + UserPromptPart, + TextPart, + ThinkingPart, + ) +except ImportError: + # Fallback if these classes are not available + BaseToolCallPart = None + BaseToolReturnPart = None + SystemPromptPart = None + UserPromptPart = None + TextPart = None + ThinkingPart = None + + +def _set_input_messages(span: "sentry_sdk.tracing.Span", messages: "Any") -> None: + """Set input messages data on a span.""" + if not _should_send_prompts(): + return + + if not messages: + return + + try: + formatted_messages = [] + system_prompt = None + + # Extract system prompt from any ModelRequest with instructions + for msg in messages: + if hasattr(msg, "instructions") and msg.instructions: + system_prompt = msg.instructions + break + + # Add system prompt as first message if present + if system_prompt: + formatted_messages.append( + {"role": "system", "content": [{"type": "text", "text": system_prompt}]} + ) + + for msg in messages: + if hasattr(msg, "parts"): + for part in msg.parts: + role = "user" + # Use isinstance checks with proper base classes + if SystemPromptPart and isinstance(part, SystemPromptPart): + role = "system" + elif ( + (TextPart and isinstance(part, TextPart)) + or (ThinkingPart and isinstance(part, ThinkingPart)) + or (BaseToolCallPart and isinstance(part, BaseToolCallPart)) + ): + role = "assistant" + elif BaseToolReturnPart and isinstance(part, BaseToolReturnPart): + role = "tool" + + content: "List[Dict[str, Any] | str]" = [] + tool_calls = None + tool_call_id = None + + # Handle ToolCallPart (assistant requesting tool use) + if BaseToolCallPart and isinstance(part, BaseToolCallPart): + tool_call_data = {} + if hasattr(part, "tool_name"): + tool_call_data["name"] = part.tool_name + if hasattr(part, "args"): + tool_call_data["arguments"] = safe_serialize(part.args) + if tool_call_data: + tool_calls = [tool_call_data] + # Handle ToolReturnPart (tool result) + elif BaseToolReturnPart and isinstance(part, BaseToolReturnPart): + if hasattr(part, "tool_name"): + tool_call_id = part.tool_name + if hasattr(part, "content"): + content.append({"type": "text", "text": str(part.content)}) + # Handle regular content + elif hasattr(part, "content"): + if isinstance(part.content, str): + content.append({"type": "text", "text": part.content}) + elif isinstance(part.content, list): + for item in part.content: + if isinstance(item, str): + content.append({"type": "text", "text": item}) + else: + content.append(safe_serialize(item)) + else: + content.append({"type": "text", "text": str(part.content)}) + + # Add message if we have content or tool calls + if content or tool_calls: + message: "Dict[str, Any]" = {"role": role} + if content: + message["content"] = content + if tool_calls: + message["tool_calls"] = tool_calls + if tool_call_id: + message["tool_call_id"] = tool_call_id + formatted_messages.append(message) + + if formatted_messages: + set_data_normalized( + span, SPANDATA.GEN_AI_REQUEST_MESSAGES, formatted_messages, unpack=False + ) + except Exception: + # If we fail to format messages, just skip it + pass + + +def _set_output_data(span: "sentry_sdk.tracing.Span", response: "Any") -> None: + """Set output data on a span.""" + if not _should_send_prompts(): + return + + if not response: + return + + set_data_normalized(span, SPANDATA.GEN_AI_RESPONSE_MODEL, response.model_name) + try: + # Extract text from ModelResponse + if hasattr(response, "parts"): + texts = [] + tool_calls = [] + + for part in response.parts: + if TextPart and isinstance(part, TextPart) and hasattr(part, "content"): + texts.append(part.content) + elif BaseToolCallPart and isinstance(part, BaseToolCallPart): + tool_call_data = { + "type": "function", + } + if hasattr(part, "tool_name"): + tool_call_data["name"] = part.tool_name + if hasattr(part, "args"): + tool_call_data["arguments"] = safe_serialize(part.args) + tool_calls.append(tool_call_data) + + if texts: + set_data_normalized(span, SPANDATA.GEN_AI_RESPONSE_TEXT, texts) + + if tool_calls: + span.set_data( + SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS, safe_serialize(tool_calls) + ) + + except Exception: + # If we fail to format output, just skip it + pass + + +def ai_client_span( + messages: "Any", agent: "Any", model: "Any", model_settings: "Any" +) -> "sentry_sdk.tracing.Span": + """Create a span for an AI client call (model request). + + Args: + messages: Full conversation history (list of messages) + agent: Agent object + model: Model object + model_settings: Model settings + """ + # Determine model name for span name + model_obj = model + if agent and hasattr(agent, "model"): + model_obj = agent.model + + model_name = _get_model_name(model_obj) or "unknown" + + span = sentry_sdk.start_span( + op=OP.GEN_AI_CHAT, + name=f"chat {model_name}", + origin=SPAN_ORIGIN, + ) + + span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "chat") + + _set_agent_data(span, agent) + _set_model_data(span, model, model_settings) + + # Set streaming flag from contextvar + span.set_data(SPANDATA.GEN_AI_RESPONSE_STREAMING, get_is_streaming()) + + # Add available tools if agent is available + agent_obj = agent or get_current_agent() + _set_available_tools(span, agent_obj) + + # Set input messages (full conversation history) + if messages: + _set_input_messages(span, messages) + + return span + + +def update_ai_client_span( + span: "sentry_sdk.tracing.Span", model_response: "Any" +) -> None: + """Update the AI client span with response data.""" + if not span: + return + + # Set usage data if available + if model_response and hasattr(model_response, "usage"): + _set_usage_data(span, model_response.usage) + + # Set output data + _set_output_data(span, model_response) diff --git a/sentry_sdk/integrations/pydantic_ai/spans/execute_tool.py b/sentry_sdk/integrations/pydantic_ai/spans/execute_tool.py new file mode 100644 index 0000000000..cc18302f87 --- /dev/null +++ b/sentry_sdk/integrations/pydantic_ai/spans/execute_tool.py @@ -0,0 +1,49 @@ +import sentry_sdk +from sentry_sdk.consts import OP, SPANDATA +from sentry_sdk.utils import safe_serialize + +from ..consts import SPAN_ORIGIN +from ..utils import _set_agent_data, _should_send_prompts + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Any, Optional + + +def execute_tool_span( + tool_name: str, tool_args: "Any", agent: "Any", tool_type: str = "function" +) -> "sentry_sdk.tracing.Span": + """Create a span for tool execution. + + Args: + tool_name: The name of the tool being executed + tool_args: The arguments passed to the tool + agent: The agent executing the tool + tool_type: The type of tool ("function" for regular tools, "mcp" for MCP services) + """ + span = sentry_sdk.start_span( + op=OP.GEN_AI_EXECUTE_TOOL, + name=f"execute_tool {tool_name}", + origin=SPAN_ORIGIN, + ) + + span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "execute_tool") + span.set_data(SPANDATA.GEN_AI_TOOL_TYPE, tool_type) + span.set_data(SPANDATA.GEN_AI_TOOL_NAME, tool_name) + + _set_agent_data(span, agent) + + if _should_send_prompts() and tool_args is not None: + span.set_data(SPANDATA.GEN_AI_TOOL_INPUT, safe_serialize(tool_args)) + + return span + + +def update_execute_tool_span(span: "sentry_sdk.tracing.Span", result: "Any") -> None: + """Update the execute tool span with the result.""" + if not span: + return + + if _should_send_prompts() and result is not None: + span.set_data(SPANDATA.GEN_AI_TOOL_OUTPUT, safe_serialize(result)) diff --git a/sentry_sdk/integrations/pydantic_ai/spans/invoke_agent.py b/sentry_sdk/integrations/pydantic_ai/spans/invoke_agent.py new file mode 100644 index 0000000000..629b3d1206 --- /dev/null +++ b/sentry_sdk/integrations/pydantic_ai/spans/invoke_agent.py @@ -0,0 +1,144 @@ +import sentry_sdk +from sentry_sdk.ai.utils import get_start_span_function, set_data_normalized +from sentry_sdk.consts import OP, SPANDATA + +from ..consts import SPAN_ORIGIN +from ..utils import ( + _set_agent_data, + _set_available_tools, + _set_model_data, + _should_send_prompts, +) +from .utils import _set_usage_data + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Any + + +def invoke_agent_span( + user_prompt: "Any", + agent: "Any", + model: "Any", + model_settings: "Any", + is_streaming: bool = False, +) -> "sentry_sdk.tracing.Span": + """Create a span for invoking the agent.""" + # Determine agent name for span + name = "agent" + if agent and getattr(agent, "name", None): + name = agent.name + + span = get_start_span_function()( + op=OP.GEN_AI_INVOKE_AGENT, + name=f"invoke_agent {name}", + origin=SPAN_ORIGIN, + ) + + span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "invoke_agent") + + _set_agent_data(span, agent) + _set_model_data(span, model, model_settings) + _set_available_tools(span, agent) + + # Add user prompt and system prompts if available and prompts are enabled + if _should_send_prompts(): + messages = [] + + # Add system prompts (both instructions and system_prompt) + system_texts = [] + + if agent: + # Check for system_prompt + system_prompts = getattr(agent, "_system_prompts", None) or [] + for prompt in system_prompts: + if isinstance(prompt, str): + system_texts.append(prompt) + + # Check for instructions (stored in _instructions) + instructions = getattr(agent, "_instructions", None) + if instructions: + if isinstance(instructions, str): + system_texts.append(instructions) + elif isinstance(instructions, (list, tuple)): + for instr in instructions: + if isinstance(instr, str): + system_texts.append(instr) + elif callable(instr): + # Skip dynamic/callable instructions + pass + + # Add all system texts as system messages + for system_text in system_texts: + messages.append( + { + "content": [{"text": system_text, "type": "text"}], + "role": "system", + } + ) + + # Add user prompt + if user_prompt: + if isinstance(user_prompt, str): + messages.append( + { + "content": [{"text": user_prompt, "type": "text"}], + "role": "user", + } + ) + elif isinstance(user_prompt, list): + # Handle list of user content + content = [] + for item in user_prompt: + if isinstance(item, str): + content.append({"text": item, "type": "text"}) + if content: + messages.append( + { + "content": content, + "role": "user", + } + ) + + if messages: + set_data_normalized( + span, SPANDATA.GEN_AI_REQUEST_MESSAGES, messages, unpack=False + ) + + return span + + +def update_invoke_agent_span(span: "sentry_sdk.tracing.Span", result: "Any") -> None: + """Update and close the invoke agent span.""" + if not span or not result: + return + + # Extract output from result + output = getattr(result, "output", None) + + # Set response text if prompts are enabled + if _should_send_prompts() and output: + set_data_normalized( + span, SPANDATA.GEN_AI_RESPONSE_TEXT, str(output), unpack=False + ) + + # Set token usage data if available + if hasattr(result, "usage") and callable(result.usage): + try: + usage = result.usage() + if usage: + _set_usage_data(span, usage) + except Exception: + # If usage() call fails, continue without setting usage data + pass + + # Set model name from response if available + if hasattr(result, "response"): + try: + response = result.response + if hasattr(response, "model_name") and response.model_name: + span.set_data(SPANDATA.GEN_AI_RESPONSE_MODEL, response.model_name) + except Exception: + # If response access fails, continue without setting model name + pass diff --git a/sentry_sdk/integrations/pydantic_ai/spans/utils.py b/sentry_sdk/integrations/pydantic_ai/spans/utils.py new file mode 100644 index 0000000000..c70afd5f31 --- /dev/null +++ b/sentry_sdk/integrations/pydantic_ai/spans/utils.py @@ -0,0 +1,35 @@ +"""Utility functions for PydanticAI span instrumentation.""" + +import sentry_sdk +from sentry_sdk.consts import SPANDATA + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Union + from pydantic_ai.usage import RequestUsage, RunUsage # type: ignore + + +def _set_usage_data( + span: "sentry_sdk.tracing.Span", usage: "Union[RequestUsage, RunUsage]" +) -> None: + """Set token usage data on a span. + + This function works with both RequestUsage (single request) and + RunUsage (agent run) objects from pydantic_ai. + + Args: + span: The Sentry span to set data on. + usage: RequestUsage or RunUsage object containing token usage information. + """ + if usage is None: + return + + if hasattr(usage, "input_tokens") and usage.input_tokens is not None: + span.set_data(SPANDATA.GEN_AI_USAGE_INPUT_TOKENS, usage.input_tokens) + + if hasattr(usage, "output_tokens") and usage.output_tokens is not None: + span.set_data(SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS, usage.output_tokens) + + if hasattr(usage, "total_tokens") and usage.total_tokens is not None: + span.set_data(SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS, usage.total_tokens) diff --git a/sentry_sdk/integrations/pydantic_ai/utils.py b/sentry_sdk/integrations/pydantic_ai/utils.py new file mode 100644 index 0000000000..62d36fb912 --- /dev/null +++ b/sentry_sdk/integrations/pydantic_ai/utils.py @@ -0,0 +1,217 @@ +import sentry_sdk +from contextvars import ContextVar +from sentry_sdk.consts import SPANDATA +from sentry_sdk.scope import should_send_default_pii +from sentry_sdk.tracing_utils import set_span_errored +from sentry_sdk.utils import event_from_exception, safe_serialize + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Any, Optional + + +# Store the current agent context in a contextvar for re-entrant safety +# Using a list as a stack to support nested agent calls +_agent_context_stack: "ContextVar[list[dict[str, Any]]]" = ContextVar( + "pydantic_ai_agent_context_stack", default=[] +) + + +def push_agent(agent: "Any", is_streaming: bool = False) -> None: + """Push an agent context onto the stack along with its streaming flag.""" + stack = _agent_context_stack.get().copy() + stack.append({"agent": agent, "is_streaming": is_streaming}) + _agent_context_stack.set(stack) + + +def pop_agent() -> None: + """Pop an agent context from the stack.""" + stack = _agent_context_stack.get().copy() + if stack: + stack.pop() + _agent_context_stack.set(stack) + + +def get_current_agent() -> "Any": + """Get the current agent from the contextvar stack.""" + stack = _agent_context_stack.get() + if stack: + return stack[-1]["agent"] + return None + + +def get_is_streaming() -> bool: + """Get the streaming flag from the contextvar stack.""" + stack = _agent_context_stack.get() + if stack: + return stack[-1].get("is_streaming", False) + return False + + +def _should_send_prompts() -> bool: + """ + Check if prompts should be sent to Sentry. + + This checks both send_default_pii and the include_prompts integration setting. + """ + if not should_send_default_pii(): + return False + + from . import PydanticAIIntegration + + # Get the integration instance from the client + integration = sentry_sdk.get_client().get_integration(PydanticAIIntegration) + + if integration is None: + return False + + return getattr(integration, "include_prompts", False) + + +def _set_agent_data(span: "sentry_sdk.tracing.Span", agent: "Any") -> None: + """Set agent-related data on a span. + + Args: + span: The span to set data on + agent: Agent object (can be None, will try to get from contextvar if not provided) + """ + # Extract agent name from agent object or contextvar + agent_obj = agent + if not agent_obj: + # Try to get from contextvar + agent_obj = get_current_agent() + + if agent_obj and hasattr(agent_obj, "name") and agent_obj.name: + span.set_data(SPANDATA.GEN_AI_AGENT_NAME, agent_obj.name) + + +def _get_model_name(model_obj: "Any") -> "Optional[str]": + """Extract model name from a model object. + + Args: + model_obj: Model object to extract name from + + Returns: + Model name string or None if not found + """ + if not model_obj: + return None + + if hasattr(model_obj, "model_name"): + return model_obj.model_name + elif hasattr(model_obj, "name"): + try: + return model_obj.name() + except Exception: + return str(model_obj) + elif isinstance(model_obj, str): + return model_obj + else: + return str(model_obj) + + +def _set_model_data( + span: "sentry_sdk.tracing.Span", model: "Any", model_settings: "Any" +) -> None: + """Set model-related data on a span. + + Args: + span: The span to set data on + model: Model object (can be None, will try to get from agent if not provided) + model_settings: Model settings (can be None, will try to get from agent if not provided) + """ + # Try to get agent from contextvar if we need it + agent_obj = get_current_agent() + + # Extract model information + model_obj = model + if not model_obj and agent_obj and hasattr(agent_obj, "model"): + model_obj = agent_obj.model + + if model_obj: + # Set system from model + if hasattr(model_obj, "system"): + span.set_data(SPANDATA.GEN_AI_SYSTEM, model_obj.system) + + # Set model name + model_name = _get_model_name(model_obj) + if model_name: + span.set_data(SPANDATA.GEN_AI_REQUEST_MODEL, model_name) + + # Extract model settings + settings = model_settings + if not settings and agent_obj and hasattr(agent_obj, "model_settings"): + settings = agent_obj.model_settings + + if settings: + settings_map = { + "max_tokens": SPANDATA.GEN_AI_REQUEST_MAX_TOKENS, + "temperature": SPANDATA.GEN_AI_REQUEST_TEMPERATURE, + "top_p": SPANDATA.GEN_AI_REQUEST_TOP_P, + "frequency_penalty": SPANDATA.GEN_AI_REQUEST_FREQUENCY_PENALTY, + "presence_penalty": SPANDATA.GEN_AI_REQUEST_PRESENCE_PENALTY, + } + + # ModelSettings is a TypedDict (dict at runtime), so use dict access + if isinstance(settings, dict): + for setting_name, spandata_key in settings_map.items(): + value = settings.get(setting_name) + if value is not None: + span.set_data(spandata_key, value) + else: + # Fallback for object-style settings + for setting_name, spandata_key in settings_map.items(): + if hasattr(settings, setting_name): + value = getattr(settings, setting_name) + if value is not None: + span.set_data(spandata_key, value) + + +def _set_available_tools(span: "sentry_sdk.tracing.Span", agent: "Any") -> None: + """Set available tools data on a span from an agent's function toolset. + + Args: + span: The span to set data on + agent: Agent object with _function_toolset attribute + """ + if not agent or not hasattr(agent, "_function_toolset"): + return + + try: + tools = [] + # Get tools from the function toolset + if hasattr(agent._function_toolset, "tools"): + for tool_name, tool in agent._function_toolset.tools.items(): + tool_info = {"name": tool_name} + + # Add description from function_schema if available + if hasattr(tool, "function_schema"): + schema = tool.function_schema + if getattr(schema, "description", None): + tool_info["description"] = schema.description + + # Add parameters from json_schema + if getattr(schema, "json_schema", None): + tool_info["parameters"] = schema.json_schema + + tools.append(tool_info) + + if tools: + span.set_data( + SPANDATA.GEN_AI_REQUEST_AVAILABLE_TOOLS, safe_serialize(tools) + ) + except Exception: + # If we can't extract tools, just skip it + pass + + +def _capture_exception(exc: "Any", handled: bool = False) -> None: + set_span_errored() + + event, hint = event_from_exception( + exc, + client_options=sentry_sdk.get_client().options, + mechanism={"type": "pydantic_ai", "handled": handled}, + ) + sentry_sdk.capture_event(event, hint=hint) diff --git a/sentry_sdk/integrations/pymongo.py b/sentry_sdk/integrations/pymongo.py index 59001bb937..86399b54d1 100644 --- a/sentry_sdk/integrations/pymongo.py +++ b/sentry_sdk/integrations/pymongo.py @@ -1,20 +1,20 @@ -from __future__ import absolute_import import copy +import json -from sentry_sdk import Hub -from sentry_sdk.consts import SPANDATA -from sentry_sdk.hub import _should_send_default_pii +import sentry_sdk +from sentry_sdk.consts import SPANSTATUS, SPANDATA, OP from sentry_sdk.integrations import DidNotEnable, Integration +from sentry_sdk.scope import should_send_default_pii from sentry_sdk.tracing import Span from sentry_sdk.utils import capture_internal_exceptions -from sentry_sdk._types import TYPE_CHECKING - try: from pymongo import monitoring except ImportError: raise DidNotEnable("Pymongo not installed") +from typing import TYPE_CHECKING + if TYPE_CHECKING: from typing import Any, Dict, Union @@ -42,8 +42,7 @@ ] -def _strip_pii(command): - # type: (Dict[str, Any]) -> Dict[str, Any] +def _strip_pii(command: "Dict[str, Any]") -> "Dict[str, Any]": for key in command: is_safe_field = key in SAFE_COMMAND_ATTRIBUTES if is_safe_field: @@ -85,8 +84,7 @@ def _strip_pii(command): return command -def _get_db_data(event): - # type: (Any) -> Dict[str, Any] +def _get_db_data(event: "Any") -> "Dict[str, Any]": data = {} data[SPANDATA.DB_SYSTEM] = "mongodb" @@ -107,19 +105,19 @@ def _get_db_data(event): class CommandTracer(monitoring.CommandListener): - def __init__(self): - # type: () -> None - self._ongoing_operations = {} # type: Dict[int, Span] + def __init__(self) -> None: + self._ongoing_operations: "Dict[int, Span]" = {} - def _operation_key(self, event): - # type: (Union[CommandFailedEvent, CommandStartedEvent, CommandSucceededEvent]) -> int + def _operation_key( + self, + event: "Union[CommandFailedEvent, CommandStartedEvent, CommandSucceededEvent]", + ) -> int: return event.request_id - def started(self, event): - # type: (CommandStartedEvent) -> None - hub = Hub.current - if hub.get_integration(PyMongoIntegration) is None: + def started(self, event: "CommandStartedEvent") -> None: + if sentry_sdk.get_client().get_integration(PyMongoIntegration) is None: return + with capture_internal_exceptions(): command = dict(copy.deepcopy(event.command)) @@ -127,12 +125,11 @@ def started(self, event): command.pop("$clusterTime", None) command.pop("$signature", None) - op = "db.query" - tags = { "db.name": event.database_name, SPANDATA.DB_SYSTEM: "mongodb", SPANDATA.DB_OPERATION: event.command_name, + SPANDATA.DB_MONGODB_COLLECTION: command.get(event.command_name), } try: @@ -141,7 +138,7 @@ def started(self, event): except TypeError: pass - data = {"operation_ids": {}} # type: Dict[str, Any] + data: "Dict[str, Any]" = {"operation_ids": {}} data["operation_ids"]["operation"] = event.operation_id data["operation_ids"]["request"] = event.request_id @@ -153,45 +150,51 @@ def started(self, event): except KeyError: pass - if not _should_send_default_pii(): + if not should_send_default_pii(): command = _strip_pii(command) - query = "{} {}".format(event.command_name, command) - span = hub.start_span(op=op, description=query) + query = json.dumps(command, default=str) + span = sentry_sdk.start_span( + op=OP.DB, + name=query, + origin=PyMongoIntegration.origin, + ) for tag, value in tags.items(): + # set the tag for backwards-compatibility. + # TODO: remove the set_tag call in the next major release! span.set_tag(tag, value) + span.set_data(tag, value) + for key, value in data.items(): span.set_data(key, value) with capture_internal_exceptions(): - hub.add_breadcrumb(message=query, category="query", type=op, data=tags) + sentry_sdk.add_breadcrumb( + message=query, category="query", type=OP.DB, data=tags + ) self._ongoing_operations[self._operation_key(event)] = span.__enter__() - def failed(self, event): - # type: (CommandFailedEvent) -> None - hub = Hub.current - if hub.get_integration(PyMongoIntegration) is None: + def failed(self, event: "CommandFailedEvent") -> None: + if sentry_sdk.get_client().get_integration(PyMongoIntegration) is None: return try: span = self._ongoing_operations.pop(self._operation_key(event)) - span.set_status("internal_error") + span.set_status(SPANSTATUS.INTERNAL_ERROR) span.__exit__(None, None, None) except KeyError: return - def succeeded(self, event): - # type: (CommandSucceededEvent) -> None - hub = Hub.current - if hub.get_integration(PyMongoIntegration) is None: + def succeeded(self, event: "CommandSucceededEvent") -> None: + if sentry_sdk.get_client().get_integration(PyMongoIntegration) is None: return try: span = self._ongoing_operations.pop(self._operation_key(event)) - span.set_status("ok") + span.set_status(SPANSTATUS.OK) span.__exit__(None, None, None) except KeyError: pass @@ -199,8 +202,8 @@ def succeeded(self, event): class PyMongoIntegration(Integration): identifier = "pymongo" + origin = f"auto.db.{identifier}" @staticmethod - def setup_once(): - # type: () -> None + def setup_once() -> None: monitoring.register(CommandTracer()) diff --git a/sentry_sdk/integrations/pyramid.py b/sentry_sdk/integrations/pyramid.py index 6bfed0318f..82e629c862 100644 --- a/sentry_sdk/integrations/pyramid.py +++ b/sentry_sdk/integrations/pyramid.py @@ -1,21 +1,20 @@ -from __future__ import absolute_import - +import functools import os import sys import weakref -from sentry_sdk.hub import Hub, _should_send_default_pii -from sentry_sdk.scope import Scope +import sentry_sdk +from sentry_sdk.integrations import Integration, DidNotEnable +from sentry_sdk.integrations._wsgi_common import RequestExtractor +from sentry_sdk.integrations.wsgi import SentryWsgiMiddleware +from sentry_sdk.scope import should_send_default_pii from sentry_sdk.tracing import SOURCE_FOR_STYLE from sentry_sdk.utils import ( capture_internal_exceptions, + ensure_integration_enabled, event_from_exception, + reraise, ) -from sentry_sdk._compat import reraise, iteritems - -from sentry_sdk.integrations import Integration, DidNotEnable -from sentry_sdk.integrations._wsgi_common import RequestExtractor -from sentry_sdk.integrations.wsgi import SentryWsgiMiddleware try: from pyramid.httpexceptions import HTTPException @@ -23,7 +22,7 @@ except ImportError: raise DidNotEnable("Pyramid not installed") -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from pyramid.response import Response @@ -32,17 +31,16 @@ from typing import Callable from typing import Dict from typing import Optional - from webob.cookies import RequestCookies # type: ignore - from webob.compat import cgi_FieldStorage # type: ignore + from webob.cookies import RequestCookies + from webob.request import _FieldStorageWithFile from sentry_sdk.utils import ExcInfo - from sentry_sdk._types import EventProcessor + from sentry_sdk._types import Event, EventProcessor if getattr(Request, "authenticated_userid", None): - def authenticated_userid(request): - # type: (Request) -> Optional[Any] + def authenticated_userid(request: "Request") -> "Optional[Any]": return request.authenticated_userid else: @@ -55,11 +53,11 @@ def authenticated_userid(request): class PyramidIntegration(Integration): identifier = "pyramid" + origin = f"auto.http.{identifier}" transaction_style = "" - def __init__(self, transaction_style="route_name"): - # type: (str) -> None + def __init__(self, transaction_style: str = "route_name") -> None: if transaction_style not in TRANSACTION_STYLE_VALUES: raise ValueError( "Invalid value for transaction_style: %s (must be in %s)" @@ -68,25 +66,26 @@ def __init__(self, transaction_style="route_name"): self.transaction_style = transaction_style @staticmethod - def setup_once(): - # type: () -> None + def setup_once() -> None: from pyramid import router old_call_view = router._call_view - def sentry_patched_call_view(registry, request, *args, **kwargs): - # type: (Any, Request, *Any, **Any) -> Response - hub = Hub.current - integration = hub.get_integration(PyramidIntegration) + @functools.wraps(old_call_view) + def sentry_patched_call_view( + registry: "Any", request: "Request", *args: "Any", **kwargs: "Any" + ) -> "Response": + integration = sentry_sdk.get_client().get_integration(PyramidIntegration) + if integration is None: + return old_call_view(registry, request, *args, **kwargs) - if integration is not None: - with hub.configure_scope() as scope: - _set_transaction_name_and_source( - scope, integration.transaction_style, request - ) - scope.add_event_processor( - _make_event_processor(weakref.ref(request), integration) - ) + _set_transaction_name_and_source( + sentry_sdk.get_current_scope(), integration.transaction_style, request + ) + scope = sentry_sdk.get_isolation_scope() + scope.add_event_processor( + _make_event_processor(weakref.ref(request), integration) + ) return old_call_view(registry, request, *args, **kwargs) @@ -95,15 +94,17 @@ def sentry_patched_call_view(registry, request, *args, **kwargs): if hasattr(Request, "invoke_exception_view"): old_invoke_exception_view = Request.invoke_exception_view - def sentry_patched_invoke_exception_view(self, *args, **kwargs): - # type: (Request, *Any, **Any) -> Any + def sentry_patched_invoke_exception_view( + self: "Request", *args: "Any", **kwargs: "Any" + ) -> "Any": rv = old_invoke_exception_view(self, *args, **kwargs) if ( self.exc_info and all(self.exc_info) and rv.status_int == 500 - and Hub.current.get_integration(PyramidIntegration) is not None + and sentry_sdk.get_client().get_integration(PyramidIntegration) + is not None ): _capture_exception(self.exc_info) @@ -113,15 +114,13 @@ def sentry_patched_invoke_exception_view(self, *args, **kwargs): old_wsgi_call = router.Router.__call__ - def sentry_patched_wsgi_call(self, environ, start_response): - # type: (Any, Dict[str, str], Callable[..., Any]) -> _ScopedResponse - hub = Hub.current - integration = hub.get_integration(PyramidIntegration) - if integration is None: - return old_wsgi_call(self, environ, start_response) - - def sentry_patched_inner_wsgi_call(environ, start_response): - # type: (Dict[str, Any], Callable[..., Any]) -> Any + @ensure_integration_enabled(PyramidIntegration, old_wsgi_call) + def sentry_patched_wsgi_call( + self: "Any", environ: "Dict[str, str]", start_response: "Callable[..., Any]" + ) -> "_ScopedResponse": + def sentry_patched_inner_wsgi_call( + environ: "Dict[str, Any]", start_response: "Callable[..., Any]" + ) -> "Any": try: return old_wsgi_call(self, environ, start_response) except Exception: @@ -129,35 +128,32 @@ def sentry_patched_inner_wsgi_call(environ, start_response): _capture_exception(einfo) reraise(*einfo) - return SentryWsgiMiddleware(sentry_patched_inner_wsgi_call)( - environ, start_response + middleware = SentryWsgiMiddleware( + sentry_patched_inner_wsgi_call, + span_origin=PyramidIntegration.origin, ) + return middleware(environ, start_response) router.Router.__call__ = sentry_patched_wsgi_call -def _capture_exception(exc_info): - # type: (ExcInfo) -> None +@ensure_integration_enabled(PyramidIntegration) +def _capture_exception(exc_info: "ExcInfo") -> None: if exc_info[0] is None or issubclass(exc_info[0], HTTPException): return - hub = Hub.current - if hub.get_integration(PyramidIntegration) is None: - return - - # If an integration is there, a client has to be there. - client = hub.client # type: Any event, hint = event_from_exception( exc_info, - client_options=client.options, + client_options=sentry_sdk.get_client().options, mechanism={"type": "pyramid", "handled": False}, ) - hub.capture_event(event, hint=hint) + sentry_sdk.capture_event(event, hint=hint) -def _set_transaction_name_and_source(scope, transaction_style, request): - # type: (Scope, str, Request) -> None +def _set_transaction_name_and_source( + scope: "sentry_sdk.Scope", transaction_style: str, request: "Request" +) -> None: try: name_for_style = { "route_name": request.matched_route.name, @@ -172,40 +168,33 @@ def _set_transaction_name_and_source(scope, transaction_style, request): class PyramidRequestExtractor(RequestExtractor): - def url(self): - # type: () -> str + def url(self) -> str: return self.request.path_url - def env(self): - # type: () -> Dict[str, str] + def env(self) -> "Dict[str, str]": return self.request.environ - def cookies(self): - # type: () -> RequestCookies + def cookies(self) -> "RequestCookies": return self.request.cookies - def raw_data(self): - # type: () -> str + def raw_data(self) -> str: return self.request.text - def form(self): - # type: () -> Dict[str, str] + def form(self) -> "Dict[str, str]": return { key: value - for key, value in iteritems(self.request.POST) + for key, value in self.request.POST.items() if not getattr(value, "filename", None) } - def files(self): - # type: () -> Dict[str, cgi_FieldStorage] + def files(self) -> "Dict[str, _FieldStorageWithFile]": return { key: value - for key, value in iteritems(self.request.POST) + for key, value in self.request.POST.items() if getattr(value, "filename", None) } - def size_of_file(self, postdata): - # type: (cgi_FieldStorage) -> int + def size_of_file(self, postdata: "_FieldStorageWithFile") -> int: file = postdata.file try: return os.fstat(file.fileno()).st_size @@ -213,10 +202,10 @@ def size_of_file(self, postdata): return 0 -def _make_event_processor(weak_request, integration): - # type: (Callable[[], Request], PyramidIntegration) -> EventProcessor - def event_processor(event, hint): - # type: (Dict[str, Any], Dict[str, Any]) -> Dict[str, Any] +def _make_event_processor( + weak_request: "Callable[[], Request]", integration: "PyramidIntegration" +) -> "EventProcessor": + def pyramid_event_processor(event: "Event", hint: "Dict[str, Any]") -> "Event": request = weak_request() if request is None: return event @@ -224,11 +213,11 @@ def event_processor(event, hint): with capture_internal_exceptions(): PyramidRequestExtractor(request).extract_into_event(event) - if _should_send_default_pii(): + if should_send_default_pii(): with capture_internal_exceptions(): user_info = event.setdefault("user", {}) user_info.setdefault("id", authenticated_userid(request)) return event - return event_processor + return pyramid_event_processor diff --git a/sentry_sdk/integrations/quart.py b/sentry_sdk/integrations/quart.py index 38420ec795..c1b8fca717 100644 --- a/sentry_sdk/integrations/quart.py +++ b/sentry_sdk/integrations/quart.py @@ -1,28 +1,25 @@ -from __future__ import absolute_import - +import asyncio import inspect -import threading +from functools import wraps -from sentry_sdk.hub import _should_send_default_pii, Hub +import sentry_sdk from sentry_sdk.integrations import DidNotEnable, Integration from sentry_sdk.integrations._wsgi_common import _filter_headers from sentry_sdk.integrations.asgi import SentryAsgiMiddleware -from sentry_sdk.scope import Scope +from sentry_sdk.scope import should_send_default_pii from sentry_sdk.tracing import SOURCE_FOR_STYLE from sentry_sdk.utils import ( capture_internal_exceptions, + ensure_integration_enabled, event_from_exception, ) - -from sentry_sdk._functools import wraps -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any - from typing import Dict from typing import Union - from sentry_sdk._types import EventProcessor + from sentry_sdk._types import Event, EventProcessor try: import quart_auth # type: ignore @@ -45,7 +42,6 @@ request_started, websocket_started, ) - from quart.utils import is_coroutine_function # type: ignore except ImportError: raise DidNotEnable("Quart is not installed") else: @@ -60,11 +56,11 @@ class QuartIntegration(Integration): identifier = "quart" + origin = f"auto.http.{identifier}" transaction_style = "" - def __init__(self, transaction_style="endpoint"): - # type: (str) -> None + def __init__(self, transaction_style: str = "endpoint") -> None: if transaction_style not in TRANSACTION_STYLE_VALUES: raise ValueError( "Invalid value for transaction_style: %s (must be in %s)" @@ -73,9 +69,7 @@ def __init__(self, transaction_style="endpoint"): self.transaction_style = transaction_style @staticmethod - def setup_once(): - # type: () -> None - + def setup_once() -> None: request_started.connect(_request_websocket_started) websocket_started.connect(_request_websocket_started) got_background_exception.connect(_capture_exception) @@ -86,50 +80,48 @@ def setup_once(): patch_scaffold_route() -def patch_asgi_app(): - # type: () -> None +def patch_asgi_app() -> None: old_app = Quart.__call__ - async def sentry_patched_asgi_app(self, scope, receive, send): - # type: (Any, Any, Any, Any) -> Any - if Hub.current.get_integration(QuartIntegration) is None: + async def sentry_patched_asgi_app( + self: "Any", scope: "Any", receive: "Any", send: "Any" + ) -> "Any": + if sentry_sdk.get_client().get_integration(QuartIntegration) is None: return await old_app(self, scope, receive, send) - middleware = SentryAsgiMiddleware(lambda *a, **kw: old_app(self, *a, **kw)) - middleware.__call__ = middleware._run_asgi3 + middleware = SentryAsgiMiddleware( + lambda *a, **kw: old_app(self, *a, **kw), + span_origin=QuartIntegration.origin, + asgi_version=3, + ) return await middleware(scope, receive, send) Quart.__call__ = sentry_patched_asgi_app -def patch_scaffold_route(): - # type: () -> None +def patch_scaffold_route() -> None: old_route = Scaffold.route - def _sentry_route(*args, **kwargs): - # type: (*Any, **Any) -> Any + def _sentry_route(*args: "Any", **kwargs: "Any") -> "Any": old_decorator = old_route(*args, **kwargs) - def decorator(old_func): - # type: (Any) -> Any - - if inspect.isfunction(old_func) and not is_coroutine_function(old_func): + def decorator(old_func: "Any") -> "Any": + if inspect.isfunction(old_func) and not asyncio.iscoroutinefunction( + old_func + ): @wraps(old_func) - def _sentry_func(*args, **kwargs): - # type: (*Any, **Any) -> Any - hub = Hub.current - integration = hub.get_integration(QuartIntegration) - if integration is None: - return old_func(*args, **kwargs) + @ensure_integration_enabled(QuartIntegration, old_func) + def _sentry_func(*args: "Any", **kwargs: "Any") -> "Any": + current_scope = sentry_sdk.get_current_scope() + if current_scope.transaction is not None: + current_scope.transaction.update_active_thread() - with hub.configure_scope() as sentry_scope: - if sentry_scope.profile is not None: - sentry_scope.profile.active_thread_id = ( - threading.current_thread().ident - ) + sentry_scope = sentry_sdk.get_isolation_scope() + if sentry_scope.profile is not None: + sentry_scope.profile.update_active_thread_id() - return old_func(*args, **kwargs) + return old_func(*args, **kwargs) return old_decorator(_sentry_func) @@ -140,9 +132,9 @@ def _sentry_func(*args, **kwargs): Scaffold.route = _sentry_route -def _set_transaction_name_and_source(scope, transaction_style, request): - # type: (Scope, str, Request) -> None - +def _set_transaction_name_and_source( + scope: "sentry_sdk.Scope", transaction_style: str, request: "Request" +) -> None: try: name_for_style = { "url": request.url_rule.rule, @@ -156,35 +148,31 @@ def _set_transaction_name_and_source(scope, transaction_style, request): pass -async def _request_websocket_started(app, **kwargs): - # type: (Quart, **Any) -> None - hub = Hub.current - integration = hub.get_integration(QuartIntegration) +async def _request_websocket_started(app: "Quart", **kwargs: "Any") -> None: + integration = sentry_sdk.get_client().get_integration(QuartIntegration) if integration is None: return - with hub.configure_scope() as scope: - if has_request_context(): - request_websocket = request._get_current_object() - if has_websocket_context(): - request_websocket = websocket._get_current_object() + if has_request_context(): + request_websocket = request._get_current_object() + if has_websocket_context(): + request_websocket = websocket._get_current_object() - # Set the transaction name here, but rely on ASGI middleware - # to actually start the transaction - _set_transaction_name_and_source( - scope, integration.transaction_style, request_websocket - ) + # Set the transaction name here, but rely on ASGI middleware + # to actually start the transaction + _set_transaction_name_and_source( + sentry_sdk.get_current_scope(), integration.transaction_style, request_websocket + ) - evt_processor = _make_request_event_processor( - app, request_websocket, integration - ) - scope.add_event_processor(evt_processor) + scope = sentry_sdk.get_isolation_scope() + evt_processor = _make_request_event_processor(app, request_websocket, integration) + scope.add_event_processor(evt_processor) -def _make_request_event_processor(app, request, integration): - # type: (Quart, Request, QuartIntegration) -> EventProcessor - def inner(event, hint): - # type: (Dict[str, Any], Dict[str, Any]) -> Dict[str, Any] +def _make_request_event_processor( + app: "Quart", request: "Request", integration: "QuartIntegration" +) -> "EventProcessor": + def inner(event: "Event", hint: "dict[str, Any]") -> "Event": # if the request is gone we are fine not logging the data from # it. This might happen if the processor is pushed away to # another thread. @@ -201,7 +189,7 @@ def inner(event, hint): request_info["method"] = request.method request_info["headers"] = _filter_headers(dict(request.headers)) - if _should_send_default_pii(): + if should_send_default_pii(): request_info["env"] = {"REMOTE_ADDR": request.access_route[0]} _add_user_to_event(event) @@ -210,26 +198,23 @@ def inner(event, hint): return inner -async def _capture_exception(sender, exception, **kwargs): - # type: (Quart, Union[ValueError, BaseException], **Any) -> None - hub = Hub.current - if hub.get_integration(QuartIntegration) is None: +async def _capture_exception( + sender: "Quart", exception: "Union[ValueError, BaseException]", **kwargs: "Any" +) -> None: + integration = sentry_sdk.get_client().get_integration(QuartIntegration) + if integration is None: return - # If an integration is there, a client has to be there. - client = hub.client # type: Any - event, hint = event_from_exception( exception, - client_options=client.options, + client_options=sentry_sdk.get_client().options, mechanism={"type": "quart", "handled": False}, ) - hub.capture_event(event, hint=hint) + sentry_sdk.capture_event(event, hint=hint) -def _add_user_to_event(event): - # type: (Dict[str, Any]) -> None +def _add_user_to_event(event: "Event") -> None: if quart_auth is None: return diff --git a/sentry_sdk/integrations/ray.py b/sentry_sdk/integrations/ray.py new file mode 100644 index 0000000000..92a35546ab --- /dev/null +++ b/sentry_sdk/integrations/ray.py @@ -0,0 +1,171 @@ +import inspect +import functools +import sys + +import sentry_sdk +from sentry_sdk.consts import OP, SPANSTATUS +from sentry_sdk.integrations import _check_minimum_version, DidNotEnable, Integration +from sentry_sdk.tracing import TransactionSource +from sentry_sdk.utils import ( + event_from_exception, + logger, + package_version, + qualname_from_function, + reraise, +) + +try: + import ray # type: ignore[import-not-found] +except ImportError: + raise DidNotEnable("Ray not installed.") + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Callable + from typing import Any, Optional + from sentry_sdk.utils import ExcInfo + + +def _check_sentry_initialized() -> None: + if sentry_sdk.get_client().is_active(): + return + + logger.debug( + "[Tracing] Sentry not initialized in ray cluster worker, performance data will be discarded." + ) + + +def _patch_ray_remote() -> None: + old_remote = ray.remote + + @functools.wraps(old_remote) + def new_remote( + f: "Optional[Callable[..., Any]]" = None, *args: "Any", **kwargs: "Any" + ) -> "Callable[..., Any]": + if inspect.isclass(f): + # Ray Actors + # (https://docs.ray.io/en/latest/ray-core/actors.html) + # are not supported + # (Only Ray Tasks are supported) + return old_remote(f, *args, **kwargs) + + def wrapper(user_f: "Callable[..., Any]") -> "Any": + if inspect.isclass(user_f): + # Ray Actors + # (https://docs.ray.io/en/latest/ray-core/actors.html) + # are not supported + # (Only Ray Tasks are supported) + return old_remote(*args, **kwargs)(user_f) + + @functools.wraps(user_f) + def new_func( + *f_args: "Any", + _sentry_tracing: "Optional[dict[str, Any]]" = None, + **f_kwargs: "Any", + ) -> "Any": + _check_sentry_initialized() + + transaction = sentry_sdk.continue_trace( + _sentry_tracing or {}, + op=OP.QUEUE_TASK_RAY, + name=qualname_from_function(user_f), + origin=RayIntegration.origin, + source=TransactionSource.TASK, + ) + + with sentry_sdk.start_transaction(transaction) as transaction: + try: + result = user_f(*f_args, **f_kwargs) + transaction.set_status(SPANSTATUS.OK) + except Exception: + transaction.set_status(SPANSTATUS.INTERNAL_ERROR) + exc_info = sys.exc_info() + _capture_exception(exc_info) + reraise(*exc_info) + + return result + + # Patching new_func signature to add the _sentry_tracing parameter to it + # Ray later inspects the signature and finds the unexpected parameter otherwise + signature = inspect.signature(new_func) + params = list(signature.parameters.values()) + params.append( + inspect.Parameter( + "_sentry_tracing", + kind=inspect.Parameter.KEYWORD_ONLY, + default=None, + ) + ) + new_func.__signature__ = signature.replace(parameters=params) # type: ignore[attr-defined] + + if f: + rv = old_remote(new_func) + else: + rv = old_remote(*args, **kwargs)(new_func) + old_remote_method = rv.remote + + def _remote_method_with_header_propagation( + *args: "Any", **kwargs: "Any" + ) -> "Any": + """ + Ray Client + """ + with sentry_sdk.start_span( + op=OP.QUEUE_SUBMIT_RAY, + name=qualname_from_function(user_f), + origin=RayIntegration.origin, + ) as span: + tracing = { + k: v + for k, v in sentry_sdk.get_current_scope().iter_trace_propagation_headers() + } + try: + result = old_remote_method( + *args, **kwargs, _sentry_tracing=tracing + ) + span.set_status(SPANSTATUS.OK) + except Exception: + span.set_status(SPANSTATUS.INTERNAL_ERROR) + exc_info = sys.exc_info() + _capture_exception(exc_info) + reraise(*exc_info) + + return result + + rv.remote = _remote_method_with_header_propagation + + return rv + + if f is not None: + return wrapper(f) + else: + return wrapper + + ray.remote = new_remote + + +def _capture_exception(exc_info: "ExcInfo", **kwargs: "Any") -> None: + client = sentry_sdk.get_client() + + event, hint = event_from_exception( + exc_info, + client_options=client.options, + mechanism={ + "handled": False, + "type": RayIntegration.identifier, + }, + ) + sentry_sdk.capture_event(event, hint=hint) + + +class RayIntegration(Integration): + identifier = "ray" + origin = f"auto.queue.{identifier}" + + @staticmethod + def setup_once() -> None: + version = package_version("ray") + _check_minimum_version(RayIntegration, version) + + _patch_ray_remote() diff --git a/sentry_sdk/integrations/redis/__init__.py b/sentry_sdk/integrations/redis/__init__.py index f6c4f186ff..a5b67eb7f6 100644 --- a/sentry_sdk/integrations/redis/__init__.py +++ b/sentry_sdk/integrations/redis/__init__.py @@ -1,279 +1,47 @@ -from __future__ import absolute_import +import warnings -from sentry_sdk import Hub -from sentry_sdk.consts import OP, SPANDATA -from sentry_sdk._compat import text_type -from sentry_sdk.hub import _should_send_default_pii from sentry_sdk.integrations import Integration, DidNotEnable -from sentry_sdk._types import TYPE_CHECKING -from sentry_sdk.utils import ( - SENSITIVE_DATA_SUBSTITUTE, - capture_internal_exceptions, - logger, -) +from sentry_sdk.integrations.redis.consts import _DEFAULT_MAX_DATA_SIZE +from sentry_sdk.integrations.redis.rb import _patch_rb +from sentry_sdk.integrations.redis.redis import _patch_redis +from sentry_sdk.integrations.redis.redis_cluster import _patch_redis_cluster +from sentry_sdk.integrations.redis.redis_py_cluster_legacy import _patch_rediscluster +from sentry_sdk.utils import logger -if TYPE_CHECKING: - from typing import Any, Dict, Sequence - from sentry_sdk.tracing import Span - -_SINGLE_KEY_COMMANDS = frozenset( - ["decr", "decrby", "get", "incr", "incrby", "pttl", "set", "setex", "setnx", "ttl"], -) -_MULTI_KEY_COMMANDS = frozenset( - ["del", "touch", "unlink"], -) -_COMMANDS_INCLUDING_SENSITIVE_DATA = [ - "auth", -] -_MAX_NUM_ARGS = 10 # Trim argument lists to this many values -_MAX_NUM_COMMANDS = 10 # Trim command lists to this many values -_DEFAULT_MAX_DATA_SIZE = 1024 - - -def _get_safe_command(name, args): - # type: (str, Sequence[Any]) -> str - command_parts = [name] - - for i, arg in enumerate(args): - if i > _MAX_NUM_ARGS: - break - - name_low = name.lower() - - if name_low in _COMMANDS_INCLUDING_SENSITIVE_DATA: - command_parts.append(SENSITIVE_DATA_SUBSTITUTE) - continue - - arg_is_the_key = i == 0 - if arg_is_the_key: - command_parts.append(repr(arg)) - - else: - if _should_send_default_pii(): - command_parts.append(repr(arg)) - else: - command_parts.append(SENSITIVE_DATA_SUBSTITUTE) - - command = " ".join(command_parts) - return command - - -def _get_span_description(name, *args): - # type: (str, *Any) -> str - description = name - - with capture_internal_exceptions(): - description = _get_safe_command(name, args) - - return description - - -def _get_redis_command_args(command): - # type: (Any) -> Sequence[Any] - return command[0] - - -def _parse_rediscluster_command(command): - # type: (Any) -> Sequence[Any] - return command.args - - -def _set_pipeline_data( - span, is_cluster, get_command_args_fn, is_transaction, command_stack -): - # type: (Span, bool, Any, bool, Sequence[Any]) -> None - span.set_tag("redis.is_cluster", is_cluster) - transaction = is_transaction if not is_cluster else False - span.set_tag("redis.transaction", transaction) - - commands = [] - for i, arg in enumerate(command_stack): - if i >= _MAX_NUM_COMMANDS: - break - - command = get_command_args_fn(arg) - commands.append(_get_safe_command(command[0], command[1:])) - - span.set_data( - "redis.commands", - { - "count": len(command_stack), - "first_ten": commands, - }, - ) - - -def _set_client_data(span, is_cluster, name, *args): - # type: (Span, bool, str, *Any) -> None - span.set_tag("redis.is_cluster", is_cluster) - if name: - span.set_tag("redis.command", name) - span.set_tag(SPANDATA.DB_OPERATION, name) - - if name and args: - name_low = name.lower() - if (name_low in _SINGLE_KEY_COMMANDS) or ( - name_low in _MULTI_KEY_COMMANDS and len(args) == 1 - ): - span.set_tag("redis.key", args[0]) - - -def _set_db_data(span, connection_params): - # type: (Span, Dict[str, Any]) -> None - span.set_data(SPANDATA.DB_SYSTEM, "redis") - - db = connection_params.get("db") - if db is not None: - span.set_data(SPANDATA.DB_NAME, text_type(db)) - - host = connection_params.get("host") - if host is not None: - span.set_data(SPANDATA.SERVER_ADDRESS, host) +from typing import TYPE_CHECKING - port = connection_params.get("port") - if port is not None: - span.set_data(SPANDATA.SERVER_PORT, port) - - -def patch_redis_pipeline(pipeline_cls, is_cluster, get_command_args_fn): - # type: (Any, bool, Any) -> None - old_execute = pipeline_cls.execute - - def sentry_patched_execute(self, *args, **kwargs): - # type: (Any, *Any, **Any) -> Any - hub = Hub.current - - if hub.get_integration(RedisIntegration) is None: - return old_execute(self, *args, **kwargs) - - with hub.start_span( - op=OP.DB_REDIS, description="redis.pipeline.execute" - ) as span: - with capture_internal_exceptions(): - _set_db_data(span, self.connection_pool.connection_kwargs) - _set_pipeline_data( - span, - is_cluster, - get_command_args_fn, - self.transaction, - self.command_stack, - ) - - return old_execute(self, *args, **kwargs) - - pipeline_cls.execute = sentry_patched_execute - - -def patch_redis_client(cls, is_cluster): - # type: (Any, bool) -> None - """ - This function can be used to instrument custom redis client classes or - subclasses. - """ - old_execute_command = cls.execute_command - - def sentry_patched_execute_command(self, name, *args, **kwargs): - # type: (Any, str, *Any, **Any) -> Any - hub = Hub.current - integration = hub.get_integration(RedisIntegration) - - if integration is None: - return old_execute_command(self, name, *args, **kwargs) - - description = _get_span_description(name, *args) - - data_should_be_truncated = ( - integration.max_data_size and len(description) > integration.max_data_size - ) - if data_should_be_truncated: - description = description[: integration.max_data_size - len("...")] + "..." - - with hub.start_span(op=OP.DB_REDIS, description=description) as span: - _set_db_data(span, self.connection_pool.connection_kwargs) - _set_client_data(span, is_cluster, name, *args) - - return old_execute_command(self, name, *args, **kwargs) - - cls.execute_command = sentry_patched_execute_command - - -def _patch_redis(StrictRedis, client): # noqa: N803 - # type: (Any, Any) -> None - patch_redis_client(StrictRedis, is_cluster=False) - patch_redis_pipeline(client.Pipeline, False, _get_redis_command_args) - try: - strict_pipeline = client.StrictPipeline - except AttributeError: - pass - else: - patch_redis_pipeline(strict_pipeline, False, _get_redis_command_args) - - try: - import redis.asyncio - except ImportError: - pass - else: - from sentry_sdk.integrations.redis.asyncio import ( - patch_redis_async_client, - patch_redis_async_pipeline, - ) - - patch_redis_async_client(redis.asyncio.client.StrictRedis) - patch_redis_async_pipeline(redis.asyncio.client.Pipeline) - - -def _patch_rb(): - # type: () -> None - try: - import rb.clients # type: ignore - except ImportError: - pass - else: - patch_redis_client(rb.clients.FanoutClient, is_cluster=False) - patch_redis_client(rb.clients.MappingClient, is_cluster=False) - patch_redis_client(rb.clients.RoutingClient, is_cluster=False) - - -def _patch_rediscluster(): - # type: () -> None - try: - import rediscluster # type: ignore - except ImportError: - return - - patch_redis_client(rediscluster.RedisCluster, is_cluster=True) - - # up to v1.3.6, __version__ attribute is a tuple - # from v2.0.0, __version__ is a string and VERSION a tuple - version = getattr(rediscluster, "VERSION", rediscluster.__version__) - - # StrictRedisCluster was introduced in v0.2.0 and removed in v2.0.0 - # https://github.com/Grokzen/redis-py-cluster/blob/master/docs/release-notes.rst - if (0, 2, 0) < version < (2, 0, 0): - pipeline_cls = rediscluster.pipeline.StrictClusterPipeline - patch_redis_client(rediscluster.StrictRedisCluster, is_cluster=True) - else: - pipeline_cls = rediscluster.pipeline.ClusterPipeline - - patch_redis_pipeline(pipeline_cls, True, _parse_rediscluster_command) +if TYPE_CHECKING: + from typing import Optional class RedisIntegration(Integration): identifier = "redis" - def __init__(self, max_data_size=_DEFAULT_MAX_DATA_SIZE): - # type: (int) -> None + def __init__( + self, + max_data_size: "Optional[int]" = _DEFAULT_MAX_DATA_SIZE, + cache_prefixes: "Optional[list[str]]" = None, + ) -> None: self.max_data_size = max_data_size + self.cache_prefixes = cache_prefixes if cache_prefixes is not None else [] + + if max_data_size is not None: + warnings.warn( + "The `max_data_size` parameter of `RedisIntegration` is " + "deprecated and will be removed in version 3.0 of sentry-sdk.", + DeprecationWarning, + stacklevel=2, + ) @staticmethod - def setup_once(): - # type: () -> None + def setup_once() -> None: try: from redis import StrictRedis, client except ImportError: raise DidNotEnable("Redis client not installed") _patch_redis(StrictRedis, client) + _patch_redis_cluster() _patch_rb() try: diff --git a/sentry_sdk/integrations/redis/_async_common.py b/sentry_sdk/integrations/redis/_async_common.py new file mode 100644 index 0000000000..1afc355843 --- /dev/null +++ b/sentry_sdk/integrations/redis/_async_common.py @@ -0,0 +1,121 @@ +import sentry_sdk +from sentry_sdk.consts import OP +from sentry_sdk.integrations.redis.consts import SPAN_ORIGIN +from sentry_sdk.integrations.redis.modules.caches import ( + _compile_cache_span_properties, + _set_cache_data, +) +from sentry_sdk.integrations.redis.modules.queries import _compile_db_span_properties +from sentry_sdk.integrations.redis.utils import ( + _set_client_data, + _set_pipeline_data, +) +from sentry_sdk.tracing import Span +from sentry_sdk.utils import capture_internal_exceptions + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Callable + from typing import Any, Union + from redis.asyncio.client import Pipeline, StrictRedis + from redis.asyncio.cluster import ClusterPipeline, RedisCluster + + +def patch_redis_async_pipeline( + pipeline_cls: "Union[type[Pipeline[Any]], type[ClusterPipeline[Any]]]", + is_cluster: bool, + get_command_args_fn: "Any", + set_db_data_fn: "Callable[[Span, Any], None]", +) -> None: + old_execute = pipeline_cls.execute + + from sentry_sdk.integrations.redis import RedisIntegration + + async def _sentry_execute(self: "Any", *args: "Any", **kwargs: "Any") -> "Any": + if sentry_sdk.get_client().get_integration(RedisIntegration) is None: + return await old_execute(self, *args, **kwargs) + + with sentry_sdk.start_span( + op=OP.DB_REDIS, + name="redis.pipeline.execute", + origin=SPAN_ORIGIN, + ) as span: + with capture_internal_exceptions(): + try: + command_seq = self._execution_strategy._command_queue + except AttributeError: + if is_cluster: + command_seq = self._command_stack + else: + command_seq = self.command_stack + + set_db_data_fn(span, self) + _set_pipeline_data( + span, + is_cluster, + get_command_args_fn, + False if is_cluster else self.is_transaction, + command_seq, + ) + + return await old_execute(self, *args, **kwargs) + + pipeline_cls.execute = _sentry_execute # type: ignore + + +def patch_redis_async_client( + cls: "Union[type[StrictRedis[Any]], type[RedisCluster[Any]]]", + is_cluster: bool, + set_db_data_fn: "Callable[[Span, Any], None]", +) -> None: + old_execute_command = cls.execute_command + + from sentry_sdk.integrations.redis import RedisIntegration + + async def _sentry_execute_command( + self: "Any", name: str, *args: "Any", **kwargs: "Any" + ) -> "Any": + integration = sentry_sdk.get_client().get_integration(RedisIntegration) + if integration is None: + return await old_execute_command(self, name, *args, **kwargs) + + cache_properties = _compile_cache_span_properties( + name, + args, + kwargs, + integration, + ) + + cache_span = None + if cache_properties["is_cache_key"] and cache_properties["op"] is not None: + cache_span = sentry_sdk.start_span( + op=cache_properties["op"], + name=cache_properties["description"], + origin=SPAN_ORIGIN, + ) + cache_span.__enter__() + + db_properties = _compile_db_span_properties(integration, name, args) + + db_span = sentry_sdk.start_span( + op=db_properties["op"], + name=db_properties["description"], + origin=SPAN_ORIGIN, + ) + db_span.__enter__() + + set_db_data_fn(db_span, self) + _set_client_data(db_span, is_cluster, name, *args) + + value = await old_execute_command(self, name, *args, **kwargs) + + db_span.__exit__(None, None, None) + + if cache_span: + _set_cache_data(cache_span, self, cache_properties, value) + cache_span.__exit__(None, None, None) + + return value + + cls.execute_command = _sentry_execute_command # type: ignore diff --git a/sentry_sdk/integrations/redis/_sync_common.py b/sentry_sdk/integrations/redis/_sync_common.py new file mode 100644 index 0000000000..4624260f6a --- /dev/null +++ b/sentry_sdk/integrations/redis/_sync_common.py @@ -0,0 +1,119 @@ +import sentry_sdk +from sentry_sdk.consts import OP +from sentry_sdk.integrations.redis.consts import SPAN_ORIGIN +from sentry_sdk.integrations.redis.modules.caches import ( + _compile_cache_span_properties, + _set_cache_data, +) +from sentry_sdk.integrations.redis.modules.queries import _compile_db_span_properties +from sentry_sdk.integrations.redis.utils import ( + _set_client_data, + _set_pipeline_data, +) +from sentry_sdk.tracing import Span +from sentry_sdk.utils import capture_internal_exceptions + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Callable + from typing import Any + + +def patch_redis_pipeline( + pipeline_cls: "Any", + is_cluster: bool, + get_command_args_fn: "Any", + set_db_data_fn: "Callable[[Span, Any], None]", +) -> None: + old_execute = pipeline_cls.execute + + from sentry_sdk.integrations.redis import RedisIntegration + + def sentry_patched_execute(self: "Any", *args: "Any", **kwargs: "Any") -> "Any": + if sentry_sdk.get_client().get_integration(RedisIntegration) is None: + return old_execute(self, *args, **kwargs) + + with sentry_sdk.start_span( + op=OP.DB_REDIS, + name="redis.pipeline.execute", + origin=SPAN_ORIGIN, + ) as span: + with capture_internal_exceptions(): + command_seq = None + try: + command_seq = self._execution_strategy.command_queue + except AttributeError: + command_seq = self.command_stack + + set_db_data_fn(span, self) + _set_pipeline_data( + span, + is_cluster, + get_command_args_fn, + False if is_cluster else self.transaction, + command_seq, + ) + + return old_execute(self, *args, **kwargs) + + pipeline_cls.execute = sentry_patched_execute + + +def patch_redis_client( + cls: "Any", is_cluster: bool, set_db_data_fn: "Callable[[Span, Any], None]" +) -> None: + """ + This function can be used to instrument custom redis client classes or + subclasses. + """ + old_execute_command = cls.execute_command + + from sentry_sdk.integrations.redis import RedisIntegration + + def sentry_patched_execute_command( + self: "Any", name: str, *args: "Any", **kwargs: "Any" + ) -> "Any": + integration = sentry_sdk.get_client().get_integration(RedisIntegration) + if integration is None: + return old_execute_command(self, name, *args, **kwargs) + + cache_properties = _compile_cache_span_properties( + name, + args, + kwargs, + integration, + ) + + cache_span = None + if cache_properties["is_cache_key"] and cache_properties["op"] is not None: + cache_span = sentry_sdk.start_span( + op=cache_properties["op"], + name=cache_properties["description"], + origin=SPAN_ORIGIN, + ) + cache_span.__enter__() + + db_properties = _compile_db_span_properties(integration, name, args) + + db_span = sentry_sdk.start_span( + op=db_properties["op"], + name=db_properties["description"], + origin=SPAN_ORIGIN, + ) + db_span.__enter__() + + set_db_data_fn(db_span, self) + _set_client_data(db_span, is_cluster, name, *args) + + value = old_execute_command(self, name, *args, **kwargs) + + db_span.__exit__(None, None, None) + + if cache_span: + _set_cache_data(cache_span, self, cache_properties, value) + cache_span.__exit__(None, None, None) + + return value + + cls.execute_command = sentry_patched_execute_command diff --git a/sentry_sdk/integrations/redis/asyncio.py b/sentry_sdk/integrations/redis/asyncio.py deleted file mode 100644 index 70decdcbd4..0000000000 --- a/sentry_sdk/integrations/redis/asyncio.py +++ /dev/null @@ -1,68 +0,0 @@ -from __future__ import absolute_import - -from sentry_sdk import Hub -from sentry_sdk.consts import OP -from sentry_sdk.integrations.redis import ( - RedisIntegration, - _get_redis_command_args, - _get_span_description, - _set_client_data, - _set_db_data, - _set_pipeline_data, -) -from sentry_sdk._types import TYPE_CHECKING -from sentry_sdk.utils import capture_internal_exceptions - -if TYPE_CHECKING: - from typing import Any - - -def patch_redis_async_pipeline(pipeline_cls): - # type: (Any) -> None - old_execute = pipeline_cls.execute - - async def _sentry_execute(self, *args, **kwargs): - # type: (Any, *Any, **Any) -> Any - hub = Hub.current - - if hub.get_integration(RedisIntegration) is None: - return await old_execute(self, *args, **kwargs) - - with hub.start_span( - op=OP.DB_REDIS, description="redis.pipeline.execute" - ) as span: - with capture_internal_exceptions(): - _set_db_data(span, self.connection_pool.connection_kwargs) - _set_pipeline_data( - span, - False, - _get_redis_command_args, - self.is_transaction, - self.command_stack, - ) - - return await old_execute(self, *args, **kwargs) - - pipeline_cls.execute = _sentry_execute - - -def patch_redis_async_client(cls): - # type: (Any) -> None - old_execute_command = cls.execute_command - - async def _sentry_execute_command(self, name, *args, **kwargs): - # type: (Any, str, *Any, **Any) -> Any - hub = Hub.current - - if hub.get_integration(RedisIntegration) is None: - return await old_execute_command(self, name, *args, **kwargs) - - description = _get_span_description(name, *args) - - with hub.start_span(op=OP.DB_REDIS, description=description) as span: - _set_db_data(span, self.connection_pool.connection_kwargs) - _set_client_data(span, False, name, *args) - - return await old_execute_command(self, name, *args, **kwargs) - - cls.execute_command = _sentry_execute_command diff --git a/sentry_sdk/integrations/redis/consts.py b/sentry_sdk/integrations/redis/consts.py new file mode 100644 index 0000000000..0822c2c930 --- /dev/null +++ b/sentry_sdk/integrations/redis/consts.py @@ -0,0 +1,19 @@ +SPAN_ORIGIN = "auto.db.redis" + +_SINGLE_KEY_COMMANDS = frozenset( + ["decr", "decrby", "get", "incr", "incrby", "pttl", "set", "setex", "setnx", "ttl"], +) +_MULTI_KEY_COMMANDS = frozenset( + [ + "del", + "touch", + "unlink", + "mget", + ], +) +_COMMANDS_INCLUDING_SENSITIVE_DATA = [ + "auth", +] +_MAX_NUM_ARGS = 10 # Trim argument lists to this many values +_MAX_NUM_COMMANDS = 10 # Trim command lists to this many values +_DEFAULT_MAX_DATA_SIZE = None diff --git a/sentry_sdk/integrations/redis/modules/__init__.py b/sentry_sdk/integrations/redis/modules/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/sentry_sdk/integrations/redis/modules/caches.py b/sentry_sdk/integrations/redis/modules/caches.py new file mode 100644 index 0000000000..ee5a7d3943 --- /dev/null +++ b/sentry_sdk/integrations/redis/modules/caches.py @@ -0,0 +1,129 @@ +""" +Code used for the Caches module in Sentry +""" + +from sentry_sdk.consts import OP, SPANDATA +from sentry_sdk.integrations.redis.utils import _get_safe_key, _key_as_string +from sentry_sdk.utils import capture_internal_exceptions + +GET_COMMANDS = ("get", "mget") +SET_COMMANDS = ("set", "setex") + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from sentry_sdk.integrations.redis import RedisIntegration + from sentry_sdk.tracing import Span + from typing import Any, Optional + + +def _get_op(name: str) -> "Optional[str]": + op = None + if name.lower() in GET_COMMANDS: + op = OP.CACHE_GET + elif name.lower() in SET_COMMANDS: + op = OP.CACHE_PUT + + return op + + +def _compile_cache_span_properties( + redis_command: str, + args: "tuple[Any, ...]", + kwargs: "dict[str, Any]", + integration: "RedisIntegration", +) -> "dict[str, Any]": + key = _get_safe_key(redis_command, args, kwargs) + key_as_string = _key_as_string(key) + keys_as_string = key_as_string.split(", ") + + is_cache_key = False + for prefix in integration.cache_prefixes: + for kee in keys_as_string: + if kee.startswith(prefix): + is_cache_key = True + break + if is_cache_key: + break + + value = None + if redis_command.lower() in SET_COMMANDS: + value = args[-1] + + properties = { + "op": _get_op(redis_command), + "description": _get_cache_span_description( + redis_command, args, kwargs, integration + ), + "key": key, + "key_as_string": key_as_string, + "redis_command": redis_command.lower(), + "is_cache_key": is_cache_key, + "value": value, + } + + return properties + + +def _get_cache_span_description( + redis_command: str, + args: "tuple[Any, ...]", + kwargs: "dict[str, Any]", + integration: "RedisIntegration", +) -> str: + description = _key_as_string(_get_safe_key(redis_command, args, kwargs)) + + if integration.max_data_size and len(description) > integration.max_data_size: + description = description[: integration.max_data_size - len("...")] + "..." + + return description + + +def _set_cache_data( + span: "Span", + redis_client: "Any", + properties: "dict[str, Any]", + return_value: "Optional[Any]", +) -> None: + with capture_internal_exceptions(): + span.set_data(SPANDATA.CACHE_KEY, properties["key"]) + + if properties["redis_command"] in GET_COMMANDS: + if return_value is not None: + span.set_data(SPANDATA.CACHE_HIT, True) + size = ( + len(str(return_value).encode("utf-8")) + if not isinstance(return_value, bytes) + else len(return_value) + ) + span.set_data(SPANDATA.CACHE_ITEM_SIZE, size) + else: + span.set_data(SPANDATA.CACHE_HIT, False) + + elif properties["redis_command"] in SET_COMMANDS: + if properties["value"] is not None: + size = ( + len(properties["value"].encode("utf-8")) + if not isinstance(properties["value"], bytes) + else len(properties["value"]) + ) + span.set_data(SPANDATA.CACHE_ITEM_SIZE, size) + + try: + connection_params = redis_client.connection_pool.connection_kwargs + except AttributeError: + # If it is a cluster, there is no connection_pool attribute so we + # need to get the default node from the cluster instance + default_node = redis_client.get_default_node() + connection_params = { + "host": default_node.host, + "port": default_node.port, + } + + host = connection_params.get("host") + if host is not None: + span.set_data(SPANDATA.NETWORK_PEER_ADDRESS, host) + + port = connection_params.get("port") + if port is not None: + span.set_data(SPANDATA.NETWORK_PEER_PORT, port) diff --git a/sentry_sdk/integrations/redis/modules/queries.py b/sentry_sdk/integrations/redis/modules/queries.py new file mode 100644 index 0000000000..3e8a820f44 --- /dev/null +++ b/sentry_sdk/integrations/redis/modules/queries.py @@ -0,0 +1,65 @@ +""" +Code used for the Queries module in Sentry +""" + +from sentry_sdk.consts import OP, SPANDATA +from sentry_sdk.integrations.redis.utils import _get_safe_command +from sentry_sdk.utils import capture_internal_exceptions + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from redis import Redis + from sentry_sdk.integrations.redis import RedisIntegration + from sentry_sdk.tracing import Span + from typing import Any + + +def _compile_db_span_properties( + integration: "RedisIntegration", redis_command: str, args: "tuple[Any, ...]" +) -> "dict[str, Any]": + description = _get_db_span_description(integration, redis_command, args) + + properties = { + "op": OP.DB_REDIS, + "description": description, + } + + return properties + + +def _get_db_span_description( + integration: "RedisIntegration", command_name: str, args: "tuple[Any, ...]" +) -> str: + description = command_name + + with capture_internal_exceptions(): + description = _get_safe_command(command_name, args) + + if integration.max_data_size and len(description) > integration.max_data_size: + description = description[: integration.max_data_size - len("...")] + "..." + + return description + + +def _set_db_data_on_span(span: "Span", connection_params: "dict[str, Any]") -> None: + span.set_data(SPANDATA.DB_SYSTEM, "redis") + + db = connection_params.get("db") + if db is not None: + span.set_data(SPANDATA.DB_NAME, str(db)) + + host = connection_params.get("host") + if host is not None: + span.set_data(SPANDATA.SERVER_ADDRESS, host) + + port = connection_params.get("port") + if port is not None: + span.set_data(SPANDATA.SERVER_PORT, port) + + +def _set_db_data(span: "Span", redis_instance: "Redis[Any]") -> None: + try: + _set_db_data_on_span(span, redis_instance.connection_pool.connection_kwargs) + except AttributeError: + pass # connections_kwargs may be missing in some cases diff --git a/sentry_sdk/integrations/redis/rb.py b/sentry_sdk/integrations/redis/rb.py new file mode 100644 index 0000000000..e2ce863fe8 --- /dev/null +++ b/sentry_sdk/integrations/redis/rb.py @@ -0,0 +1,31 @@ +""" +Instrumentation for Redis Blaster (rb) + +https://github.com/getsentry/rb +""" + +from sentry_sdk.integrations.redis._sync_common import patch_redis_client +from sentry_sdk.integrations.redis.modules.queries import _set_db_data + + +def _patch_rb() -> None: + try: + import rb.clients # type: ignore + except ImportError: + pass + else: + patch_redis_client( + rb.clients.FanoutClient, + is_cluster=False, + set_db_data_fn=_set_db_data, + ) + patch_redis_client( + rb.clients.MappingClient, + is_cluster=False, + set_db_data_fn=_set_db_data, + ) + patch_redis_client( + rb.clients.RoutingClient, + is_cluster=False, + set_db_data_fn=_set_db_data, + ) diff --git a/sentry_sdk/integrations/redis/redis.py b/sentry_sdk/integrations/redis/redis.py new file mode 100644 index 0000000000..8011001456 --- /dev/null +++ b/sentry_sdk/integrations/redis/redis.py @@ -0,0 +1,67 @@ +""" +Instrumentation for Redis + +https://github.com/redis/redis-py +""" + +from sentry_sdk.integrations.redis._sync_common import ( + patch_redis_client, + patch_redis_pipeline, +) +from sentry_sdk.integrations.redis.modules.queries import _set_db_data + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Any, Sequence + + +def _get_redis_command_args(command: "Any") -> "Sequence[Any]": + return command[0] + + +def _patch_redis(StrictRedis: "Any", client: "Any") -> None: # noqa: N803 + patch_redis_client( + StrictRedis, + is_cluster=False, + set_db_data_fn=_set_db_data, + ) + patch_redis_pipeline( + client.Pipeline, + is_cluster=False, + get_command_args_fn=_get_redis_command_args, + set_db_data_fn=_set_db_data, + ) + try: + strict_pipeline = client.StrictPipeline + except AttributeError: + pass + else: + patch_redis_pipeline( + strict_pipeline, + is_cluster=False, + get_command_args_fn=_get_redis_command_args, + set_db_data_fn=_set_db_data, + ) + + try: + import redis.asyncio + except ImportError: + pass + else: + from sentry_sdk.integrations.redis._async_common import ( + patch_redis_async_client, + patch_redis_async_pipeline, + ) + + patch_redis_async_client( + redis.asyncio.client.StrictRedis, + is_cluster=False, + set_db_data_fn=_set_db_data, + ) + patch_redis_async_pipeline( + redis.asyncio.client.Pipeline, + False, + _get_redis_command_args, + set_db_data_fn=_set_db_data, + ) diff --git a/sentry_sdk/integrations/redis/redis_cluster.py b/sentry_sdk/integrations/redis/redis_cluster.py new file mode 100644 index 0000000000..b73a8e730c --- /dev/null +++ b/sentry_sdk/integrations/redis/redis_cluster.py @@ -0,0 +1,109 @@ +""" +Instrumentation for RedisCluster +This is part of the main redis-py client. + +https://github.com/redis/redis-py/blob/master/redis/cluster.py +""" + +from sentry_sdk.integrations.redis._sync_common import ( + patch_redis_client, + patch_redis_pipeline, +) +from sentry_sdk.integrations.redis.modules.queries import _set_db_data_on_span +from sentry_sdk.integrations.redis.utils import _parse_rediscluster_command + +from sentry_sdk.utils import capture_internal_exceptions + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Any + from redis import RedisCluster + from redis.asyncio.cluster import ( + RedisCluster as AsyncRedisCluster, + ClusterPipeline as AsyncClusterPipeline, + ) + from sentry_sdk.tracing import Span + + +def _set_async_cluster_db_data( + span: "Span", async_redis_cluster_instance: "AsyncRedisCluster[Any]" +) -> None: + default_node = async_redis_cluster_instance.get_default_node() + if default_node is not None and default_node.connection_kwargs is not None: + _set_db_data_on_span(span, default_node.connection_kwargs) + + +def _set_async_cluster_pipeline_db_data( + span: "Span", async_redis_cluster_pipeline_instance: "AsyncClusterPipeline[Any]" +) -> None: + with capture_internal_exceptions(): + client = getattr(async_redis_cluster_pipeline_instance, "cluster_client", None) + if client is None: + # In older redis-py versions, the AsyncClusterPipeline had a `_client` + # attr but it is private so potentially problematic and mypy does not + # recognize it - see + # https://github.com/redis/redis-py/blame/v5.0.0/redis/asyncio/cluster.py#L1386 + client = ( + async_redis_cluster_pipeline_instance._client # type: ignore[attr-defined] + ) + + _set_async_cluster_db_data( + span, + client, + ) + + +def _set_cluster_db_data( + span: "Span", redis_cluster_instance: "RedisCluster[Any]" +) -> None: + default_node = redis_cluster_instance.get_default_node() + + if default_node is not None: + connection_params = { + "host": default_node.host, + "port": default_node.port, + } + _set_db_data_on_span(span, connection_params) + + +def _patch_redis_cluster() -> None: + """Patches the cluster module on redis SDK (as opposed to rediscluster library)""" + try: + from redis import RedisCluster, cluster + except ImportError: + pass + else: + patch_redis_client( + RedisCluster, + is_cluster=True, + set_db_data_fn=_set_cluster_db_data, + ) + patch_redis_pipeline( + cluster.ClusterPipeline, + is_cluster=True, + get_command_args_fn=_parse_rediscluster_command, + set_db_data_fn=_set_cluster_db_data, + ) + + try: + from redis.asyncio import cluster as async_cluster + except ImportError: + pass + else: + from sentry_sdk.integrations.redis._async_common import ( + patch_redis_async_client, + patch_redis_async_pipeline, + ) + + patch_redis_async_client( + async_cluster.RedisCluster, + is_cluster=True, + set_db_data_fn=_set_async_cluster_db_data, + ) + patch_redis_async_pipeline( + async_cluster.ClusterPipeline, + is_cluster=True, + get_command_args_fn=_parse_rediscluster_command, + set_db_data_fn=_set_async_cluster_pipeline_db_data, + ) diff --git a/sentry_sdk/integrations/redis/redis_py_cluster_legacy.py b/sentry_sdk/integrations/redis/redis_py_cluster_legacy.py new file mode 100644 index 0000000000..3437aa1f2f --- /dev/null +++ b/sentry_sdk/integrations/redis/redis_py_cluster_legacy.py @@ -0,0 +1,49 @@ +""" +Instrumentation for redis-py-cluster +The project redis-py-cluster is EOL and was integrated into redis-py starting from version 4.1.0 (Dec 26, 2021). + +https://github.com/grokzen/redis-py-cluster +""" + +from sentry_sdk.integrations.redis._sync_common import ( + patch_redis_client, + patch_redis_pipeline, +) +from sentry_sdk.integrations.redis.modules.queries import _set_db_data +from sentry_sdk.integrations.redis.utils import _parse_rediscluster_command + + +def _patch_rediscluster() -> None: + try: + import rediscluster # type: ignore + except ImportError: + return + + patch_redis_client( + rediscluster.RedisCluster, + is_cluster=True, + set_db_data_fn=_set_db_data, + ) + + # up to v1.3.6, __version__ attribute is a tuple + # from v2.0.0, __version__ is a string and VERSION a tuple + version = getattr(rediscluster, "VERSION", rediscluster.__version__) + + # StrictRedisCluster was introduced in v0.2.0 and removed in v2.0.0 + # https://github.com/Grokzen/redis-py-cluster/blob/master/docs/release-notes.rst + if (0, 2, 0) < version < (2, 0, 0): + pipeline_cls = rediscluster.pipeline.StrictClusterPipeline + patch_redis_client( + rediscluster.StrictRedisCluster, + is_cluster=True, + set_db_data_fn=_set_db_data, + ) + else: + pipeline_cls = rediscluster.pipeline.ClusterPipeline + + patch_redis_pipeline( + pipeline_cls, + is_cluster=True, + get_command_args_fn=_parse_rediscluster_command, + set_db_data_fn=_set_db_data, + ) diff --git a/sentry_sdk/integrations/redis/utils.py b/sentry_sdk/integrations/redis/utils.py new file mode 100644 index 0000000000..81d544a75a --- /dev/null +++ b/sentry_sdk/integrations/redis/utils.py @@ -0,0 +1,145 @@ +from sentry_sdk.consts import SPANDATA +from sentry_sdk.integrations.redis.consts import ( + _COMMANDS_INCLUDING_SENSITIVE_DATA, + _MAX_NUM_ARGS, + _MAX_NUM_COMMANDS, + _MULTI_KEY_COMMANDS, + _SINGLE_KEY_COMMANDS, +) +from sentry_sdk.scope import should_send_default_pii +from sentry_sdk.utils import SENSITIVE_DATA_SUBSTITUTE + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Any, Optional, Sequence + from sentry_sdk.tracing import Span + + +def _get_safe_command(name: str, args: "Sequence[Any]") -> str: + command_parts = [name] + + name_low = name.lower() + send_default_pii = should_send_default_pii() + + for i, arg in enumerate(args): + if i > _MAX_NUM_ARGS: + break + + if name_low in _COMMANDS_INCLUDING_SENSITIVE_DATA: + command_parts.append(SENSITIVE_DATA_SUBSTITUTE) + continue + + arg_is_the_key = i == 0 + if arg_is_the_key: + command_parts.append(repr(arg)) + else: + if send_default_pii: + command_parts.append(repr(arg)) + else: + command_parts.append(SENSITIVE_DATA_SUBSTITUTE) + + command = " ".join(command_parts) + return command + + +def _safe_decode(key: "Any") -> str: + if isinstance(key, bytes): + try: + return key.decode() + except UnicodeDecodeError: + return "" + + return str(key) + + +def _key_as_string(key: "Any") -> str: + if isinstance(key, (dict, list, tuple)): + key = ", ".join(_safe_decode(x) for x in key) + elif isinstance(key, bytes): + key = _safe_decode(key) + elif key is None: + key = "" + else: + key = str(key) + + return key + + +def _get_safe_key( + method_name: str, + args: "Optional[tuple[Any, ...]]", + kwargs: "Optional[dict[str, Any]]", +) -> "Optional[tuple[str, ...]]": + """ + Gets the key (or keys) from the given method_name. + The method_name could be a redis command or a django caching command + """ + key = None + + if args is not None and method_name.lower() in _MULTI_KEY_COMMANDS: + # for example redis "mget" + key = tuple(args) + + elif args is not None and len(args) >= 1: + # for example django "set_many/get_many" or redis "get" + if isinstance(args[0], (dict, list, tuple)): + key = tuple(args[0]) + else: + key = (args[0],) + + elif kwargs is not None and "key" in kwargs: + # this is a legacy case for older versions of Django + if isinstance(kwargs["key"], (list, tuple)): + if len(kwargs["key"]) > 0: + key = tuple(kwargs["key"]) + else: + if kwargs["key"] is not None: + key = (kwargs["key"],) + + return key + + +def _parse_rediscluster_command(command: "Any") -> "Sequence[Any]": + return command.args + + +def _set_pipeline_data( + span: "Span", + is_cluster: bool, + get_command_args_fn: "Any", + is_transaction: bool, + commands_seq: "Sequence[Any]", +) -> None: + span.set_tag("redis.is_cluster", is_cluster) + span.set_tag("redis.transaction", is_transaction) + + commands = [] + for i, arg in enumerate(commands_seq): + if i >= _MAX_NUM_COMMANDS: + break + + command = get_command_args_fn(arg) + commands.append(_get_safe_command(command[0], command[1:])) + + span.set_data( + "redis.commands", + { + "count": len(commands_seq), + "first_ten": commands, + }, + ) + + +def _set_client_data(span: "Span", is_cluster: bool, name: str, *args: "Any") -> None: + span.set_tag("redis.is_cluster", is_cluster) + if name: + span.set_tag("redis.command", name) + span.set_tag(SPANDATA.DB_OPERATION, name) + + if name and args: + name_low = name.lower() + if (name_low in _SINGLE_KEY_COMMANDS) or ( + name_low in _MULTI_KEY_COMMANDS and len(args) == 1 + ): + span.set_tag("redis.key", args[0]) diff --git a/sentry_sdk/integrations/rq.py b/sentry_sdk/integrations/rq.py index 7f1a79abed..8caf46b171 100644 --- a/sentry_sdk/integrations/rq.py +++ b/sentry_sdk/integrations/rq.py @@ -1,15 +1,14 @@ -from __future__ import absolute_import - import weakref -from sentry_sdk.consts import OP +import sentry_sdk +from sentry_sdk.consts import OP from sentry_sdk.api import continue_trace -from sentry_sdk.hub import Hub -from sentry_sdk.integrations import DidNotEnable, Integration +from sentry_sdk.integrations import _check_minimum_version, DidNotEnable, Integration from sentry_sdk.integrations.logging import ignore_logger -from sentry_sdk.tracing import TRANSACTION_SOURCE_TASK +from sentry_sdk.tracing import TransactionSource from sentry_sdk.utils import ( capture_internal_exceptions, + ensure_integration_enabled, event_from_exception, format_timestamp, parse_version, @@ -24,12 +23,12 @@ except ImportError: raise DidNotEnable("RQ not installed") -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: - from typing import Any, Callable, Dict + from typing import Any, Callable - from sentry_sdk._types import EventProcessor + from sentry_sdk._types import Event, EventProcessor from sentry_sdk.utils import ExcInfo from rq.job import Job @@ -37,33 +36,20 @@ class RqIntegration(Integration): identifier = "rq" + origin = f"auto.queue.{identifier}" @staticmethod - def setup_once(): - # type: () -> None - + def setup_once() -> None: version = parse_version(RQ_VERSION) - - if version is None: - raise DidNotEnable("Unparsable RQ version: {}".format(RQ_VERSION)) - - if version < (0, 6): - raise DidNotEnable("RQ 0.6 or newer is required.") + _check_minimum_version(RqIntegration, version) old_perform_job = Worker.perform_job - def sentry_patched_perform_job(self, job, *args, **kwargs): - # type: (Any, Job, *Queue, **Any) -> bool - hub = Hub.current - integration = hub.get_integration(RqIntegration) - - if integration is None: - return old_perform_job(self, job, *args, **kwargs) - - client = hub.client - assert client is not None - - with hub.push_scope() as scope: + @ensure_integration_enabled(RqIntegration, old_perform_job) + def sentry_patched_perform_job( + self: "Any", job: "Job", *args: "Queue", **kwargs: "Any" + ) -> bool: + with sentry_sdk.new_scope() as scope: scope.clear_breadcrumbs() scope.add_event_processor(_make_event_processor(weakref.ref(job))) @@ -71,14 +57,16 @@ def sentry_patched_perform_job(self, job, *args, **kwargs): job.meta.get("_sentry_trace_headers") or {}, op=OP.QUEUE_TASK_RQ, name="unknown RQ task", - source=TRANSACTION_SOURCE_TASK, + source=TransactionSource.TASK, + origin=RqIntegration.origin, ) with capture_internal_exceptions(): transaction.name = job.func_name - with hub.start_transaction( - transaction, custom_sampling_context={"rq_job": job} + with sentry_sdk.start_transaction( + transaction, + custom_sampling_context={"rq_job": job}, ): rv = old_perform_job(self, job, *args, **kwargs) @@ -86,7 +74,7 @@ def sentry_patched_perform_job(self, job, *args, **kwargs): # We're inside of a forked process and RQ is # about to call `os._exit`. Make sure that our # events get sent out. - client.flush() + sentry_sdk.get_client().flush() return rv @@ -94,12 +82,17 @@ def sentry_patched_perform_job(self, job, *args, **kwargs): old_handle_exception = Worker.handle_exception - def sentry_patched_handle_exception(self, job, *exc_info, **kwargs): - # type: (Worker, Any, *Any, **Any) -> Any - # Note, the order of the `or` here is important, - # because calling `job.is_failed` will change `_status`. - if job._status == JobStatus.FAILED or job.is_failed: - _capture_exception(exc_info) # type: ignore + def sentry_patched_handle_exception( + self: "Worker", job: "Any", *exc_info: "Any", **kwargs: "Any" + ) -> "Any": + retry = ( + hasattr(job, "retries_left") + and job.retries_left + and job.retries_left > 0 + ) + failed = job._status == JobStatus.FAILED or job.is_failed + if failed and not retry: + _capture_exception(exc_info) return old_handle_exception(self, job, *exc_info, **kwargs) @@ -107,14 +100,15 @@ def sentry_patched_handle_exception(self, job, *exc_info, **kwargs): old_enqueue_job = Queue.enqueue_job - def sentry_patched_enqueue_job(self, job, **kwargs): - # type: (Queue, Any, **Any) -> Any - hub = Hub.current - if hub.get_integration(RqIntegration) is not None: - if hub.scope.span is not None: - job.meta["_sentry_trace_headers"] = dict( - hub.iter_trace_propagation_headers() - ) + @ensure_integration_enabled(RqIntegration, old_enqueue_job) + def sentry_patched_enqueue_job( + self: "Queue", job: "Any", **kwargs: "Any" + ) -> "Any": + scope = sentry_sdk.get_current_scope() + if scope.span is not None: + job.meta["_sentry_trace_headers"] = dict( + scope.iter_trace_propagation_headers() + ) return old_enqueue_job(self, job, **kwargs) @@ -123,15 +117,13 @@ def sentry_patched_enqueue_job(self, job, **kwargs): ignore_logger("rq.worker") -def _make_event_processor(weak_job): - # type: (Callable[[], Job]) -> EventProcessor - def event_processor(event, hint): - # type: (Dict[str, Any], Dict[str, Any]) -> Dict[str, Any] +def _make_event_processor(weak_job: "Callable[[], Job]") -> "EventProcessor": + def event_processor(event: "Event", hint: "dict[str, Any]") -> "Event": job = weak_job() if job is not None: with capture_internal_exceptions(): extra = event.setdefault("extra", {}) - extra["rq-job"] = { + rq_job = { "job_id": job.id, "func": job.func_name, "args": job.args, @@ -140,9 +132,11 @@ def event_processor(event, hint): } if job.enqueued_at: - extra["rq-job"]["enqueued_at"] = format_timestamp(job.enqueued_at) + rq_job["enqueued_at"] = format_timestamp(job.enqueued_at) if job.started_at: - extra["rq-job"]["started_at"] = format_timestamp(job.started_at) + rq_job["started_at"] = format_timestamp(job.started_at) + + extra["rq-job"] = rq_job if "exc_info" in hint: with capture_internal_exceptions(): @@ -154,14 +148,8 @@ def event_processor(event, hint): return event_processor -def _capture_exception(exc_info, **kwargs): - # type: (ExcInfo, **Any) -> None - hub = Hub.current - if hub.get_integration(RqIntegration) is None: - return - - # If an integration is there, a client has to be there. - client = hub.client # type: Any +def _capture_exception(exc_info: "ExcInfo", **kwargs: "Any") -> None: + client = sentry_sdk.get_client() event, hint = event_from_exception( exc_info, @@ -169,4 +157,4 @@ def _capture_exception(exc_info, **kwargs): mechanism={"type": "rq", "handled": False}, ) - hub.capture_event(event, hint=hint) + sentry_sdk.capture_event(event, hint=hint) diff --git a/sentry_sdk/integrations/rust_tracing.py b/sentry_sdk/integrations/rust_tracing.py new file mode 100644 index 0000000000..e2b203a286 --- /dev/null +++ b/sentry_sdk/integrations/rust_tracing.py @@ -0,0 +1,283 @@ +""" +This integration ingests tracing data from native extensions written in Rust. + +Using it requires additional setup on the Rust side to accept a +`RustTracingLayer` Python object and register it with the `tracing-subscriber` +using an adapter from the `pyo3-python-tracing-subscriber` crate. For example: +```rust +#[pyfunction] +pub fn initialize_tracing(py_impl: Bound<'_, PyAny>) { + tracing_subscriber::registry() + .with(pyo3_python_tracing_subscriber::PythonCallbackLayerBridge::new(py_impl)) + .init(); +} +``` + +Usage in Python would then look like: +``` +sentry_sdk.init( + dsn=sentry_dsn, + integrations=[ + RustTracingIntegration( + "demo_rust_extension", + demo_rust_extension.initialize_tracing, + event_type_mapping=event_type_mapping, + ) + ], +) +``` + +Each native extension requires its own integration. +""" + +import json +from enum import Enum, auto +from typing import Any, Callable, Dict, Tuple, Optional + +import sentry_sdk +from sentry_sdk.integrations import Integration +from sentry_sdk.scope import should_send_default_pii +from sentry_sdk.tracing import Span as SentrySpan +from sentry_sdk.utils import SENSITIVE_DATA_SUBSTITUTE + +TraceState = Optional[Tuple[Optional[SentrySpan], SentrySpan]] + + +class RustTracingLevel(Enum): + Trace = "TRACE" + Debug = "DEBUG" + Info = "INFO" + Warn = "WARN" + Error = "ERROR" + + +class EventTypeMapping(Enum): + Ignore = auto() + Exc = auto() + Breadcrumb = auto() + Event = auto() + + +def tracing_level_to_sentry_level(level: str) -> "sentry_sdk._types.LogLevelStr": + level = RustTracingLevel(level) + if level in (RustTracingLevel.Trace, RustTracingLevel.Debug): + return "debug" + elif level == RustTracingLevel.Info: + return "info" + elif level == RustTracingLevel.Warn: + return "warning" + elif level == RustTracingLevel.Error: + return "error" + else: + # Better this than crashing + return "info" + + +def extract_contexts(event: "Dict[str, Any]") -> "Dict[str, Any]": + metadata = event.get("metadata", {}) + contexts = {} + + location = {} + for field in ["module_path", "file", "line"]: + if field in metadata: + location[field] = metadata[field] + if len(location) > 0: + contexts["rust_tracing_location"] = location + + fields = {} + for field in metadata.get("fields", []): + fields[field] = event.get(field) + if len(fields) > 0: + contexts["rust_tracing_fields"] = fields + + return contexts + + +def process_event(event: "Dict[str, Any]") -> None: + metadata = event.get("metadata", {}) + + logger = metadata.get("target") + level = tracing_level_to_sentry_level(metadata.get("level")) + message: "sentry_sdk._types.Any" = event.get("message") + contexts = extract_contexts(event) + + sentry_event: "sentry_sdk._types.Event" = { + "logger": logger, + "level": level, + "message": message, + "contexts": contexts, + } + + sentry_sdk.capture_event(sentry_event) + + +def process_exception(event: "Dict[str, Any]") -> None: + process_event(event) + + +def process_breadcrumb(event: "Dict[str, Any]") -> None: + level = tracing_level_to_sentry_level(event.get("metadata", {}).get("level")) + message = event.get("message") + + sentry_sdk.add_breadcrumb(level=level, message=message) + + +def default_span_filter(metadata: "Dict[str, Any]") -> bool: + return RustTracingLevel(metadata.get("level")) in ( + RustTracingLevel.Error, + RustTracingLevel.Warn, + RustTracingLevel.Info, + ) + + +def default_event_type_mapping(metadata: "Dict[str, Any]") -> "EventTypeMapping": + level = RustTracingLevel(metadata.get("level")) + if level == RustTracingLevel.Error: + return EventTypeMapping.Exc + elif level in (RustTracingLevel.Warn, RustTracingLevel.Info): + return EventTypeMapping.Breadcrumb + elif level in (RustTracingLevel.Debug, RustTracingLevel.Trace): + return EventTypeMapping.Ignore + else: + return EventTypeMapping.Ignore + + +class RustTracingLayer: + def __init__( + self, + origin: str, + event_type_mapping: """Callable[ + [Dict[str, Any]], EventTypeMapping + ]""" = default_event_type_mapping, + span_filter: "Callable[[Dict[str, Any]], bool]" = default_span_filter, + include_tracing_fields: "Optional[bool]" = None, + ): + self.origin = origin + self.event_type_mapping = event_type_mapping + self.span_filter = span_filter + self.include_tracing_fields = include_tracing_fields + + def _include_tracing_fields(self) -> bool: + """ + By default, the values of tracing fields are not included in case they + contain PII. A user may override that by passing `True` for the + `include_tracing_fields` keyword argument of this integration or by + setting `send_default_pii` to `True` in their Sentry client options. + """ + return ( + should_send_default_pii() + if self.include_tracing_fields is None + else self.include_tracing_fields + ) + + def on_event(self, event: str, _span_state: "TraceState") -> None: + deserialized_event = json.loads(event) + metadata = deserialized_event.get("metadata", {}) + + event_type = self.event_type_mapping(metadata) + if event_type == EventTypeMapping.Ignore: + return + elif event_type == EventTypeMapping.Exc: + process_exception(deserialized_event) + elif event_type == EventTypeMapping.Breadcrumb: + process_breadcrumb(deserialized_event) + elif event_type == EventTypeMapping.Event: + process_event(deserialized_event) + + def on_new_span(self, attrs: str, span_id: str) -> "TraceState": + attrs = json.loads(attrs) + metadata = attrs.get("metadata", {}) + + if not self.span_filter(metadata): + return None + + module_path = metadata.get("module_path") + name = metadata.get("name") + message = attrs.get("message") + + if message is not None: + sentry_span_name = message + elif module_path is not None and name is not None: + sentry_span_name = f"{module_path}::{name}" # noqa: E231 + elif name is not None: + sentry_span_name = name + else: + sentry_span_name = "" + + kwargs = { + "op": "function", + "name": sentry_span_name, + "origin": self.origin, + } + + scope = sentry_sdk.get_current_scope() + parent_sentry_span = scope.span + if parent_sentry_span: + sentry_span = parent_sentry_span.start_child(**kwargs) + else: + sentry_span = scope.start_span(**kwargs) + + fields = metadata.get("fields", []) + for field in fields: + if self._include_tracing_fields(): + sentry_span.set_data(field, attrs.get(field)) + else: + sentry_span.set_data(field, SENSITIVE_DATA_SUBSTITUTE) + + scope.span = sentry_span + return (parent_sentry_span, sentry_span) + + def on_close(self, span_id: str, span_state: "TraceState") -> None: + if span_state is None: + return + + parent_sentry_span, sentry_span = span_state + sentry_span.finish() + sentry_sdk.get_current_scope().span = parent_sentry_span + + def on_record(self, span_id: str, values: str, span_state: "TraceState") -> None: + if span_state is None: + return + _parent_sentry_span, sentry_span = span_state + + deserialized_values = json.loads(values) + for key, value in deserialized_values.items(): + if self._include_tracing_fields(): + sentry_span.set_data(key, value) + else: + sentry_span.set_data(key, SENSITIVE_DATA_SUBSTITUTE) + + +class RustTracingIntegration(Integration): + """ + Ingests tracing data from a Rust native extension's `tracing` instrumentation. + + If a project uses more than one Rust native extension, each one will need + its own instance of `RustTracingIntegration` with an initializer function + specific to that extension. + + Since all of the setup for this integration requires instance-specific state + which is not available in `setup_once()`, setup instead happens in `__init__()`. + """ + + def __init__( + self, + identifier: str, + initializer: "Callable[[RustTracingLayer], None]", + event_type_mapping: """Callable[ + [Dict[str, Any]], EventTypeMapping + ]""" = default_event_type_mapping, + span_filter: "Callable[[Dict[str, Any]], bool]" = default_span_filter, + include_tracing_fields: "Optional[bool]" = None, + ): + self.identifier = identifier + origin = f"auto.function.rust_tracing.{identifier}" + self.tracing_layer = RustTracingLayer( + origin, event_type_mapping, span_filter, include_tracing_fields + ) + + initializer(self.tracing_layer) + + @staticmethod + def setup_once() -> None: + pass diff --git a/sentry_sdk/integrations/sanic.py b/sentry_sdk/integrations/sanic.py index 53d3cb6c07..9199b76eba 100644 --- a/sentry_sdk/integrations/sanic.py +++ b/sentry_sdk/integrations/sanic.py @@ -1,24 +1,26 @@ import sys import weakref from inspect import isawaitable +from urllib.parse import urlsplit +import sentry_sdk from sentry_sdk import continue_trace -from sentry_sdk._compat import urlparse, reraise from sentry_sdk.consts import OP -from sentry_sdk.hub import Hub -from sentry_sdk.tracing import TRANSACTION_SOURCE_COMPONENT, TRANSACTION_SOURCE_URL +from sentry_sdk.integrations import _check_minimum_version, Integration, DidNotEnable +from sentry_sdk.integrations._wsgi_common import RequestExtractor, _filter_headers +from sentry_sdk.integrations.logging import ignore_logger +from sentry_sdk.tracing import TransactionSource from sentry_sdk.utils import ( capture_internal_exceptions, + ensure_integration_enabled, event_from_exception, HAS_REAL_CONTEXTVARS, CONTEXTVARS_ERROR_MESSAGE, parse_version, + reraise, ) -from sentry_sdk.integrations import Integration, DidNotEnable -from sentry_sdk.integrations._wsgi_common import RequestExtractor, _filter_headers -from sentry_sdk.integrations.logging import ignore_logger -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from collections.abc import Container @@ -26,13 +28,12 @@ from typing import Callable from typing import Optional from typing import Union - from typing import Tuple from typing import Dict from sanic.request import Request, RequestParameters from sanic.response import BaseHTTPResponse - from sentry_sdk._types import Event, EventProcessor, Hint + from sentry_sdk._types import Event, EventProcessor, ExcInfo, Hint from sanic.router import Route try: @@ -56,10 +57,12 @@ class SanicIntegration(Integration): identifier = "sanic" + origin = f"auto.http.{identifier}" version = None - def __init__(self, unsampled_statuses=frozenset({404})): - # type: (Optional[Container[int]]) -> None + def __init__( + self, unsampled_statuses: "Optional[Container[int]]" = frozenset({404}) + ) -> None: """ The unsampled_statuses parameter can be used to specify for which HTTP statuses the transactions should not be sent to Sentry. By default, transactions are sent for all @@ -69,16 +72,9 @@ def __init__(self, unsampled_statuses=frozenset({404})): self._unsampled_statuses = unsampled_statuses or set() @staticmethod - def setup_once(): - # type: () -> None - + def setup_once() -> None: SanicIntegration.version = parse_version(SANIC_VERSION) - - if SanicIntegration.version is None: - raise DidNotEnable("Unparsable Sanic version: {}".format(SANIC_VERSION)) - - if SanicIntegration.version < (0, 8): - raise DidNotEnable("Sanic 0.8 or newer required.") + _check_minimum_version(SanicIntegration, SanicIntegration.version) if not HAS_REAL_CONTEXTVARS: # We better have contextvars or we're going to leak state between @@ -100,7 +96,7 @@ def setup_once(): # https://github.com/huge-success/sanic/issues/1332 ignore_logger("root") - if SanicIntegration.version < (21, 9): + if SanicIntegration.version is not None and SanicIntegration.version < (21, 9): _setup_legacy_sanic() return @@ -108,65 +104,54 @@ def setup_once(): class SanicRequestExtractor(RequestExtractor): - def content_length(self): - # type: () -> int + def content_length(self) -> int: if self.request.body is None: return 0 return len(self.request.body) - def cookies(self): - # type: () -> Dict[str, str] + def cookies(self) -> "Dict[str, str]": return dict(self.request.cookies) - def raw_data(self): - # type: () -> bytes + def raw_data(self) -> bytes: return self.request.body - def form(self): - # type: () -> RequestParameters + def form(self) -> "RequestParameters": return self.request.form - def is_json(self): - # type: () -> bool + def is_json(self) -> bool: raise NotImplementedError() - def json(self): - # type: () -> Optional[Any] + def json(self) -> "Optional[Any]": return self.request.json - def files(self): - # type: () -> RequestParameters + def files(self) -> "RequestParameters": return self.request.files - def size_of_file(self, file): - # type: (Any) -> int + def size_of_file(self, file: "Any") -> int: return len(file.body or ()) -def _setup_sanic(): - # type: () -> None +def _setup_sanic() -> None: Sanic._startup = _startup ErrorHandler.lookup = _sentry_error_handler_lookup -def _setup_legacy_sanic(): - # type: () -> None +def _setup_legacy_sanic() -> None: Sanic.handle_request = _legacy_handle_request Router.get = _legacy_router_get ErrorHandler.lookup = _sentry_error_handler_lookup -async def _startup(self): - # type: (Sanic) -> None +async def _startup(self: "Sanic") -> None: # This happens about as early in the lifecycle as possible, just after the # Request object is created. The body has not yet been consumed. - self.signal("http.lifecycle.request")(_hub_enter) + self.signal("http.lifecycle.request")(_context_enter) # This happens after the handler is complete. In v21.9 this signal is not # dispatched when there is an exception. Therefore we need to close out - # and call _hub_exit from the custom exception handler as well. + # and call _context_exit from the custom exception handler as well. # See https://github.com/sanic-org/sanic/issues/2297 - self.signal("http.lifecycle.response")(_hub_exit) + self.signal("http.lifecycle.response")(_context_exit) # This happens inside of request handling immediately after the route # has been identified by the router. @@ -176,43 +161,41 @@ async def _startup(self): await old_startup(self) -async def _hub_enter(request): - # type: (Request) -> None - hub = Hub.current +async def _context_enter(request: "Request") -> None: request.ctx._sentry_do_integration = ( - hub.get_integration(SanicIntegration) is not None + sentry_sdk.get_client().get_integration(SanicIntegration) is not None ) if not request.ctx._sentry_do_integration: return weak_request = weakref.ref(request) - request.ctx._sentry_hub = Hub(hub) - request.ctx._sentry_hub.__enter__() - - with request.ctx._sentry_hub.configure_scope() as scope: - scope.clear_breadcrumbs() - scope.add_event_processor(_make_request_processor(weak_request)) + request.ctx._sentry_scope = sentry_sdk.isolation_scope() + scope = request.ctx._sentry_scope.__enter__() + scope.clear_breadcrumbs() + scope.add_event_processor(_make_request_processor(weak_request)) transaction = continue_trace( dict(request.headers), op=OP.HTTP_SERVER, # Unless the request results in a 404 error, the name and source will get overwritten in _set_transaction name=request.path, - source=TRANSACTION_SOURCE_URL, + source=TransactionSource.URL, + origin=SanicIntegration.origin, ) - request.ctx._sentry_transaction = request.ctx._sentry_hub.start_transaction( + request.ctx._sentry_transaction = sentry_sdk.start_transaction( transaction ).__enter__() -async def _hub_exit(request, response=None): - # type: (Request, Optional[BaseHTTPResponse]) -> None +async def _context_exit( + request: "Request", response: "Optional[BaseHTTPResponse]" = None +) -> None: with capture_internal_exceptions(): if not request.ctx._sentry_do_integration: return - integration = Hub.current.get_integration(SanicIntegration) # type: Integration + integration = sentry_sdk.get_client().get_integration(SanicIntegration) response_status = None if response is None else response.status @@ -226,34 +209,32 @@ async def _hub_exit(request, response=None): ) request.ctx._sentry_transaction.__exit__(None, None, None) - request.ctx._sentry_hub.__exit__(None, None, None) + request.ctx._sentry_scope.__exit__(None, None, None) -async def _set_transaction(request, route, **_): - # type: (Request, Route, **Any) -> None - hub = Hub.current +async def _set_transaction(request: "Request", route: "Route", **_: "Any") -> None: if request.ctx._sentry_do_integration: with capture_internal_exceptions(): - with hub.configure_scope() as scope: - route_name = route.name.replace(request.app.name, "").strip(".") - scope.set_transaction_name( - route_name, source=TRANSACTION_SOURCE_COMPONENT - ) + scope = sentry_sdk.get_current_scope() + route_name = route.name.replace(request.app.name, "").strip(".") + scope.set_transaction_name(route_name, source=TransactionSource.COMPONENT) -def _sentry_error_handler_lookup(self, exception, *args, **kwargs): - # type: (Any, Exception, *Any, **Any) -> Optional[object] +def _sentry_error_handler_lookup( + self: "Any", exception: Exception, *args: "Any", **kwargs: "Any" +) -> "Optional[object]": _capture_exception(exception) old_error_handler = old_error_handler_lookup(self, exception, *args, **kwargs) if old_error_handler is None: return None - if Hub.current.get_integration(SanicIntegration) is None: + if sentry_sdk.get_client().get_integration(SanicIntegration) is None: return old_error_handler - async def sentry_wrapped_error_handler(request, exception): - # type: (Request, Exception) -> Any + async def sentry_wrapped_error_handler( + request: "Request", exception: Exception + ) -> "Any": try: response = old_error_handler(request, exception) if isawaitable(response): @@ -270,23 +251,22 @@ async def sentry_wrapped_error_handler(request, exception): # As mentioned in previous comment in _startup, this can be removed # after https://github.com/sanic-org/sanic/issues/2297 is resolved if SanicIntegration.version and SanicIntegration.version == (21, 9): - await _hub_exit(request) + await _context_exit(request) return sentry_wrapped_error_handler -async def _legacy_handle_request(self, request, *args, **kwargs): - # type: (Any, Request, *Any, **Any) -> Any - hub = Hub.current - if hub.get_integration(SanicIntegration) is None: - return old_handle_request(self, request, *args, **kwargs) +async def _legacy_handle_request( + self: "Any", request: "Request", *args: "Any", **kwargs: "Any" +) -> "Any": + if sentry_sdk.get_client().get_integration(SanicIntegration) is None: + return await old_handle_request(self, request, *args, **kwargs) weak_request = weakref.ref(request) - with Hub(hub) as hub: - with hub.configure_scope() as scope: - scope.clear_breadcrumbs() - scope.add_event_processor(_make_request_processor(weak_request)) + with sentry_sdk.isolation_scope() as scope: + scope.clear_breadcrumbs() + scope.add_event_processor(_make_request_processor(weak_request)) response = old_handle_request(self, request, *args, **kwargs) if isawaitable(response): @@ -295,61 +275,52 @@ async def _legacy_handle_request(self, request, *args, **kwargs): return response -def _legacy_router_get(self, *args): - # type: (Any, Union[Any, Request]) -> Any +def _legacy_router_get(self: "Any", *args: "Union[Any, Request]") -> "Any": rv = old_router_get(self, *args) - hub = Hub.current - if hub.get_integration(SanicIntegration) is not None: + if sentry_sdk.get_client().get_integration(SanicIntegration) is not None: with capture_internal_exceptions(): - with hub.configure_scope() as scope: - if SanicIntegration.version and SanicIntegration.version >= (21, 3): - # Sanic versions above and including 21.3 append the app name to the - # route name, and so we need to remove it from Route name so the - # transaction name is consistent across all versions - sanic_app_name = self.ctx.app.name - sanic_route = rv[0].name - - if sanic_route.startswith("%s." % sanic_app_name): - # We add a 1 to the len of the sanic_app_name because there is a dot - # that joins app name and the route name - # Format: app_name.route_name - sanic_route = sanic_route[len(sanic_app_name) + 1 :] - - scope.set_transaction_name( - sanic_route, source=TRANSACTION_SOURCE_COMPONENT - ) - else: - scope.set_transaction_name( - rv[0].__name__, source=TRANSACTION_SOURCE_COMPONENT - ) - - return rv + scope = sentry_sdk.get_isolation_scope() + if SanicIntegration.version and SanicIntegration.version >= (21, 3): + # Sanic versions above and including 21.3 append the app name to the + # route name, and so we need to remove it from Route name so the + # transaction name is consistent across all versions + sanic_app_name = self.ctx.app.name + sanic_route = rv[0].name + + if sanic_route.startswith("%s." % sanic_app_name): + # We add a 1 to the len of the sanic_app_name because there is a dot + # that joins app name and the route name + # Format: app_name.route_name + sanic_route = sanic_route[len(sanic_app_name) + 1 :] + scope.set_transaction_name( + sanic_route, source=TransactionSource.COMPONENT + ) + else: + scope.set_transaction_name( + rv[0].__name__, source=TransactionSource.COMPONENT + ) -def _capture_exception(exception): - # type: (Union[Tuple[Optional[type], Optional[BaseException], Any], BaseException]) -> None - hub = Hub.current - integration = hub.get_integration(SanicIntegration) - if integration is None: - return + return rv - # If an integration is there, a client has to be there. - client = hub.client # type: Any +@ensure_integration_enabled(SanicIntegration) +def _capture_exception(exception: "Union[ExcInfo, BaseException]") -> None: with capture_internal_exceptions(): event, hint = event_from_exception( exception, - client_options=client.options, + client_options=sentry_sdk.get_client().options, mechanism={"type": "sanic", "handled": False}, ) - hub.capture_event(event, hint=hint) + if hint and hasattr(hint["exc_info"][0], "quiet") and hint["exc_info"][0].quiet: + return + + sentry_sdk.capture_event(event, hint=hint) -def _make_request_processor(weak_request): - # type: (Callable[[], Request]) -> EventProcessor - def sanic_processor(event, hint): - # type: (Event, Optional[Hint]) -> Optional[Event] +def _make_request_processor(weak_request: "Callable[[], Request]") -> "EventProcessor": + def sanic_processor(event: "Event", hint: "Optional[Hint]") -> "Optional[Event]": try: if hint and issubclass(hint["exc_info"][0], SanicException): return None @@ -365,7 +336,7 @@ def sanic_processor(event, hint): extractor.extract_into_event(event) request_info = event["request"] - urlparts = urlparse.urlsplit(request.url) + urlparts = urlsplit(request.url) request_info["url"] = "%s://%s%s" % ( urlparts.scheme, diff --git a/sentry_sdk/integrations/serverless.py b/sentry_sdk/integrations/serverless.py index 534034547a..16f91b28ae 100644 --- a/sentry_sdk/integrations/serverless.py +++ b/sentry_sdk/integrations/serverless.py @@ -1,53 +1,39 @@ import sys +from functools import wraps +from typing import TYPE_CHECKING -from sentry_sdk.hub import Hub -from sentry_sdk.utils import event_from_exception -from sentry_sdk._compat import reraise -from sentry_sdk._functools import wraps - - -from sentry_sdk._types import TYPE_CHECKING +import sentry_sdk +from sentry_sdk.utils import event_from_exception, reraise if TYPE_CHECKING: - from typing import Any - from typing import Callable - from typing import TypeVar - from typing import Union - from typing import Optional - - from typing import overload + from typing import Any, Callable, Optional, TypeVar, Union, overload F = TypeVar("F", bound=Callable[..., Any]) else: - def overload(x): - # type: (F) -> F + def overload(x: "F") -> "F": return x @overload -def serverless_function(f, flush=True): - # type: (F, bool) -> F +def serverless_function(f: "F", flush: bool = True) -> "F": pass @overload -def serverless_function(f=None, flush=True): # noqa: F811 - # type: (None, bool) -> Callable[[F], F] +def serverless_function(f: None = None, flush: bool = True) -> "Callable[[F], F]": # noqa: F811 pass -def serverless_function(f=None, flush=True): # noqa - # type: (Optional[F], bool) -> Union[F, Callable[[F], F]] - def wrapper(f): - # type: (F) -> F +def serverless_function( # noqa + f: "Optional[F]" = None, flush: bool = True +) -> "Union[F, Callable[[F], F]]": + def wrapper(f: "F") -> "F": @wraps(f) - def inner(*args, **kwargs): - # type: (*Any, **Any) -> Any - with Hub(Hub.current) as hub: - with hub.configure_scope() as scope: - scope.clear_breadcrumbs() + def inner(*args: "Any", **kwargs: "Any") -> "Any": + with sentry_sdk.isolation_scope() as scope: + scope.clear_breadcrumbs() try: return f(*args, **kwargs) @@ -55,7 +41,7 @@ def inner(*args, **kwargs): _capture_and_reraise() finally: if flush: - _flush_client() + sentry_sdk.flush() return inner # type: ignore @@ -65,21 +51,15 @@ def inner(*args, **kwargs): return wrapper(f) -def _capture_and_reraise(): - # type: () -> None +def _capture_and_reraise() -> None: exc_info = sys.exc_info() - hub = Hub.current - if hub.client is not None: + client = sentry_sdk.get_client() + if client.is_active(): event, hint = event_from_exception( exc_info, - client_options=hub.client.options, + client_options=client.options, mechanism={"type": "serverless", "handled": False}, ) - hub.capture_event(event, hint=hint) + sentry_sdk.capture_event(event, hint=hint) reraise(*exc_info) - - -def _flush_client(): - # type: () -> None - return Hub.current.flush() diff --git a/sentry_sdk/integrations/socket.py b/sentry_sdk/integrations/socket.py index 7a4e358185..472b909d28 100644 --- a/sentry_sdk/integrations/socket.py +++ b/sentry_sdk/integrations/socket.py @@ -1,7 +1,6 @@ -from __future__ import absolute_import - import socket -from sentry_sdk import Hub + +import sentry_sdk from sentry_sdk._types import MYPY from sentry_sdk.consts import OP from sentry_sdk.integrations import Integration @@ -15,10 +14,10 @@ class SocketIntegration(Integration): identifier = "socket" + origin = f"auto.socket.{identifier}" @staticmethod - def setup_once(): - # type: () -> None + def setup_once() -> None: """ patches two of the most used functions of socket: create_connection and getaddrinfo(dns resolver) """ @@ -26,38 +25,39 @@ def setup_once(): _patch_getaddrinfo() -def _get_span_description(host, port): - # type: (Union[bytes, str, None], Union[str, int, None]) -> str - +def _get_span_description( + host: "Union[bytes, str, None]", port: "Union[bytes, str, int, None]" +) -> str: try: host = host.decode() # type: ignore except (UnicodeDecodeError, AttributeError): pass - description = "%s:%s" % (host, port) # type: ignore + try: + port = port.decode() # type: ignore + except (UnicodeDecodeError, AttributeError): + pass + description = "%s:%s" % (host, port) # type: ignore return description -def _patch_create_connection(): - # type: () -> None +def _patch_create_connection() -> None: real_create_connection = socket.create_connection def create_connection( - address, - timeout=socket._GLOBAL_DEFAULT_TIMEOUT, # type: ignore - source_address=None, - ): - # type: (Tuple[Optional[str], int], Optional[float], Optional[Tuple[Union[bytearray, bytes, str], int]])-> socket.socket - hub = Hub.current - if hub.get_integration(SocketIntegration) is None: - return real_create_connection( - address=address, timeout=timeout, source_address=source_address - ) - - with hub.start_span( + address: "Tuple[Optional[str], int]", + timeout: "Optional[float]" = socket._GLOBAL_DEFAULT_TIMEOUT, # type: ignore + source_address: "Optional[Tuple[Union[bytearray, bytes, str], int]]" = None, + ) -> "socket.socket": + integration = sentry_sdk.get_client().get_integration(SocketIntegration) + if integration is None: + return real_create_connection(address, timeout, source_address) + + with sentry_sdk.start_span( op=OP.SOCKET_CONNECTION, - description=_get_span_description(address[0], address[1]), + name=_get_span_description(address[0], address[1]), + origin=SocketIntegration.origin, ) as span: span.set_data("address", address) span.set_data("timeout", timeout) @@ -70,22 +70,29 @@ def create_connection( socket.create_connection = create_connection # type: ignore -def _patch_getaddrinfo(): - # type: () -> None +def _patch_getaddrinfo() -> None: real_getaddrinfo = socket.getaddrinfo - def getaddrinfo(host, port, family=0, type=0, proto=0, flags=0): - # type: (Union[bytes, str, None], Union[str, int, None], int, int, int, int) -> List[Tuple[AddressFamily, SocketKind, int, str, Union[Tuple[str, int], Tuple[str, int, int, int]]]] - hub = Hub.current - if hub.get_integration(SocketIntegration) is None: + def getaddrinfo( + host: "Union[bytes, str, None]", + port: "Union[bytes, str, int, None]", + family: int = 0, + type: int = 0, + proto: int = 0, + flags: int = 0, + ) -> "List[Tuple[AddressFamily, SocketKind, int, str, Union[Tuple[str, int], Tuple[str, int, int, int], Tuple[int, bytes]]]]": + integration = sentry_sdk.get_client().get_integration(SocketIntegration) + if integration is None: return real_getaddrinfo(host, port, family, type, proto, flags) - with hub.start_span( - op=OP.SOCKET_DNS, description=_get_span_description(host, port) + with sentry_sdk.start_span( + op=OP.SOCKET_DNS, + name=_get_span_description(host, port), + origin=SocketIntegration.origin, ) as span: span.set_data("host", host) span.set_data("port", port) return real_getaddrinfo(host, port, family, type, proto, flags) - socket.getaddrinfo = getaddrinfo # type: ignore + socket.getaddrinfo = getaddrinfo diff --git a/sentry_sdk/integrations/spark/spark_driver.py b/sentry_sdk/integrations/spark/spark_driver.py index b3085fc4af..5ce8102853 100644 --- a/sentry_sdk/integrations/spark/spark_driver.py +++ b/sentry_sdk/integrations/spark/spark_driver.py @@ -1,28 +1,26 @@ -from sentry_sdk import configure_scope -from sentry_sdk.hub import Hub +import sentry_sdk from sentry_sdk.integrations import Integration -from sentry_sdk.utils import capture_internal_exceptions +from sentry_sdk.utils import capture_internal_exceptions, ensure_integration_enabled -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any from typing import Optional from sentry_sdk._types import Event, Hint + from pyspark import SparkContext class SparkIntegration(Integration): identifier = "spark" @staticmethod - def setup_once(): - # type: () -> None - patch_spark_context_init() + def setup_once() -> None: + _setup_sentry_tracing() -def _set_app_properties(): - # type: () -> None +def _set_app_properties() -> None: """ Set properties in driver that propagate to worker processes, allowing for workers to have access to those properties. This allows worker integration to have access to app_name and application_id. @@ -31,14 +29,17 @@ def _set_app_properties(): spark_context = SparkContext._active_spark_context if spark_context: - spark_context.setLocalProperty("sentry_app_name", spark_context.appName) spark_context.setLocalProperty( - "sentry_application_id", spark_context.applicationId + "sentry_app_name", + spark_context.appName, + ) + spark_context.setLocalProperty( + "sentry_application_id", + spark_context.applicationId, ) -def _start_sentry_listener(sc): - # type: (Any) -> None +def _start_sentry_listener(sc: "SparkContext") -> None: """ Start java gateway server to add custom `SparkListener` """ @@ -50,158 +51,145 @@ def _start_sentry_listener(sc): sc._jsc.sc().addSparkListener(listener) -def patch_spark_context_init(): - # type: () -> None - from pyspark import SparkContext +def _add_event_processor(sc: "SparkContext") -> None: + scope = sentry_sdk.get_isolation_scope() - spark_context_init = SparkContext._do_init + @scope.add_event_processor + def process_event(event: "Event", hint: "Hint") -> "Optional[Event]": + with capture_internal_exceptions(): + if sentry_sdk.get_client().get_integration(SparkIntegration) is None: + return event - def _sentry_patched_spark_context_init(self, *args, **kwargs): - # type: (SparkContext, *Any, **Any) -> Optional[Any] - init = spark_context_init(self, *args, **kwargs) + if sc._active_spark_context is None: + return event - if Hub.current.get_integration(SparkIntegration) is None: - return init + event.setdefault("user", {}).setdefault("id", sc.sparkUser()) - _start_sentry_listener(self) - _set_app_properties() + event.setdefault("tags", {}).setdefault( + "executor.id", sc._conf.get("spark.executor.id") + ) + event["tags"].setdefault( + "spark-submit.deployMode", + sc._conf.get("spark.submit.deployMode"), + ) + event["tags"].setdefault("driver.host", sc._conf.get("spark.driver.host")) + event["tags"].setdefault("driver.port", sc._conf.get("spark.driver.port")) + event["tags"].setdefault("spark_version", sc.version) + event["tags"].setdefault("app_name", sc.appName) + event["tags"].setdefault("application_id", sc.applicationId) + event["tags"].setdefault("master", sc.master) + event["tags"].setdefault("spark_home", sc.sparkHome) - with configure_scope() as scope: - - @scope.add_event_processor - def process_event(event, hint): - # type: (Event, Hint) -> Optional[Event] - with capture_internal_exceptions(): - if Hub.current.get_integration(SparkIntegration) is None: - return event - - event.setdefault("user", {}).setdefault("id", self.sparkUser()) - - event.setdefault("tags", {}).setdefault( - "executor.id", self._conf.get("spark.executor.id") - ) - event["tags"].setdefault( - "spark-submit.deployMode", - self._conf.get("spark.submit.deployMode"), - ) - event["tags"].setdefault( - "driver.host", self._conf.get("spark.driver.host") - ) - event["tags"].setdefault( - "driver.port", self._conf.get("spark.driver.port") - ) - event["tags"].setdefault("spark_version", self.version) - event["tags"].setdefault("app_name", self.appName) - event["tags"].setdefault("application_id", self.applicationId) - event["tags"].setdefault("master", self.master) - event["tags"].setdefault("spark_home", self.sparkHome) - - event.setdefault("extra", {}).setdefault("web_url", self.uiWebUrl) + event.setdefault("extra", {}).setdefault("web_url", sc.uiWebUrl) - return event + return event + + +def _activate_integration(sc: "SparkContext") -> None: + _start_sentry_listener(sc) + _set_app_properties() + _add_event_processor(sc) + + +def _patch_spark_context_init() -> None: + from pyspark import SparkContext + + spark_context_init = SparkContext._do_init - return init + @ensure_integration_enabled(SparkIntegration, spark_context_init) + def _sentry_patched_spark_context_init( + self: "SparkContext", *args: "Any", **kwargs: "Any" + ) -> "Optional[Any]": + rv = spark_context_init(self, *args, **kwargs) + _activate_integration(self) + return rv SparkContext._do_init = _sentry_patched_spark_context_init -class SparkListener(object): - def onApplicationEnd(self, applicationEnd): # noqa: N802,N803 - # type: (Any) -> None +def _setup_sentry_tracing() -> None: + from pyspark import SparkContext + + if SparkContext._active_spark_context is not None: + _activate_integration(SparkContext._active_spark_context) + return + _patch_spark_context_init() + + +class SparkListener: + def onApplicationEnd(self, applicationEnd: "Any") -> None: # noqa: N802,N803 pass - def onApplicationStart(self, applicationStart): # noqa: N802,N803 - # type: (Any) -> None + def onApplicationStart(self, applicationStart: "Any") -> None: # noqa: N802,N803 pass - def onBlockManagerAdded(self, blockManagerAdded): # noqa: N802,N803 - # type: (Any) -> None + def onBlockManagerAdded(self, blockManagerAdded: "Any") -> None: # noqa: N802,N803 pass - def onBlockManagerRemoved(self, blockManagerRemoved): # noqa: N802,N803 - # type: (Any) -> None + def onBlockManagerRemoved(self, blockManagerRemoved: "Any") -> None: # noqa: N802,N803 pass - def onBlockUpdated(self, blockUpdated): # noqa: N802,N803 - # type: (Any) -> None + def onBlockUpdated(self, blockUpdated: "Any") -> None: # noqa: N802,N803 pass - def onEnvironmentUpdate(self, environmentUpdate): # noqa: N802,N803 - # type: (Any) -> None + def onEnvironmentUpdate(self, environmentUpdate: "Any") -> None: # noqa: N802,N803 pass - def onExecutorAdded(self, executorAdded): # noqa: N802,N803 - # type: (Any) -> None + def onExecutorAdded(self, executorAdded: "Any") -> None: # noqa: N802,N803 pass - def onExecutorBlacklisted(self, executorBlacklisted): # noqa: N802,N803 - # type: (Any) -> None + def onExecutorBlacklisted(self, executorBlacklisted: "Any") -> None: # noqa: N802,N803 pass def onExecutorBlacklistedForStage( # noqa: N802 - self, executorBlacklistedForStage # noqa: N803 - ): - # type: (Any) -> None + self, + executorBlacklistedForStage: "Any", # noqa: N803 + ) -> None: pass - def onExecutorMetricsUpdate(self, executorMetricsUpdate): # noqa: N802,N803 - # type: (Any) -> None + def onExecutorMetricsUpdate(self, executorMetricsUpdate: "Any") -> None: # noqa: N802,N803 pass - def onExecutorRemoved(self, executorRemoved): # noqa: N802,N803 - # type: (Any) -> None + def onExecutorRemoved(self, executorRemoved: "Any") -> None: # noqa: N802,N803 pass - def onJobEnd(self, jobEnd): # noqa: N802,N803 - # type: (Any) -> None + def onJobEnd(self, jobEnd: "Any") -> None: # noqa: N802,N803 pass - def onJobStart(self, jobStart): # noqa: N802,N803 - # type: (Any) -> None + def onJobStart(self, jobStart: "Any") -> None: # noqa: N802,N803 pass - def onNodeBlacklisted(self, nodeBlacklisted): # noqa: N802,N803 - # type: (Any) -> None + def onNodeBlacklisted(self, nodeBlacklisted: "Any") -> None: # noqa: N802,N803 pass - def onNodeBlacklistedForStage(self, nodeBlacklistedForStage): # noqa: N802,N803 - # type: (Any) -> None + def onNodeBlacklistedForStage(self, nodeBlacklistedForStage: "Any") -> None: # noqa: N802,N803 pass - def onNodeUnblacklisted(self, nodeUnblacklisted): # noqa: N802,N803 - # type: (Any) -> None + def onNodeUnblacklisted(self, nodeUnblacklisted: "Any") -> None: # noqa: N802,N803 pass - def onOtherEvent(self, event): # noqa: N802,N803 - # type: (Any) -> None + def onOtherEvent(self, event: "Any") -> None: # noqa: N802,N803 pass - def onSpeculativeTaskSubmitted(self, speculativeTask): # noqa: N802,N803 - # type: (Any) -> None + def onSpeculativeTaskSubmitted(self, speculativeTask: "Any") -> None: # noqa: N802,N803 pass - def onStageCompleted(self, stageCompleted): # noqa: N802,N803 - # type: (Any) -> None + def onStageCompleted(self, stageCompleted: "Any") -> None: # noqa: N802,N803 pass - def onStageSubmitted(self, stageSubmitted): # noqa: N802,N803 - # type: (Any) -> None + def onStageSubmitted(self, stageSubmitted: "Any") -> None: # noqa: N802,N803 pass - def onTaskEnd(self, taskEnd): # noqa: N802,N803 - # type: (Any) -> None + def onTaskEnd(self, taskEnd: "Any") -> None: # noqa: N802,N803 pass - def onTaskGettingResult(self, taskGettingResult): # noqa: N802,N803 - # type: (Any) -> None + def onTaskGettingResult(self, taskGettingResult: "Any") -> None: # noqa: N802,N803 pass - def onTaskStart(self, taskStart): # noqa: N802,N803 - # type: (Any) -> None + def onTaskStart(self, taskStart: "Any") -> None: # noqa: N802,N803 pass - def onUnpersistRDD(self, unpersistRDD): # noqa: N802,N803 - # type: (Any) -> None + def onUnpersistRDD(self, unpersistRDD: "Any") -> None: # noqa: N802,N803 pass class Java: @@ -209,18 +197,24 @@ class Java: class SentryListener(SparkListener): - def __init__(self): - # type: () -> None - self.hub = Hub.current + def _add_breadcrumb( + self, + level: str, + message: str, + data: "Optional[dict[str, Any]]" = None, + ) -> None: + sentry_sdk.get_isolation_scope().add_breadcrumb( + level=level, message=message, data=data + ) + + def onJobStart(self, jobStart: "Any") -> None: # noqa: N802,N803 + sentry_sdk.get_isolation_scope().clear_breadcrumbs() - def onJobStart(self, jobStart): # noqa: N802,N803 - # type: (Any) -> None message = "Job {} Started".format(jobStart.jobId()) - self.hub.add_breadcrumb(level="info", message=message) + self._add_breadcrumb(level="info", message=message) _set_app_properties() - def onJobEnd(self, jobEnd): # noqa: N802,N803 - # type: (Any) -> None + def onJobEnd(self, jobEnd: "Any") -> None: # noqa: N802,N803 level = "" message = "" data = {"result": jobEnd.jobResult().toString()} @@ -232,24 +226,31 @@ def onJobEnd(self, jobEnd): # noqa: N802,N803 level = "warning" message = "Job {} Failed".format(jobEnd.jobId()) - self.hub.add_breadcrumb(level=level, message=message, data=data) + self._add_breadcrumb(level=level, message=message, data=data) - def onStageSubmitted(self, stageSubmitted): # noqa: N802,N803 - # type: (Any) -> None + def onStageSubmitted(self, stageSubmitted: "Any") -> None: # noqa: N802,N803 stage_info = stageSubmitted.stageInfo() message = "Stage {} Submitted".format(stage_info.stageId()) - data = {"attemptId": stage_info.attemptId(), "name": stage_info.name()} - self.hub.add_breadcrumb(level="info", message=message, data=data) + + data = {"name": stage_info.name()} + attempt_id = _get_attempt_id(stage_info) + if attempt_id is not None: + data["attemptId"] = attempt_id + + self._add_breadcrumb(level="info", message=message, data=data) _set_app_properties() - def onStageCompleted(self, stageCompleted): # noqa: N802,N803 - # type: (Any) -> None + def onStageCompleted(self, stageCompleted: "Any") -> None: # noqa: N802,N803 from py4j.protocol import Py4JJavaError # type: ignore stage_info = stageCompleted.stageInfo() message = "" level = "" - data = {"attemptId": stage_info.attemptId(), "name": stage_info.name()} + + data = {"name": stage_info.name()} + attempt_id = _get_attempt_id(stage_info) + if attempt_id is not None: + data["attemptId"] = attempt_id # Have to Try Except because stageInfo.failureReason() is typed with Scala Option try: @@ -260,4 +261,18 @@ def onStageCompleted(self, stageCompleted): # noqa: N802,N803 message = "Stage {} Completed".format(stage_info.stageId()) level = "info" - self.hub.add_breadcrumb(level=level, message=message, data=data) + self._add_breadcrumb(level=level, message=message, data=data) + + +def _get_attempt_id(stage_info: "Any") -> "Optional[int]": + try: + return stage_info.attemptId() + except Exception: + pass + + try: + return stage_info.attemptNumber() + except Exception: + pass + + return None diff --git a/sentry_sdk/integrations/spark/spark_worker.py b/sentry_sdk/integrations/spark/spark_worker.py index cd4eb0f28b..f1dffdf50b 100644 --- a/sentry_sdk/integrations/spark/spark_worker.py +++ b/sentry_sdk/integrations/spark/spark_worker.py @@ -1,9 +1,6 @@ -from __future__ import absolute_import - import sys -from sentry_sdk import configure_scope -from sentry_sdk.hub import Hub +import sentry_sdk from sentry_sdk.integrations import Integration from sentry_sdk.utils import ( capture_internal_exceptions, @@ -13,7 +10,7 @@ event_hint_with_exc_info, ) -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any @@ -26,18 +23,14 @@ class SparkWorkerIntegration(Integration): identifier = "spark_worker" @staticmethod - def setup_once(): - # type: () -> None + def setup_once() -> None: import pyspark.daemon as original_daemon original_daemon.worker_main = _sentry_worker_main -def _capture_exception(exc_info, hub): - # type: (ExcInfo, Hub) -> None - client = hub.client - - client_options = client.options # type: ignore +def _capture_exception(exc_info: "ExcInfo") -> None: + client = sentry_sdk.get_client() mechanism = {"type": "spark", "handled": False} @@ -51,74 +44,68 @@ def _capture_exception(exc_info, hub): if exc_type not in (SystemExit, EOFError, ConnectionResetError): rv.append( single_exception_from_error_tuple( - exc_type, exc_value, tb, client_options, mechanism + exc_type, exc_value, tb, client.options, mechanism ) ) if rv: rv.reverse() hint = event_hint_with_exc_info(exc_info) - event = {"level": "error", "exception": {"values": rv}} + event: "Event" = {"level": "error", "exception": {"values": rv}} _tag_task_context() - hub.capture_event(event, hint=hint) + sentry_sdk.capture_event(event, hint=hint) -def _tag_task_context(): - # type: () -> None +def _tag_task_context() -> None: from pyspark.taskcontext import TaskContext - with configure_scope() as scope: + scope = sentry_sdk.get_isolation_scope() - @scope.add_event_processor - def process_event(event, hint): - # type: (Event, Hint) -> Optional[Event] - with capture_internal_exceptions(): - integration = Hub.current.get_integration(SparkWorkerIntegration) - task_context = TaskContext.get() + @scope.add_event_processor + def process_event(event: "Event", hint: "Hint") -> "Optional[Event]": + with capture_internal_exceptions(): + integration = sentry_sdk.get_client().get_integration( + SparkWorkerIntegration + ) + task_context = TaskContext.get() - if integration is None or task_context is None: - return event + if integration is None or task_context is None: + return event - event.setdefault("tags", {}).setdefault( - "stageId", str(task_context.stageId()) - ) - event["tags"].setdefault("partitionId", str(task_context.partitionId())) - event["tags"].setdefault( - "attemptNumber", str(task_context.attemptNumber()) - ) - event["tags"].setdefault( - "taskAttemptId", str(task_context.taskAttemptId()) - ) + event.setdefault("tags", {}).setdefault( + "stageId", str(task_context.stageId()) + ) + event["tags"].setdefault("partitionId", str(task_context.partitionId())) + event["tags"].setdefault("attemptNumber", str(task_context.attemptNumber())) + event["tags"].setdefault("taskAttemptId", str(task_context.taskAttemptId())) - if task_context._localProperties: - if "sentry_app_name" in task_context._localProperties: - event["tags"].setdefault( - "app_name", task_context._localProperties["sentry_app_name"] - ) - event["tags"].setdefault( - "application_id", - task_context._localProperties["sentry_application_id"], - ) + if task_context._localProperties: + if "sentry_app_name" in task_context._localProperties: + event["tags"].setdefault( + "app_name", task_context._localProperties["sentry_app_name"] + ) + event["tags"].setdefault( + "application_id", + task_context._localProperties["sentry_application_id"], + ) - if "callSite.short" in task_context._localProperties: - event.setdefault("extra", {}).setdefault( - "callSite", task_context._localProperties["callSite.short"] - ) + if "callSite.short" in task_context._localProperties: + event.setdefault("extra", {}).setdefault( + "callSite", task_context._localProperties["callSite.short"] + ) - return event + return event -def _sentry_worker_main(*args, **kwargs): - # type: (*Optional[Any], **Optional[Any]) -> None +def _sentry_worker_main(*args: "Optional[Any]", **kwargs: "Optional[Any]") -> None: import pyspark.worker as original_worker try: original_worker.main(*args, **kwargs) except SystemExit: - if Hub.current.get_integration(SparkWorkerIntegration) is not None: - hub = Hub.current + if sentry_sdk.get_client().get_integration(SparkWorkerIntegration) is not None: exc_info = sys.exc_info() with capture_internal_exceptions(): - _capture_exception(exc_info, hub) + _capture_exception(exc_info) diff --git a/sentry_sdk/integrations/sqlalchemy.py b/sentry_sdk/integrations/sqlalchemy.py index d1a47f495d..7d3ed95373 100644 --- a/sentry_sdk/integrations/sqlalchemy.py +++ b/sentry_sdk/integrations/sqlalchemy.py @@ -1,14 +1,11 @@ -from __future__ import absolute_import - -from sentry_sdk._compat import text_type -from sentry_sdk._types import TYPE_CHECKING -from sentry_sdk.consts import SPANDATA -from sentry_sdk.db.explain_plan.sqlalchemy import attach_explain_plan_to_span -from sentry_sdk.hub import Hub -from sentry_sdk.integrations import Integration, DidNotEnable -from sentry_sdk.tracing_utils import record_sql_queries - -from sentry_sdk.utils import parse_version +from sentry_sdk.consts import SPANSTATUS, SPANDATA +from sentry_sdk.integrations import _check_minimum_version, Integration, DidNotEnable +from sentry_sdk.tracing_utils import add_query_source, record_sql_queries +from sentry_sdk.utils import ( + capture_internal_exceptions, + ensure_integration_enabled, + parse_version, +) try: from sqlalchemy.engine import Engine # type: ignore @@ -17,6 +14,8 @@ except ImportError: raise DidNotEnable("SQLAlchemy not installed.") +from typing import TYPE_CHECKING + if TYPE_CHECKING: from typing import Any from typing import ContextManager @@ -27,41 +26,35 @@ class SqlalchemyIntegration(Integration): identifier = "sqlalchemy" + origin = f"auto.db.{identifier}" @staticmethod - def setup_once(): - # type: () -> None - + def setup_once() -> None: version = parse_version(SQLALCHEMY_VERSION) - - if version is None: - raise DidNotEnable( - "Unparsable SQLAlchemy version: {}".format(SQLALCHEMY_VERSION) - ) - - if version < (1, 2): - raise DidNotEnable("SQLAlchemy 1.2 or newer required.") + _check_minimum_version(SqlalchemyIntegration, version) listen(Engine, "before_cursor_execute", _before_cursor_execute) listen(Engine, "after_cursor_execute", _after_cursor_execute) listen(Engine, "handle_error", _handle_error) +@ensure_integration_enabled(SqlalchemyIntegration) def _before_cursor_execute( - conn, cursor, statement, parameters, context, executemany, *args -): - # type: (Any, Any, Any, Any, Any, bool, *Any) -> None - hub = Hub.current - if hub.get_integration(SqlalchemyIntegration) is None: - return - + conn: "Any", + cursor: "Any", + statement: "Any", + parameters: "Any", + context: "Any", + executemany: bool, + *args: "Any", +) -> None: ctx_mgr = record_sql_queries( - hub, cursor, statement, parameters, paramstyle=context and context.dialect and context.dialect.paramstyle or None, executemany=executemany, + span_origin=SqlalchemyIntegration.origin, ) context._sentry_sql_span_manager = ctx_mgr @@ -69,47 +62,48 @@ def _before_cursor_execute( if span is not None: _set_db_data(span, conn) - if hub.client: - options = hub.client.options["_experiments"].get("attach_explain_plans") - if options is not None: - attach_explain_plan_to_span( - span, - conn, - statement, - parameters, - options, - ) context._sentry_sql_span = span -def _after_cursor_execute(conn, cursor, statement, parameters, context, *args): - # type: (Any, Any, Any, Any, Any, *Any) -> None - ctx_mgr = getattr( +@ensure_integration_enabled(SqlalchemyIntegration) +def _after_cursor_execute( + conn: "Any", + cursor: "Any", + statement: "Any", + parameters: "Any", + context: "Any", + *args: "Any", +) -> None: + ctx_mgr: "Optional[ContextManager[Any]]" = getattr( context, "_sentry_sql_span_manager", None - ) # type: Optional[ContextManager[Any]] + ) if ctx_mgr is not None: context._sentry_sql_span_manager = None ctx_mgr.__exit__(None, None, None) + span: "Optional[Span]" = getattr(context, "_sentry_sql_span", None) + if span is not None: + with capture_internal_exceptions(): + add_query_source(span) + -def _handle_error(context, *args): - # type: (Any, *Any) -> None +def _handle_error(context: "Any", *args: "Any") -> None: execution_context = context.execution_context if execution_context is None: return - span = getattr(execution_context, "_sentry_sql_span", None) # type: Optional[Span] + span: "Optional[Span]" = getattr(execution_context, "_sentry_sql_span", None) if span is not None: - span.set_status("internal_error") + span.set_status(SPANSTATUS.INTERNAL_ERROR) # _after_cursor_execute does not get called for crashing SQL stmts. Judging # from SQLAlchemy codebase it does seem like any error coming into this # handler is going to be fatal. - ctx_mgr = getattr( + ctx_mgr: "Optional[ContextManager[Any]]" = getattr( execution_context, "_sentry_sql_span_manager", None - ) # type: Optional[ContextManager[Any]] + ) if ctx_mgr is not None: execution_context._sentry_sql_span_manager = None @@ -117,9 +111,8 @@ def _handle_error(context, *args): # See: https://docs.sqlalchemy.org/en/20/dialects/index.html -def _get_db_system(name): - # type: (str) -> Optional[str] - name = text_type(name) +def _get_db_system(name: str) -> "Optional[str]": + name = str(name) if "sqlite" in name: return "sqlite" @@ -139,12 +132,14 @@ def _get_db_system(name): return None -def _set_db_data(span, conn): - # type: (Span, Any) -> None +def _set_db_data(span: "Span", conn: "Any") -> None: db_system = _get_db_system(conn.engine.name) if db_system is not None: span.set_data(SPANDATA.DB_SYSTEM, db_system) + if conn.engine.url is None: + return + db_name = conn.engine.url.database if db_name is not None: span.set_data(SPANDATA.DB_NAME, db_name) diff --git a/sentry_sdk/integrations/starlette.py b/sentry_sdk/integrations/starlette.py index ed95c757f1..0b797ebcde 100644 --- a/sentry_sdk/integrations/starlette.py +++ b/sentry_sdk/integrations/starlette.py @@ -1,37 +1,44 @@ -from __future__ import absolute_import - import asyncio import functools +import warnings +from collections.abc import Set from copy import deepcopy +from json import JSONDecodeError -from sentry_sdk._compat import iteritems -from sentry_sdk._types import TYPE_CHECKING +import sentry_sdk from sentry_sdk.consts import OP -from sentry_sdk.hub import Hub, _should_send_default_pii -from sentry_sdk.integrations import DidNotEnable, Integration +from sentry_sdk.integrations import ( + DidNotEnable, + Integration, + _DEFAULT_FAILED_REQUEST_STATUS_CODES, +) from sentry_sdk.integrations._wsgi_common import ( + DEFAULT_HTTP_METHODS_TO_CAPTURE, + HttpCodeRangeContainer, _is_json_content_type, request_body_within_bounds, ) from sentry_sdk.integrations.asgi import SentryAsgiMiddleware +from sentry_sdk.scope import should_send_default_pii from sentry_sdk.tracing import ( SOURCE_FOR_STYLE, - TRANSACTION_SOURCE_COMPONENT, - TRANSACTION_SOURCE_ROUTE, + TransactionSource, ) from sentry_sdk.utils import ( AnnotatedValue, capture_internal_exceptions, + ensure_integration_enabled, event_from_exception, - logger, parse_version, transaction_from_function, ) +from typing import TYPE_CHECKING + if TYPE_CHECKING: - from typing import Any, Awaitable, Callable, Dict, Optional, Tuple + from typing import Any, Awaitable, Callable, Container, Dict, Optional, Tuple, Union - from sentry_sdk.scope import Scope as SentryScope + from sentry_sdk._types import Event, HttpStatusCodeRange try: import starlette # type: ignore @@ -57,7 +64,12 @@ try: # Optional dependency of Starlette to parse form data. - import multipart # type: ignore + try: + # python-multipart 0.0.13 and later + import python_multipart as multipart # type: ignore + except ImportError: + # python-multipart 0.0.12 and earlier + import multipart # type: ignore except ImportError: multipart = None @@ -69,21 +81,47 @@ class StarletteIntegration(Integration): identifier = "starlette" + origin = f"auto.http.{identifier}" transaction_style = "" - def __init__(self, transaction_style="url"): - # type: (str) -> None + def __init__( + self, + transaction_style: str = "url", + failed_request_status_codes: "Union[Set[int], list[HttpStatusCodeRange], None]" = _DEFAULT_FAILED_REQUEST_STATUS_CODES, + middleware_spans: bool = False, + http_methods_to_capture: "tuple[str, ...]" = DEFAULT_HTTP_METHODS_TO_CAPTURE, + ): if transaction_style not in TRANSACTION_STYLE_VALUES: raise ValueError( "Invalid value for transaction_style: %s (must be in %s)" % (transaction_style, TRANSACTION_STYLE_VALUES) ) self.transaction_style = transaction_style + self.middleware_spans = middleware_spans + self.http_methods_to_capture = tuple(map(str.upper, http_methods_to_capture)) + + if isinstance(failed_request_status_codes, Set): + self.failed_request_status_codes: "Container[int]" = ( + failed_request_status_codes + ) + else: + warnings.warn( + "Passing a list or None for failed_request_status_codes is deprecated. " + "Please pass a set of int instead.", + DeprecationWarning, + stacklevel=2, + ) + + if failed_request_status_codes is None: + self.failed_request_status_codes = _DEFAULT_FAILED_REQUEST_STATUS_CODES + else: + self.failed_request_status_codes = HttpCodeRangeContainer( + failed_request_status_codes + ) @staticmethod - def setup_once(): - # type: () -> None + def setup_once() -> None: version = parse_version(STARLETTE_VERSION) if version is None: @@ -99,66 +137,71 @@ def setup_once(): patch_templates() -def _enable_span_for_middleware(middleware_class): - # type: (Any) -> type +def _enable_span_for_middleware(middleware_class: "Any") -> type: old_call = middleware_class.__call__ - async def _create_span_call(app, scope, receive, send, **kwargs): - # type: (Any, Dict[str, Any], Callable[[], Awaitable[Dict[str, Any]]], Callable[[Dict[str, Any]], Awaitable[None]], Any) -> None - hub = Hub.current - integration = hub.get_integration(StarletteIntegration) - if integration is not None: - middleware_name = app.__class__.__name__ - - # Update transaction name with middleware name - with hub.configure_scope() as sentry_scope: - name, source = _get_transaction_from_middleware(app, scope, integration) - if name is not None: - sentry_scope.set_transaction_name( - name, - source=source, - ) + async def _create_span_call( + app: "Any", + scope: "Dict[str, Any]", + receive: "Callable[[], Awaitable[Dict[str, Any]]]", + send: "Callable[[Dict[str, Any]], Awaitable[None]]", + **kwargs: "Any", + ) -> None: + integration = sentry_sdk.get_client().get_integration(StarletteIntegration) + if integration is None: + return await old_call(app, scope, receive, send, **kwargs) - with hub.start_span( - op=OP.MIDDLEWARE_STARLETTE, description=middleware_name - ) as middleware_span: - middleware_span.set_tag("starlette.middleware_name", middleware_name) - - # Creating spans for the "receive" callback - async def _sentry_receive(*args, **kwargs): - # type: (*Any, **Any) -> Any - hub = Hub.current - with hub.start_span( - op=OP.MIDDLEWARE_STARLETTE_RECEIVE, - description=getattr(receive, "__qualname__", str(receive)), - ) as span: - span.set_tag("starlette.middleware_name", middleware_name) - return await receive(*args, **kwargs) - - receive_name = getattr(receive, "__name__", str(receive)) - receive_patched = receive_name == "_sentry_receive" - new_receive = _sentry_receive if not receive_patched else receive - - # Creating spans for the "send" callback - async def _sentry_send(*args, **kwargs): - # type: (*Any, **Any) -> Any - hub = Hub.current - with hub.start_span( - op=OP.MIDDLEWARE_STARLETTE_SEND, - description=getattr(send, "__qualname__", str(send)), - ) as span: - span.set_tag("starlette.middleware_name", middleware_name) - return await send(*args, **kwargs) - - send_name = getattr(send, "__name__", str(send)) - send_patched = send_name == "_sentry_send" - new_send = _sentry_send if not send_patched else send - - return await old_call(app, scope, new_receive, new_send, **kwargs) + # Update transaction name with middleware name + name, source = _get_transaction_from_middleware(app, scope, integration) - else: + if name is not None: + sentry_sdk.get_current_scope().set_transaction_name( + name, + source=source, + ) + + if not integration.middleware_spans: return await old_call(app, scope, receive, send, **kwargs) + middleware_name = app.__class__.__name__ + + with sentry_sdk.start_span( + op=OP.MIDDLEWARE_STARLETTE, + name=middleware_name, + origin=StarletteIntegration.origin, + ) as middleware_span: + middleware_span.set_tag("starlette.middleware_name", middleware_name) + + # Creating spans for the "receive" callback + async def _sentry_receive(*args: "Any", **kwargs: "Any") -> "Any": + with sentry_sdk.start_span( + op=OP.MIDDLEWARE_STARLETTE_RECEIVE, + name=getattr(receive, "__qualname__", str(receive)), + origin=StarletteIntegration.origin, + ) as span: + span.set_tag("starlette.middleware_name", middleware_name) + return await receive(*args, **kwargs) + + receive_name = getattr(receive, "__name__", str(receive)) + receive_patched = receive_name == "_sentry_receive" + new_receive = _sentry_receive if not receive_patched else receive + + # Creating spans for the "send" callback + async def _sentry_send(*args: "Any", **kwargs: "Any") -> "Any": + with sentry_sdk.start_span( + op=OP.MIDDLEWARE_STARLETTE_SEND, + name=getattr(send, "__qualname__", str(send)), + origin=StarletteIntegration.origin, + ) as span: + span.set_tag("starlette.middleware_name", middleware_name) + return await send(*args, **kwargs) + + send_name = getattr(send, "__name__", str(send)) + send_patched = send_name == "_sentry_send" + new_send = _sentry_send if not send_patched else send + + return await old_call(app, scope, new_receive, new_send, **kwargs) + not_yet_patched = old_call.__name__ not in [ "_create_span_call", "_sentry_authenticationmiddleware_call", @@ -171,23 +214,18 @@ async def _sentry_send(*args, **kwargs): return middleware_class -def _capture_exception(exception, handled=False): - # type: (BaseException, **Any) -> None - hub = Hub.current - if hub.get_integration(StarletteIntegration) is None: - return - +@ensure_integration_enabled(StarletteIntegration) +def _capture_exception(exception: BaseException, handled: "Any" = False) -> None: event, hint = event_from_exception( exception, - client_options=hub.client.options if hub.client else None, + client_options=sentry_sdk.get_client().options, mechanism={"type": StarletteIntegration.identifier, "handled": handled}, ) - hub.capture_event(event, hint=hint) + sentry_sdk.capture_event(event, hint=hint) -def patch_exception_middleware(middleware_class): - # type: (Any) -> None +def patch_exception_middleware(middleware_class: "Any") -> None: """ Capture all exceptions in Starlette app and also extract user information. @@ -198,24 +236,29 @@ def patch_exception_middleware(middleware_class): if not_yet_patched: - def _sentry_middleware_init(self, *args, **kwargs): - # type: (Any, Any, Any) -> None + def _sentry_middleware_init(self: "Any", *args: "Any", **kwargs: "Any") -> None: old_middleware_init(self, *args, **kwargs) # Patch existing exception handlers old_handlers = self._exception_handlers.copy() - async def _sentry_patched_exception_handler(self, *args, **kwargs): - # type: (Any, Any, Any) -> None + async def _sentry_patched_exception_handler( + self: "Any", *args: "Any", **kwargs: "Any" + ) -> None: + integration = sentry_sdk.get_client().get_integration( + StarletteIntegration + ) + exp = args[0] - is_http_server_error = ( - hasattr(exp, "status_code") - and isinstance(exp.status_code, int) - and exp.status_code >= 500 - ) - if is_http_server_error: - _capture_exception(exp, handled=True) + if integration is not None: + is_http_server_error = ( + hasattr(exp, "status_code") + and isinstance(exp.status_code, int) + and exp.status_code in integration.failed_request_status_codes + ) + if is_http_server_error: + _capture_exception(exp, handled=True) # Find a matching handler old_handler = None @@ -239,8 +282,12 @@ async def _sentry_patched_exception_handler(self, *args, **kwargs): old_call = middleware_class.__call__ - async def _sentry_exceptionmiddleware_call(self, scope, receive, send): - # type: (Dict[str, Any], Dict[str, Any], Callable[[], Awaitable[Dict[str, Any]]], Callable[[Dict[str, Any]], Awaitable[None]]) -> None + async def _sentry_exceptionmiddleware_call( + self: "Dict[str, Any]", + scope: "Dict[str, Any]", + receive: "Callable[[], Awaitable[Dict[str, Any]]]", + send: "Callable[[Dict[str, Any]], Awaitable[None]]", + ) -> None: # Also add the user (that was eventually set by be Authentication middle # that was called before this middleware). This is done because the authentication # middleware sets the user in the scope and then (in the same function) @@ -258,8 +305,8 @@ async def _sentry_exceptionmiddleware_call(self, scope, receive, send): middleware_class.__call__ = _sentry_exceptionmiddleware_call -def _add_user_to_sentry_scope(scope): - # type: (Dict[str, Any]) -> None +@ensure_integration_enabled(StarletteIntegration) +def _add_user_to_sentry_scope(scope: "Dict[str, Any]") -> None: """ Extracts user information from the ASGI scope and adds it to Sentry's scope. @@ -267,34 +314,29 @@ def _add_user_to_sentry_scope(scope): if "user" not in scope: return - if not _should_send_default_pii(): - return - - hub = Hub.current - if hub.get_integration(StarletteIntegration) is None: + if not should_send_default_pii(): return - with hub.configure_scope() as sentry_scope: - user_info = {} # type: Dict[str, Any] - starlette_user = scope["user"] + user_info: "Dict[str, Any]" = {} + starlette_user = scope["user"] - username = getattr(starlette_user, "username", None) - if username: - user_info.setdefault("username", starlette_user.username) + username = getattr(starlette_user, "username", None) + if username: + user_info.setdefault("username", starlette_user.username) - user_id = getattr(starlette_user, "id", None) - if user_id: - user_info.setdefault("id", starlette_user.id) + user_id = getattr(starlette_user, "id", None) + if user_id: + user_info.setdefault("id", starlette_user.id) - email = getattr(starlette_user, "email", None) - if email: - user_info.setdefault("email", starlette_user.email) + email = getattr(starlette_user, "email", None) + if email: + user_info.setdefault("email", starlette_user.email) - sentry_scope.user = user_info + sentry_scope = sentry_sdk.get_isolation_scope() + sentry_scope.set_user(user_info) -def patch_authentication_middleware(middleware_class): - # type: (Any) -> None +def patch_authentication_middleware(middleware_class: "Any") -> None: """ Add user information to Sentry scope. """ @@ -304,16 +346,19 @@ def patch_authentication_middleware(middleware_class): if not_yet_patched: - async def _sentry_authenticationmiddleware_call(self, scope, receive, send): - # type: (Dict[str, Any], Dict[str, Any], Callable[[], Awaitable[Dict[str, Any]]], Callable[[Dict[str, Any]], Awaitable[None]]) -> None + async def _sentry_authenticationmiddleware_call( + self: "Dict[str, Any]", + scope: "Dict[str, Any]", + receive: "Callable[[], Awaitable[Dict[str, Any]]]", + send: "Callable[[Dict[str, Any]], Awaitable[None]]", + ) -> None: await old_call(self, scope, receive, send) _add_user_to_sentry_scope(scope) middleware_class.__call__ = _sentry_authenticationmiddleware_call -def patch_middlewares(): - # type: () -> None +def patch_middlewares() -> None: """ Patches Starlettes `Middleware` class to record spans for every middleware invoked. @@ -324,13 +369,14 @@ def patch_middlewares(): if not_yet_patched: - def _sentry_middleware_init(self, cls, **options): - # type: (Any, Any, Any) -> None + def _sentry_middleware_init( + self: "Any", cls: "Any", *args: "Any", **kwargs: "Any" + ) -> None: if cls == SentryAsgiMiddleware: - return old_middleware_init(self, cls, **options) + return old_middleware_init(self, cls, *args, **kwargs) span_enabled_cls = _enable_span_for_middleware(cls) - old_middleware_init(self, span_enabled_cls, **options) + old_middleware_init(self, span_enabled_cls, *args, **kwargs) if cls == AuthenticationMiddleware: patch_authentication_middleware(cls) @@ -341,16 +387,16 @@ def _sentry_middleware_init(self, cls, **options): Middleware.__init__ = _sentry_middleware_init -def patch_asgi_app(): - # type: () -> None +def patch_asgi_app() -> None: """ Instrument Starlette ASGI app using the SentryAsgiMiddleware. """ old_app = Starlette.__call__ - async def _sentry_patched_asgi_app(self, scope, receive, send): - # type: (Starlette, StarletteScope, Receive, Send) -> None - integration = Hub.current.get_integration(StarletteIntegration) + async def _sentry_patched_asgi_app( + self: "Starlette", scope: "StarletteScope", receive: "Receive", send: "Send" + ) -> None: + integration = sentry_sdk.get_client().get_integration(StarletteIntegration) if integration is None: return await old_app(self, scope, receive, send) @@ -358,9 +404,15 @@ async def _sentry_patched_asgi_app(self, scope, receive, send): lambda *a, **kw: old_app(self, *a, **kw), mechanism_type=StarletteIntegration.identifier, transaction_style=integration.transaction_style, + span_origin=StarletteIntegration.origin, + http_methods_to_capture=( + integration.http_methods_to_capture + if integration + else DEFAULT_HTTP_METHODS_TO_CAPTURE + ), + asgi_version=3, ) - middleware.__call__ = middleware._run_asgi3 return await middleware(scope, receive, send) Starlette.__call__ = _sentry_patched_asgi_app @@ -368,8 +420,7 @@ async def _sentry_patched_asgi_app(self, scope, receive, send): # This was vendored in from Starlette to support Starlette 0.19.1 because # this function was only introduced in 0.20.x -def _is_async_callable(obj): - # type: (Any) -> bool +def _is_async_callable(obj: "Any") -> bool: while isinstance(obj, functools.partial): obj = obj.func @@ -378,51 +429,52 @@ def _is_async_callable(obj): ) -def patch_request_response(): - # type: () -> None +def patch_request_response() -> None: old_request_response = starlette.routing.request_response - def _sentry_request_response(func): - # type: (Callable[[Any], Any]) -> ASGIApp + def _sentry_request_response(func: "Callable[[Any], Any]") -> "ASGIApp": old_func = func is_coroutine = _is_async_callable(old_func) if is_coroutine: - async def _sentry_async_func(*args, **kwargs): - # type: (*Any, **Any) -> Any - hub = Hub.current - integration = hub.get_integration(StarletteIntegration) + async def _sentry_async_func(*args: "Any", **kwargs: "Any") -> "Any": + integration = sentry_sdk.get_client().get_integration( + StarletteIntegration + ) if integration is None: return await old_func(*args, **kwargs) - with hub.configure_scope() as sentry_scope: - request = args[0] - - _set_transaction_name_and_source( - sentry_scope, integration.transaction_style, request - ) + request = args[0] - extractor = StarletteRequestExtractor(request) - info = await extractor.extract_request_info() - - def _make_request_event_processor(req, integration): - # type: (Any, Any) -> Callable[[Dict[str, Any], Dict[str, Any]], Dict[str, Any]] - def event_processor(event, hint): - # type: (Dict[str, Any], Dict[str, Any]) -> Dict[str, Any] - - # Add info from request to event - request_info = event.get("request", {}) - if info: - if "cookies" in info: - request_info["cookies"] = info["cookies"] - if "data" in info: - request_info["data"] = info["data"] - event["request"] = deepcopy(request_info) - - return event + _set_transaction_name_and_source( + sentry_sdk.get_current_scope(), + integration.transaction_style, + request, + ) - return event_processor + sentry_scope = sentry_sdk.get_isolation_scope() + extractor = StarletteRequestExtractor(request) + info = await extractor.extract_request_info() + + def _make_request_event_processor( + req: "Any", integration: "Any" + ) -> "Callable[[Event, dict[str, Any]], Event]": + def event_processor( + event: "Event", hint: "Dict[str, Any]" + ) -> "Event": + # Add info from request to event + request_info = event.get("request", {}) + if info: + if "cookies" in info: + request_info["cookies"] = info["cookies"] + if "data" in info: + request_info["data"] = info["data"] + event["request"] = deepcopy(request_info) + + return event + + return event_processor sentry_scope._name = StarletteIntegration.identifier sentry_scope.add_event_processor( @@ -432,43 +484,50 @@ def event_processor(event, hint): return await old_func(*args, **kwargs) func = _sentry_async_func + else: - def _sentry_sync_func(*args, **kwargs): - # type: (*Any, **Any) -> Any - hub = Hub.current - integration = hub.get_integration(StarletteIntegration) + @functools.wraps(old_func) + def _sentry_sync_func(*args: "Any", **kwargs: "Any") -> "Any": + integration = sentry_sdk.get_client().get_integration( + StarletteIntegration + ) if integration is None: return old_func(*args, **kwargs) - with hub.configure_scope() as sentry_scope: - if sentry_scope.profile is not None: - sentry_scope.profile.update_active_thread_id() + current_scope = sentry_sdk.get_current_scope() + if current_scope.transaction is not None: + current_scope.transaction.update_active_thread() - request = args[0] + sentry_scope = sentry_sdk.get_isolation_scope() + if sentry_scope.profile is not None: + sentry_scope.profile.update_active_thread_id() - _set_transaction_name_and_source( - sentry_scope, integration.transaction_style, request - ) + request = args[0] - extractor = StarletteRequestExtractor(request) - cookies = extractor.extract_cookies_from_request() + _set_transaction_name_and_source( + sentry_scope, integration.transaction_style, request + ) - def _make_request_event_processor(req, integration): - # type: (Any, Any) -> Callable[[Dict[str, Any], Dict[str, Any]], Dict[str, Any]] - def event_processor(event, hint): - # type: (Dict[str, Any], Dict[str, Any]) -> Dict[str, Any] + extractor = StarletteRequestExtractor(request) + cookies = extractor.extract_cookies_from_request() - # Extract information from request - request_info = event.get("request", {}) - if cookies: - request_info["cookies"] = cookies + def _make_request_event_processor( + req: "Any", integration: "Any" + ) -> "Callable[[Event, dict[str, Any]], Event]": + def event_processor( + event: "Event", hint: "dict[str, Any]" + ) -> "Event": + # Extract information from request + request_info = event.get("request", {}) + if cookies: + request_info["cookies"] = cookies - event["request"] = deepcopy(request_info) + event["request"] = deepcopy(request_info) - return event + return event - return event_processor + return event_processor sentry_scope._name = StarletteIntegration.identifier sentry_scope.add_event_processor( @@ -484,9 +543,7 @@ def event_processor(event, hint): starlette.routing.request_response = _sentry_request_response -def patch_templates(): - # type: () -> None - +def patch_templates() -> None: # If markupsafe is not installed, then Jinja2 is not installed # (markupsafe is a dependency of Jinja2) # In this case we do not need to patch the Jinja2Templates class @@ -505,12 +562,13 @@ def patch_templates(): if not_yet_patched: - def _sentry_jinja2templates_init(self, *args, **kwargs): - # type: (Jinja2Templates, *Any, **Any) -> None - def add_sentry_trace_meta(request): - # type: (Request) -> Dict[str, Any] - hub = Hub.current - trace_meta = Markup(hub.trace_propagation_meta()) + def _sentry_jinja2templates_init( + self: "Jinja2Templates", *args: "Any", **kwargs: "Any" + ) -> None: + def add_sentry_trace_meta(request: "Request") -> "Dict[str, Any]": + trace_meta = Markup( + sentry_sdk.get_current_scope().trace_propagation_meta() + ) return { "sentry_trace_meta": trace_meta, } @@ -531,35 +589,30 @@ class StarletteRequestExtractor: (like form data or cookies) and adds it to the Sentry event. """ - request = None # type: Request + request: "Request" = None - def __init__(self, request): - # type: (StarletteRequestExtractor, Request) -> None + def __init__(self: "StarletteRequestExtractor", request: "Request") -> None: self.request = request - def extract_cookies_from_request(self): - # type: (StarletteRequestExtractor) -> Optional[Dict[str, Any]] - client = Hub.current.client - if client is None: - return None - - cookies = None # type: Optional[Dict[str, Any]] - if _should_send_default_pii(): + def extract_cookies_from_request( + self: "StarletteRequestExtractor", + ) -> "Optional[Dict[str, Any]]": + cookies: "Optional[Dict[str, Any]]" = None + if should_send_default_pii(): cookies = self.cookies() return cookies - async def extract_request_info(self): - # type: (StarletteRequestExtractor) -> Optional[Dict[str, Any]] - client = Hub.current.client - if client is None: - return None + async def extract_request_info( + self: "StarletteRequestExtractor", + ) -> "Optional[Dict[str, Any]]": + client = sentry_sdk.get_client() - request_info = {} # type: Dict[str, Any] + request_info: "Dict[str, Any]" = {} with capture_internal_exceptions(): # Add cookies - if _should_send_default_pii(): + if should_send_default_pii(): request_info["cookies"] = self.cookies() # If there is no body, just return the cookies @@ -584,7 +637,7 @@ async def extract_request_info(self): form = await self.form() if form: form_data = {} - for key, val in iteritems(form): + for key, val in form.items(): is_file = isinstance(val, UploadFile) form_data[key] = ( val @@ -599,19 +652,16 @@ async def extract_request_info(self): request_info["data"] = AnnotatedValue.removed_because_raw_data() return request_info - async def content_length(self): - # type: (StarletteRequestExtractor) -> Optional[int] + async def content_length(self: "StarletteRequestExtractor") -> "Optional[int]": if "content-length" in self.request.headers: return int(self.request.headers["content-length"]) return None - def cookies(self): - # type: (StarletteRequestExtractor) -> Dict[str, Any] + def cookies(self: "StarletteRequestExtractor") -> "Dict[str, Any]": return self.request.cookies - async def form(self): - # type: (StarletteRequestExtractor) -> Any + async def form(self: "StarletteRequestExtractor") -> "Any": if multipart is None: return None @@ -623,20 +673,19 @@ async def form(self): return await self.request.form() - def is_json(self): - # type: (StarletteRequestExtractor) -> bool + def is_json(self: "StarletteRequestExtractor") -> bool: return _is_json_content_type(self.request.headers.get("content-type")) - async def json(self): - # type: (StarletteRequestExtractor) -> Optional[Dict[str, Any]] + async def json(self: "StarletteRequestExtractor") -> "Optional[Dict[str, Any]]": if not self.is_json(): return None - - return await self.request.json() + try: + return await self.request.json() + except JSONDecodeError: + return None -def _transaction_name_from_router(scope): - # type: (StarletteScope) -> Optional[str] +def _transaction_name_from_router(scope: "StarletteScope") -> "Optional[str]": router = scope.get("router") if not router: return None @@ -644,13 +693,18 @@ def _transaction_name_from_router(scope): for route in router.routes: match = route.matches(scope) if match[0] == Match.FULL: - return route.path + try: + return route.path + except AttributeError: + # routes added via app.host() won't have a path attribute + return scope.get("path") return None -def _set_transaction_name_and_source(scope, transaction_style, request): - # type: (SentryScope, str, Any) -> None +def _set_transaction_name_and_source( + scope: "sentry_sdk.Scope", transaction_style: str, request: "Any" +) -> None: name = None source = SOURCE_FOR_STYLE[transaction_style] @@ -664,24 +718,22 @@ def _set_transaction_name_and_source(scope, transaction_style, request): if name is None: name = _DEFAULT_TRANSACTION_NAME - source = TRANSACTION_SOURCE_ROUTE + source = TransactionSource.ROUTE scope.set_transaction_name(name, source=source) - logger.debug( - "[Starlette] Set transaction name and source on scope: %s / %s", name, source - ) -def _get_transaction_from_middleware(app, asgi_scope, integration): - # type: (Any, Dict[str, Any], StarletteIntegration) -> Tuple[Optional[str], Optional[str]] +def _get_transaction_from_middleware( + app: "Any", asgi_scope: "Dict[str, Any]", integration: "StarletteIntegration" +) -> "Tuple[Optional[str], Optional[str]]": name = None source = None if integration.transaction_style == "endpoint": name = transaction_from_function(app.__class__) - source = TRANSACTION_SOURCE_COMPONENT + source = TransactionSource.COMPONENT elif integration.transaction_style == "url": name = _transaction_name_from_router(asgi_scope) - source = TRANSACTION_SOURCE_ROUTE + source = TransactionSource.ROUTE return name, source diff --git a/sentry_sdk/integrations/starlite.py b/sentry_sdk/integrations/starlite.py index 3900ce8c8a..af66d37fae 100644 --- a/sentry_sdk/integrations/starlite.py +++ b/sentry_sdk/integrations/starlite.py @@ -1,12 +1,16 @@ -from typing import TYPE_CHECKING +from copy import deepcopy -from pydantic import BaseModel # type: ignore +import sentry_sdk from sentry_sdk.consts import OP -from sentry_sdk.hub import Hub, _should_send_default_pii from sentry_sdk.integrations import DidNotEnable, Integration from sentry_sdk.integrations.asgi import SentryAsgiMiddleware -from sentry_sdk.tracing import SOURCE_FOR_STYLE, TRANSACTION_SOURCE_ROUTE -from sentry_sdk.utils import event_from_exception, transaction_from_function +from sentry_sdk.scope import should_send_default_pii +from sentry_sdk.tracing import SOURCE_FOR_STYLE, TransactionSource +from sentry_sdk.utils import ( + ensure_integration_enabled, + event_from_exception, + transaction_from_function, +) try: from starlite import Request, Starlite, State # type: ignore @@ -15,41 +19,36 @@ from starlite.plugins.base import get_plugin_for_value # type: ignore from starlite.routes.http import HTTPRoute # type: ignore from starlite.utils import ConnectionDataExtractor, is_async_callable, Ref # type: ignore - - if TYPE_CHECKING: - from typing import Any, Dict, List, Optional, Union - from starlite.types import ( # type: ignore - ASGIApp, - HTTPReceiveMessage, - HTTPScope, - Message, - Middleware, - Receive, - Scope, - Send, - WebSocketReceiveMessage, - ) - from starlite import MiddlewareProtocol - from sentry_sdk._types import Event + from pydantic import BaseModel # type: ignore except ImportError: raise DidNotEnable("Starlite is not installed") +from typing import TYPE_CHECKING -_DEFAULT_TRANSACTION_NAME = "generic Starlite request" +if TYPE_CHECKING: + from typing import Any, Optional, Union + from starlite.types import ( # type: ignore + ASGIApp, + Hint, + HTTPReceiveMessage, + HTTPScope, + Message, + Middleware, + Receive, + Scope as StarliteScope, + Send, + WebSocketReceiveMessage, + ) + from starlite import MiddlewareProtocol + from sentry_sdk._types import Event -class SentryStarliteASGIMiddleware(SentryAsgiMiddleware): - def __init__(self, app: "ASGIApp"): - super().__init__( - app=app, - unsafe_context_data=False, - transaction_style="endpoint", - mechanism_type="asgi", - ) +_DEFAULT_TRANSACTION_NAME = "generic Starlite request" class StarliteIntegration(Integration): identifier = "starlite" + origin = f"auto.http.{identifier}" @staticmethod def setup_once() -> None: @@ -58,6 +57,20 @@ def setup_once() -> None: patch_http_route_handle() +class SentryStarliteASGIMiddleware(SentryAsgiMiddleware): + def __init__( + self, app: "ASGIApp", span_origin: str = StarliteIntegration.origin + ) -> None: + super().__init__( + app=app, + unsafe_context_data=False, + transaction_style="endpoint", + mechanism_type="asgi", + span_origin=span_origin, + asgi_version=3, + ) + + def patch_app_init() -> None: """ Replaces the Starlite class's `__init__` function in order to inject `after_exception` handlers and set the @@ -68,6 +81,7 @@ def patch_app_init() -> None: """ old__init__ = Starlite.__init__ + @ensure_integration_enabled(StarliteIntegration, old__init__) def injection_wrapper(self: "Starlite", *args: "Any", **kwargs: "Any") -> None: after_exception = kwargs.pop("after_exception", []) kwargs.update( @@ -81,8 +95,7 @@ def injection_wrapper(self: "Starlite", *args: "Any", **kwargs: "Any") -> None: ] ) - SentryStarliteASGIMiddleware.__call__ = SentryStarliteASGIMiddleware._run_asgi3 # type: ignore - middleware = kwargs.pop("middleware", None) or [] + middleware = kwargs.get("middleware") or [] kwargs["middleware"] = [SentryStarliteASGIMiddleware, *middleware] old__init__(self, *args, **kwargs) @@ -90,12 +103,13 @@ def injection_wrapper(self: "Starlite", *args: "Any", **kwargs: "Any") -> None: def patch_middlewares() -> None: - old__resolve_middleware_stack = BaseRouteHandler.resolve_middleware + old_resolve_middleware_stack = BaseRouteHandler.resolve_middleware - def resolve_middleware_wrapper(self: "Any") -> "List[Middleware]": + @ensure_integration_enabled(StarliteIntegration, old_resolve_middleware_stack) + def resolve_middleware_wrapper(self: "BaseRouteHandler") -> "list[Middleware]": return [ enable_span_for_middleware(middleware) - for middleware in old__resolve_middleware_stack(self) + for middleware in old_resolve_middleware_stack(self) ] BaseRouteHandler.resolve_middleware = resolve_middleware_wrapper @@ -114,51 +128,58 @@ def enable_span_for_middleware(middleware: "Middleware") -> "Middleware": old_call = middleware.__call__ async def _create_span_call( - self: "MiddlewareProtocol", scope: "Scope", receive: "Receive", send: "Send" + self: "MiddlewareProtocol", + scope: "StarliteScope", + receive: "Receive", + send: "Send", ) -> None: - hub = Hub.current - integration = hub.get_integration(StarliteIntegration) - if integration is not None: - middleware_name = self.__class__.__name__ - with hub.start_span( - op=OP.MIDDLEWARE_STARLITE, description=middleware_name - ) as middleware_span: - middleware_span.set_tag("starlite.middleware_name", middleware_name) - - # Creating spans for the "receive" callback - async def _sentry_receive( - *args: "Any", **kwargs: "Any" - ) -> "Union[HTTPReceiveMessage, WebSocketReceiveMessage]": - hub = Hub.current - with hub.start_span( - op=OP.MIDDLEWARE_STARLITE_RECEIVE, - description=getattr(receive, "__qualname__", str(receive)), - ) as span: - span.set_tag("starlite.middleware_name", middleware_name) - return await receive(*args, **kwargs) - - receive_name = getattr(receive, "__name__", str(receive)) - receive_patched = receive_name == "_sentry_receive" - new_receive = _sentry_receive if not receive_patched else receive - - # Creating spans for the "send" callback - async def _sentry_send(message: "Message") -> None: - hub = Hub.current - with hub.start_span( - op=OP.MIDDLEWARE_STARLITE_SEND, - description=getattr(send, "__qualname__", str(send)), - ) as span: - span.set_tag("starlite.middleware_name", middleware_name) - return await send(message) - - send_name = getattr(send, "__name__", str(send)) - send_patched = send_name == "_sentry_send" - new_send = _sentry_send if not send_patched else send - - return await old_call(self, scope, new_receive, new_send) - else: + if sentry_sdk.get_client().get_integration(StarliteIntegration) is None: return await old_call(self, scope, receive, send) + middleware_name = self.__class__.__name__ + with sentry_sdk.start_span( + op=OP.MIDDLEWARE_STARLITE, + name=middleware_name, + origin=StarliteIntegration.origin, + ) as middleware_span: + middleware_span.set_tag("starlite.middleware_name", middleware_name) + + # Creating spans for the "receive" callback + async def _sentry_receive( + *args: "Any", **kwargs: "Any" + ) -> "Union[HTTPReceiveMessage, WebSocketReceiveMessage]": + if sentry_sdk.get_client().get_integration(StarliteIntegration) is None: + return await receive(*args, **kwargs) + with sentry_sdk.start_span( + op=OP.MIDDLEWARE_STARLITE_RECEIVE, + name=getattr(receive, "__qualname__", str(receive)), + origin=StarliteIntegration.origin, + ) as span: + span.set_tag("starlite.middleware_name", middleware_name) + return await receive(*args, **kwargs) + + receive_name = getattr(receive, "__name__", str(receive)) + receive_patched = receive_name == "_sentry_receive" + new_receive = _sentry_receive if not receive_patched else receive + + # Creating spans for the "send" callback + async def _sentry_send(message: "Message") -> None: + if sentry_sdk.get_client().get_integration(StarliteIntegration) is None: + return await send(message) + with sentry_sdk.start_span( + op=OP.MIDDLEWARE_STARLITE_SEND, + name=getattr(send, "__qualname__", str(send)), + origin=StarliteIntegration.origin, + ) as span: + span.set_tag("starlite.middleware_name", middleware_name) + return await send(message) + + send_name = getattr(send, "__name__", str(send)) + send_patched = send_name == "_sentry_send" + new_send = _sentry_send if not send_patched else send + + return await old_call(self, scope, new_receive, new_send) + not_yet_patched = old_call.__name__ not in ["_create_span_call"] if not_yet_patched: @@ -176,63 +197,65 @@ def patch_http_route_handle() -> None: async def handle_wrapper( self: "HTTPRoute", scope: "HTTPScope", receive: "Receive", send: "Send" ) -> None: - hub = Hub.current - integration: StarliteIntegration = hub.get_integration(StarliteIntegration) - if integration is None: + if sentry_sdk.get_client().get_integration(StarliteIntegration) is None: return await old_handle(self, scope, receive, send) - with hub.configure_scope() as sentry_scope: - request: "Request[Any, Any]" = scope["app"].request_class( - scope=scope, receive=receive, send=send + sentry_scope = sentry_sdk.get_isolation_scope() + request: "Request[Any, Any]" = scope["app"].request_class( + scope=scope, receive=receive, send=send + ) + extracted_request_data = ConnectionDataExtractor( + parse_body=True, parse_query=True + )(request) + body = extracted_request_data.pop("body") + + request_data = await body + + def event_processor(event: "Event", _: "Hint") -> "Event": + route_handler = scope.get("route_handler") + + request_info = event.get("request", {}) + request_info["content_length"] = len(scope.get("_body", b"")) + if should_send_default_pii(): + request_info["cookies"] = extracted_request_data["cookies"] + if request_data is not None: + request_info["data"] = request_data + + func = None + if route_handler.name is not None: + tx_name = route_handler.name + elif isinstance(route_handler.fn, Ref): + func = route_handler.fn.value + else: + func = route_handler.fn + if func is not None: + tx_name = transaction_from_function(func) + + tx_info = {"source": SOURCE_FOR_STYLE["endpoint"]} + + if not tx_name: + tx_name = _DEFAULT_TRANSACTION_NAME + tx_info = {"source": TransactionSource.ROUTE} + + event.update( + { + "request": deepcopy(request_info), + "transaction": tx_name, + "transaction_info": tx_info, + } ) - extracted_request_data = ConnectionDataExtractor( - parse_body=True, parse_query=True - )(request) - body = extracted_request_data.pop("body") - - request_data = await body - - def event_processor(event: "Event", _: "Dict[str, Any]") -> "Event": - route_handler = scope.get("route_handler") - - request_info = event.get("request", {}) - request_info["content_length"] = len(scope.get("_body", b"")) - if _should_send_default_pii(): - request_info["cookies"] = extracted_request_data["cookies"] - if request_data is not None: - request_info["data"] = request_data - - func = None - if route_handler.name is not None: - tx_name = route_handler.name - elif isinstance(route_handler.fn, Ref): - func = route_handler.fn.value - else: - func = route_handler.fn - if func is not None: - tx_name = transaction_from_function(func) - - tx_info = {"source": SOURCE_FOR_STYLE["endpoint"]} - - if not tx_name: - tx_name = _DEFAULT_TRANSACTION_NAME - tx_info = {"source": TRANSACTION_SOURCE_ROUTE} - - event.update( - request=request_info, transaction=tx_name, transaction_info=tx_info - ) - return event - - sentry_scope._name = StarliteIntegration.identifier - sentry_scope.add_event_processor(event_processor) + return event - return await old_handle(self, scope, receive, send) + sentry_scope._name = StarliteIntegration.identifier + sentry_scope.add_event_processor(event_processor) + + return await old_handle(self, scope, receive, send) HTTPRoute.handle = handle_wrapper -def retrieve_user_from_scope(scope: "Scope") -> "Optional[Dict[str, Any]]": - scope_user = scope.get("user", {}) +def retrieve_user_from_scope(scope: "StarliteScope") -> "Optional[dict[str, Any]]": + scope_user = scope.get("user") if not scope_user: return None if isinstance(scope_user, dict): @@ -249,22 +272,19 @@ def retrieve_user_from_scope(scope: "Scope") -> "Optional[Dict[str, Any]]": return None -def exception_handler(exc: Exception, scope: "Scope", _: "State") -> None: - hub = Hub.current - if hub.get_integration(StarliteIntegration) is None: - return - - user_info: "Optional[Dict[str, Any]]" = None - if _should_send_default_pii(): +@ensure_integration_enabled(StarliteIntegration) +def exception_handler(exc: Exception, scope: "StarliteScope", _: "State") -> None: + user_info: "Optional[dict[str, Any]]" = None + if should_send_default_pii(): user_info = retrieve_user_from_scope(scope) if user_info and isinstance(user_info, dict): - with hub.configure_scope() as sentry_scope: - sentry_scope.set_user(user_info) + sentry_scope = sentry_sdk.get_isolation_scope() + sentry_scope.set_user(user_info) event, hint = event_from_exception( exc, - client_options=hub.client.options if hub.client else None, + client_options=sentry_sdk.get_client().options, mechanism={"type": StarliteIntegration.identifier, "handled": False}, ) - hub.capture_event(event, hint=hint) + sentry_sdk.capture_event(event, hint=hint) diff --git a/sentry_sdk/integrations/statsig.py b/sentry_sdk/integrations/statsig.py new file mode 100644 index 0000000000..42e71a50f5 --- /dev/null +++ b/sentry_sdk/integrations/statsig.py @@ -0,0 +1,37 @@ +from functools import wraps +from typing import Any, TYPE_CHECKING + +from sentry_sdk.feature_flags import add_feature_flag +from sentry_sdk.integrations import Integration, DidNotEnable, _check_minimum_version +from sentry_sdk.utils import parse_version + +try: + from statsig import statsig as statsig_module + from statsig.version import __version__ as STATSIG_VERSION +except ImportError: + raise DidNotEnable("statsig is not installed") + +if TYPE_CHECKING: + from statsig.statsig_user import StatsigUser + + +class StatsigIntegration(Integration): + identifier = "statsig" + + @staticmethod + def setup_once() -> None: + version = parse_version(STATSIG_VERSION) + _check_minimum_version(StatsigIntegration, version, "statsig") + + # Wrap and patch evaluation method(s) in the statsig module + old_check_gate = statsig_module.check_gate + + @wraps(old_check_gate) + def sentry_check_gate( + user: "StatsigUser", gate: str, *args: "Any", **kwargs: "Any" + ) -> "Any": + enabled = old_check_gate(user, gate, *args, **kwargs) + add_feature_flag(gate, enabled) + return enabled + + statsig_module.check_gate = sentry_check_gate diff --git a/sentry_sdk/integrations/stdlib.py b/sentry_sdk/integrations/stdlib.py index a5c3bfb2ae..bf0c626fa8 100644 --- a/sentry_sdk/integrations/stdlib.py +++ b/sentry_sdk/integrations/stdlib.py @@ -2,22 +2,28 @@ import subprocess import sys import platform -from sentry_sdk.consts import OP, SPANDATA +from http.client import HTTPConnection -from sentry_sdk.hub import Hub +import sentry_sdk +from sentry_sdk.consts import OP, SPANDATA from sentry_sdk.integrations import Integration from sentry_sdk.scope import add_global_event_processor -from sentry_sdk.tracing_utils import EnvironHeaders, should_propagate_trace +from sentry_sdk.tracing_utils import ( + EnvironHeaders, + should_propagate_trace, + add_http_request_source, +) from sentry_sdk.utils import ( SENSITIVE_DATA_SUBSTITUTE, capture_internal_exceptions, + ensure_integration_enabled, is_sentry_url, logger, safe_repr, parse_url, ) -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any @@ -29,13 +35,7 @@ from sentry_sdk._types import Event, Hint -try: - from httplib import HTTPConnection # type: ignore -except ImportError: - from http.client import HTTPConnection - - -_RUNTIME_CONTEXT = { +_RUNTIME_CONTEXT: "dict[str, object]" = { "name": platform.python_implementation(), "version": "%s.%s.%s" % (sys.version_info[:3]), "build": sys.version, @@ -46,15 +46,15 @@ class StdlibIntegration(Integration): identifier = "stdlib" @staticmethod - def setup_once(): - # type: () -> None + def setup_once() -> None: _install_httplib() _install_subprocess() @add_global_event_processor - def add_python_runtime_context(event, hint): - # type: (Event, Hint) -> Optional[Event] - if Hub.current.get_integration(StdlibIntegration) is not None: + def add_python_runtime_context( + event: "Event", hint: "Hint" + ) -> "Optional[Event]": + if sentry_sdk.get_client().get_integration(StdlibIntegration) is not None: contexts = event.setdefault("contexts", {}) if isinstance(contexts, dict) and "runtime" not in contexts: contexts["runtime"] = _RUNTIME_CONTEXT @@ -62,20 +62,21 @@ def add_python_runtime_context(event, hint): return event -def _install_httplib(): - # type: () -> None +def _install_httplib() -> None: real_putrequest = HTTPConnection.putrequest real_getresponse = HTTPConnection.getresponse - def putrequest(self, method, url, *args, **kwargs): - # type: (HTTPConnection, str, str, *Any, **Any) -> Any - hub = Hub.current - + def putrequest( + self: "HTTPConnection", method: str, url: str, *args: "Any", **kwargs: "Any" + ) -> "Any": host = self.host port = self.port default_port = self.default_port - if hub.get_integration(StdlibIntegration) is None or is_sentry_url(hub, host): + client = sentry_sdk.get_client() + if client.get_integration(StdlibIntegration) is None or is_sentry_url( + client, host + ): return real_putrequest(self, method, url, *args, **kwargs) real_url = url @@ -91,12 +92,12 @@ def putrequest(self, method, url, *args, **kwargs): with capture_internal_exceptions(): parsed_url = parse_url(real_url, sanitize=False) - span = hub.start_span( + span = sentry_sdk.start_span( op=OP.HTTP_CLIENT, - description="%s %s" + name="%s %s" % (method, parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE), + origin="auto.http.stdlib.httplib", ) - span.set_data(SPANDATA.HTTP_METHOD, method) if parsed_url is not None: span.set_data("url", parsed_url.url) @@ -105,8 +106,13 @@ def putrequest(self, method, url, *args, **kwargs): rv = real_putrequest(self, method, url, *args, **kwargs) - if should_propagate_trace(hub, real_url): - for key, value in hub.iter_trace_propagation_headers(span): + if should_propagate_trace(client, real_url): + for ( + key, + value, + ) in sentry_sdk.get_current_scope().iter_trace_propagation_headers( + span=span + ): logger.debug( "[Tracing] Adding `{key}` header {value} to outgoing request to {real_url}.".format( key=key, value=value, real_url=real_url @@ -114,31 +120,40 @@ def putrequest(self, method, url, *args, **kwargs): ) self.putheader(key, value) - self._sentrysdk_span = span + self._sentrysdk_span = span # type: ignore[attr-defined] return rv - def getresponse(self, *args, **kwargs): - # type: (HTTPConnection, *Any, **Any) -> Any + def getresponse(self: "HTTPConnection", *args: "Any", **kwargs: "Any") -> "Any": span = getattr(self, "_sentrysdk_span", None) if span is None: return real_getresponse(self, *args, **kwargs) - rv = real_getresponse(self, *args, **kwargs) + try: + rv = real_getresponse(self, *args, **kwargs) - span.set_http_status(int(rv.status)) - span.set_data("reason", rv.reason) - span.finish() + span.set_http_status(int(rv.status)) + span.set_data("reason", rv.reason) + finally: + span.finish() + + with capture_internal_exceptions(): + add_http_request_source(span) return rv - HTTPConnection.putrequest = putrequest - HTTPConnection.getresponse = getresponse + HTTPConnection.putrequest = putrequest # type: ignore[method-assign] + HTTPConnection.getresponse = getresponse # type: ignore[method-assign] -def _init_argument(args, kwargs, name, position, setdefault_callback=None): - # type: (List[Any], Dict[Any, Any], str, int, Optional[Callable[[Any], Any]]) -> Any +def _init_argument( + args: "List[Any]", + kwargs: "Dict[Any, Any]", + name: str, + position: int, + setdefault_callback: "Optional[Callable[[Any], Any]]" = None, +) -> "Any": """ given (*args, **kwargs) of a function call, retrieve (and optionally set a default for) an argument by either name or position. @@ -168,17 +183,13 @@ def _init_argument(args, kwargs, name, position, setdefault_callback=None): return rv -def _install_subprocess(): - # type: () -> None +def _install_subprocess() -> None: old_popen_init = subprocess.Popen.__init__ - def sentry_patched_popen_init(self, *a, **kw): - # type: (subprocess.Popen[Any], *Any, **Any) -> None - - hub = Hub.current - if hub.get_integration(StdlibIntegration) is None: - return old_popen_init(self, *a, **kw) - + @ensure_integration_enabled(StdlibIntegration, old_popen_init) + def sentry_patched_popen_init( + self: "subprocess.Popen[Any]", *a: "Any", **kw: "Any" + ) -> None: # Convert from tuple to list to be able to set values. a = list(a) @@ -203,11 +214,21 @@ def sentry_patched_popen_init(self, *a, **kw): env = None - with hub.start_span(op=OP.SUBPROCESS, description=description) as span: - for k, v in hub.iter_trace_propagation_headers(span): + with sentry_sdk.start_span( + op=OP.SUBPROCESS, + name=description, + origin="auto.subprocess.stdlib.subprocess", + ) as span: + for k, v in sentry_sdk.get_current_scope().iter_trace_propagation_headers( + span=span + ): if env is None: env = _init_argument( - a, kw, "env", 10, lambda x: dict(x or os.environ) + a, + kw, + "env", + 10, + lambda x: dict(x if x is not None else os.environ), ) env["SUBPROCESS_" + k.upper().replace("-", "_")] = v @@ -223,14 +244,14 @@ def sentry_patched_popen_init(self, *a, **kw): old_popen_wait = subprocess.Popen.wait - def sentry_patched_popen_wait(self, *a, **kw): - # type: (subprocess.Popen[Any], *Any, **Any) -> Any - hub = Hub.current - - if hub.get_integration(StdlibIntegration) is None: - return old_popen_wait(self, *a, **kw) - - with hub.start_span(op=OP.SUBPROCESS_WAIT) as span: + @ensure_integration_enabled(StdlibIntegration, old_popen_wait) + def sentry_patched_popen_wait( + self: "subprocess.Popen[Any]", *a: "Any", **kw: "Any" + ) -> "Any": + with sentry_sdk.start_span( + op=OP.SUBPROCESS_WAIT, + origin="auto.subprocess.stdlib.subprocess", + ) as span: span.set_tag("subprocess.pid", self.pid) return old_popen_wait(self, *a, **kw) @@ -238,20 +259,19 @@ def sentry_patched_popen_wait(self, *a, **kw): old_popen_communicate = subprocess.Popen.communicate - def sentry_patched_popen_communicate(self, *a, **kw): - # type: (subprocess.Popen[Any], *Any, **Any) -> Any - hub = Hub.current - - if hub.get_integration(StdlibIntegration) is None: - return old_popen_communicate(self, *a, **kw) - - with hub.start_span(op=OP.SUBPROCESS_COMMUNICATE) as span: + @ensure_integration_enabled(StdlibIntegration, old_popen_communicate) + def sentry_patched_popen_communicate( + self: "subprocess.Popen[Any]", *a: "Any", **kw: "Any" + ) -> "Any": + with sentry_sdk.start_span( + op=OP.SUBPROCESS_COMMUNICATE, + origin="auto.subprocess.stdlib.subprocess", + ) as span: span.set_tag("subprocess.pid", self.pid) return old_popen_communicate(self, *a, **kw) subprocess.Popen.communicate = sentry_patched_popen_communicate # type: ignore -def get_subprocess_traceparent_headers(): - # type: () -> EnvironHeaders +def get_subprocess_traceparent_headers() -> "EnvironHeaders": return EnvironHeaders(os.environ, prefix="SUBPROCESS_") diff --git a/sentry_sdk/integrations/strawberry.py b/sentry_sdk/integrations/strawberry.py index 63ddc44f25..da3c31a967 100644 --- a/sentry_sdk/integrations/strawberry.py +++ b/sentry_sdk/integrations/strawberry.py @@ -1,39 +1,59 @@ +import functools import hashlib -from functools import cached_property +import warnings from inspect import isawaitable -from sentry_sdk import configure_scope, start_span + +import sentry_sdk from sentry_sdk.consts import OP -from sentry_sdk.integrations import Integration, DidNotEnable +from sentry_sdk.integrations import _check_minimum_version, Integration, DidNotEnable from sentry_sdk.integrations.logging import ignore_logger -from sentry_sdk.integrations.modules import _get_installed_modules -from sentry_sdk.hub import Hub, _should_send_default_pii +from sentry_sdk.scope import should_send_default_pii +from sentry_sdk.tracing import TransactionSource from sentry_sdk.utils import ( capture_internal_exceptions, + ensure_integration_enabled, event_from_exception, logger, - parse_version, + package_version, + _get_installed_modules, ) -from sentry_sdk._types import TYPE_CHECKING try: - import strawberry.schema.schema as strawberry_schema # type: ignore + from functools import cached_property +except ImportError: + # The strawberry integration requires Python 3.8+. functools.cached_property + # was added in 3.8, so this check is technically not needed, but since this + # is an auto-enabling integration, we might get to executing this import in + # lower Python versions, so we need to deal with it. + raise DidNotEnable("strawberry-graphql integration requires Python 3.8 or newer") + +try: from strawberry import Schema - from strawberry.extensions import SchemaExtension # type: ignore - from strawberry.extensions.tracing.utils import should_skip_tracing as strawberry_should_skip_tracing # type: ignore - from strawberry.extensions.tracing import ( # type: ignore + from strawberry.extensions import SchemaExtension + from strawberry.extensions.tracing.utils import ( + should_skip_tracing as strawberry_should_skip_tracing, + ) + from strawberry.http import async_base_view, sync_base_view +except ImportError: + raise DidNotEnable("strawberry-graphql is not installed") + +try: + from strawberry.extensions.tracing import ( SentryTracingExtension as StrawberrySentryAsyncExtension, SentryTracingExtensionSync as StrawberrySentrySyncExtension, ) - from strawberry.http import async_base_view, sync_base_view # type: ignore except ImportError: - raise DidNotEnable("strawberry-graphql is not installed") + StrawberrySentryAsyncExtension = None + StrawberrySentrySyncExtension = None + +from typing import TYPE_CHECKING if TYPE_CHECKING: - from typing import Any, Callable, Dict, Generator, List, Optional - from graphql import GraphQLError, GraphQLResolveInfo # type: ignore + from typing import Any, Callable, Generator, List, Optional + from graphql import GraphQLError, GraphQLResolveInfo from strawberry.http import GraphQLHTTPResponse - from strawberry.types import ExecutionContext, ExecutionResult # type: ignore - from sentry_sdk._types import EventProcessor + from strawberry.types import ExecutionContext + from sentry_sdk._types import Event, EventProcessor ignore_logger("strawberry.execution") @@ -41,9 +61,9 @@ class StrawberryIntegration(Integration): identifier = "strawberry" + origin = f"auto.graphql.{identifier}" - def __init__(self, async_execution=None): - # type: (Optional[bool]) -> None + def __init__(self, async_execution: "Optional[bool]" = None) -> None: if async_execution not in (None, False, True): raise ValueError( 'Invalid value for async_execution: "{}" (must be bool)'.format( @@ -53,47 +73,40 @@ def __init__(self, async_execution=None): self.async_execution = async_execution @staticmethod - def setup_once(): - # type: () -> None - installed_packages = _get_installed_modules() - version = parse_version(installed_packages["strawberry-graphql"]) - - if version is None: - raise DidNotEnable( - "Unparsable strawberry-graphql version: {}".format(version) - ) - - if version < (0, 209, 5): - raise DidNotEnable("strawberry-graphql 0.209.5 or newer required.") + def setup_once() -> None: + version = package_version("strawberry-graphql") + _check_minimum_version(StrawberryIntegration, version, "strawberry-graphql") _patch_schema_init() - _patch_execute() _patch_views() -def _patch_schema_init(): - # type: () -> None +def _patch_schema_init() -> None: old_schema_init = Schema.__init__ - def _sentry_patched_schema_init(self, *args, **kwargs): - # type: (Schema, Any, Any) -> None - integration = Hub.current.get_integration(StrawberryIntegration) + @functools.wraps(old_schema_init) + def _sentry_patched_schema_init( + self: "Schema", *args: "Any", **kwargs: "Any" + ) -> None: + integration = sentry_sdk.get_client().get_integration(StrawberryIntegration) if integration is None: return old_schema_init(self, *args, **kwargs) extensions = kwargs.get("extensions") or [] + should_use_async_extension: "Optional[bool]" = None if integration.async_execution is not None: should_use_async_extension = integration.async_execution else: # try to figure it out ourselves should_use_async_extension = _guess_if_using_async(extensions) - logger.info( - "Assuming strawberry is running %s. If not, initialize it as StrawberryIntegration(async_execution=%s).", - "async" if should_use_async_extension else "sync", - "False" if should_use_async_extension else "True", - ) + if should_use_async_extension is None: + warnings.warn( + "Assuming strawberry is running sync. If not, initialize the integration as StrawberryIntegration(async_execution=True).", + stacklevel=2, + ) + should_use_async_extension = False # remove the built in strawberry sentry extension, if present extensions = [ @@ -112,40 +125,39 @@ def _sentry_patched_schema_init(self, *args, **kwargs): return old_schema_init(self, *args, **kwargs) - Schema.__init__ = _sentry_patched_schema_init + Schema.__init__ = _sentry_patched_schema_init # type: ignore[method-assign] -class SentryAsyncExtension(SchemaExtension): # type: ignore +class SentryAsyncExtension(SchemaExtension): def __init__( - self, + self: "Any", *, - execution_context=None, - ): - # type: (Any, Optional[ExecutionContext]) -> None + execution_context: "Optional[ExecutionContext]" = None, + ) -> None: if execution_context: self.execution_context = execution_context @cached_property - def _resource_name(self): - # type: () -> str - query_hash = self.hash_query(self.execution_context.query) + def _resource_name(self) -> str: + query_hash = self.hash_query(self.execution_context.query) # type: ignore if self.execution_context.operation_name: return "{}:{}".format(self.execution_context.operation_name, query_hash) return query_hash - def hash_query(self, query): - # type: (str) -> str + def hash_query(self, query: str) -> str: return hashlib.md5(query.encode("utf-8")).hexdigest() - def on_operation(self): - # type: () -> Generator[None, None, None] + def on_operation(self) -> "Generator[None, None, None]": self._operation_name = self.execution_context.operation_name operation_type = "query" op = OP.GRAPHQL_QUERY + if self.execution_context.query is None: + self.execution_context.query = "" + if self.execution_context.query.strip().startswith("mutation"): operation_type = "mutation" op = OP.GRAPHQL_MUTATION @@ -157,7 +169,7 @@ def on_operation(self): if self._operation_name: description += " {}".format(self._operation_name) - Hub.current.add_breadcrumb( + sentry_sdk.add_breadcrumb( category="graphql.operation", data={ "operation_name": self._operation_name, @@ -165,13 +177,23 @@ def on_operation(self): }, ) - with configure_scope() as scope: - if scope.span: - self.graphql_span = scope.span.start_child( - op=op, description=description - ) - else: - self.graphql_span = start_span(op=op, description=description) + scope = sentry_sdk.get_isolation_scope() + event_processor = _make_request_event_processor(self.execution_context) + scope.add_event_processor(event_processor) + + span = sentry_sdk.get_current_span() + if span: + self.graphql_span = span.start_child( + op=op, + name=description, + origin=StrawberryIntegration.origin, + ) + else: + self.graphql_span = sentry_sdk.start_span( + op=op, + name=description, + origin=StrawberryIntegration.origin, + ) self.graphql_span.set_data("graphql.operation.type", operation_type) self.graphql_span.set_data("graphql.operation.name", self._operation_name) @@ -180,34 +202,51 @@ def on_operation(self): yield + transaction = self.graphql_span.containing_transaction + if transaction and self.execution_context.operation_name: + transaction.name = self.execution_context.operation_name + transaction.source = TransactionSource.COMPONENT + transaction.op = op + self.graphql_span.finish() - def on_validate(self): - # type: () -> Generator[None, None, None] + def on_validate(self) -> "Generator[None, None, None]": self.validation_span = self.graphql_span.start_child( - op=OP.GRAPHQL_VALIDATE, description="validation" + op=OP.GRAPHQL_VALIDATE, + name="validation", + origin=StrawberryIntegration.origin, ) yield self.validation_span.finish() - def on_parse(self): - # type: () -> Generator[None, None, None] + def on_parse(self) -> "Generator[None, None, None]": self.parsing_span = self.graphql_span.start_child( - op=OP.GRAPHQL_PARSE, description="parsing" + op=OP.GRAPHQL_PARSE, + name="parsing", + origin=StrawberryIntegration.origin, ) yield self.parsing_span.finish() - def should_skip_tracing(self, _next, info): - # type: (Callable[[Any, GraphQLResolveInfo, Any, Any], Any], GraphQLResolveInfo) -> bool + def should_skip_tracing( + self, + _next: "Callable[[Any, GraphQLResolveInfo, Any, Any], Any]", + info: "GraphQLResolveInfo", + ) -> bool: return strawberry_should_skip_tracing(_next, info) - async def _resolve(self, _next, root, info, *args, **kwargs): - # type: (Callable[[Any, GraphQLResolveInfo, Any, Any], Any], Any, GraphQLResolveInfo, str, Any) -> Any + async def _resolve( + self, + _next: "Callable[[Any, GraphQLResolveInfo, Any, Any], Any]", + root: "Any", + info: "GraphQLResolveInfo", + *args: str, + **kwargs: "Any", + ) -> "Any": result = _next(root, info, *args, **kwargs) if isawaitable(result): @@ -215,15 +254,23 @@ async def _resolve(self, _next, root, info, *args, **kwargs): return result - async def resolve(self, _next, root, info, *args, **kwargs): - # type: (Callable[[Any, GraphQLResolveInfo, Any, Any], Any], Any, GraphQLResolveInfo, str, Any) -> Any + async def resolve( + self, + _next: "Callable[[Any, GraphQLResolveInfo, Any, Any], Any]", + root: "Any", + info: "GraphQLResolveInfo", + *args: str, + **kwargs: "Any", + ) -> "Any": if self.should_skip_tracing(_next, info): return await self._resolve(_next, root, info, *args, **kwargs) field_path = "{}.{}".format(info.parent_type, info.field_name) with self.graphql_span.start_child( - op=OP.GRAPHQL_RESOLVE, description="resolving {}".format(field_path) + op=OP.GRAPHQL_RESOLVE, + name="resolving {}".format(field_path), + origin=StrawberryIntegration.origin, ) as span: span.set_data("graphql.field_name", info.field_name) span.set_data("graphql.parent_type", info.parent_type.name) @@ -234,15 +281,23 @@ async def resolve(self, _next, root, info, *args, **kwargs): class SentrySyncExtension(SentryAsyncExtension): - def resolve(self, _next, root, info, *args, **kwargs): - # type: (Callable[[Any, Any, Any, Any], Any], Any, GraphQLResolveInfo, str, Any) -> Any + def resolve( + self, + _next: "Callable[[Any, Any, Any, Any], Any]", + root: "Any", + info: "GraphQLResolveInfo", + *args: str, + **kwargs: "Any", + ) -> "Any": if self.should_skip_tracing(_next, info): return _next(root, info, *args, **kwargs) field_path = "{}.{}".format(info.parent_type, info.field_name) with self.graphql_span.start_child( - op=OP.GRAPHQL_RESOLVE, description="resolving {}".format(field_path) + op=OP.GRAPHQL_RESOLVE, + name="resolving {}".format(field_path), + origin=StrawberryIntegration.origin, ) as span: span.set_data("graphql.field_name", info.field_name) span.set_data("graphql.parent_type", info.parent_type.name) @@ -252,119 +307,70 @@ def resolve(self, _next, root, info, *args, **kwargs): return _next(root, info, *args, **kwargs) -def _patch_execute(): - # type: () -> None - old_execute_async = strawberry_schema.execute - old_execute_sync = strawberry_schema.execute_sync - - async def _sentry_patched_execute_async(*args, **kwargs): - # type: (Any, Any) -> ExecutionResult - hub = Hub.current - integration = hub.get_integration(StrawberryIntegration) - if integration is None: - return await old_execute_async(*args, **kwargs) - - result = await old_execute_async(*args, **kwargs) - - if "execution_context" in kwargs and result.errors: - with hub.configure_scope() as scope: - event_processor = _make_request_event_processor( - kwargs["execution_context"] - ) - scope.add_event_processor(event_processor) - - return result - - def _sentry_patched_execute_sync(*args, **kwargs): - # type: (Any, Any) -> ExecutionResult - hub = Hub.current - integration = hub.get_integration(StrawberryIntegration) - if integration is None: - return old_execute_sync(*args, **kwargs) - - result = old_execute_sync(*args, **kwargs) - - if "execution_context" in kwargs and result.errors: - with hub.configure_scope() as scope: - event_processor = _make_request_event_processor( - kwargs["execution_context"] - ) - scope.add_event_processor(event_processor) - - return result - - strawberry_schema.execute = _sentry_patched_execute_async - strawberry_schema.execute_sync = _sentry_patched_execute_sync - - -def _patch_views(): - # type: () -> None +def _patch_views() -> None: old_async_view_handle_errors = async_base_view.AsyncBaseHTTPView._handle_errors old_sync_view_handle_errors = sync_base_view.SyncBaseHTTPView._handle_errors - def _sentry_patched_async_view_handle_errors(self, errors, response_data): - # type: (Any, List[GraphQLError], GraphQLHTTPResponse) -> None + def _sentry_patched_async_view_handle_errors( + self: "Any", errors: "List[GraphQLError]", response_data: "GraphQLHTTPResponse" + ) -> None: old_async_view_handle_errors(self, errors, response_data) _sentry_patched_handle_errors(self, errors, response_data) - def _sentry_patched_sync_view_handle_errors(self, errors, response_data): - # type: (Any, List[GraphQLError], GraphQLHTTPResponse) -> None + def _sentry_patched_sync_view_handle_errors( + self: "Any", errors: "List[GraphQLError]", response_data: "GraphQLHTTPResponse" + ) -> None: old_sync_view_handle_errors(self, errors, response_data) _sentry_patched_handle_errors(self, errors, response_data) - def _sentry_patched_handle_errors(self, errors, response_data): - # type: (Any, List[GraphQLError], GraphQLHTTPResponse) -> None - hub = Hub.current - integration = hub.get_integration(StrawberryIntegration) - if integration is None: - return - + @ensure_integration_enabled(StrawberryIntegration) + def _sentry_patched_handle_errors( + self: "Any", errors: "List[GraphQLError]", response_data: "GraphQLHTTPResponse" + ) -> None: if not errors: return - with hub.configure_scope() as scope: - event_processor = _make_response_event_processor(response_data) - scope.add_event_processor(event_processor) + scope = sentry_sdk.get_isolation_scope() + event_processor = _make_response_event_processor(response_data) + scope.add_event_processor(event_processor) with capture_internal_exceptions(): for error in errors: event, hint = event_from_exception( error, - client_options=hub.client.options if hub.client else None, + client_options=sentry_sdk.get_client().options, mechanism={ - "type": integration.identifier, + "type": StrawberryIntegration.identifier, "handled": False, }, ) - hub.capture_event(event, hint=hint) + sentry_sdk.capture_event(event, hint=hint) - async_base_view.AsyncBaseHTTPView._handle_errors = ( + async_base_view.AsyncBaseHTTPView._handle_errors = ( # type: ignore[method-assign] _sentry_patched_async_view_handle_errors ) - sync_base_view.SyncBaseHTTPView._handle_errors = ( + sync_base_view.SyncBaseHTTPView._handle_errors = ( # type: ignore[method-assign] _sentry_patched_sync_view_handle_errors ) -def _make_request_event_processor(execution_context): - # type: (ExecutionContext) -> EventProcessor - - def inner(event, hint): - # type: (Dict[str, Any], Dict[str, Any]) -> Dict[str, Any] +def _make_request_event_processor( + execution_context: "ExecutionContext", +) -> "EventProcessor": + def inner(event: "Event", hint: "dict[str, Any]") -> "Event": with capture_internal_exceptions(): - if _should_send_default_pii(): + if should_send_default_pii(): request_data = event.setdefault("request", {}) request_data["api_target"] = "graphql" if not request_data.get("data"): - request_data["data"] = {"query": execution_context.query} - + data: "dict[str, Any]" = {"query": execution_context.query} if execution_context.variables: - request_data["data"]["variables"] = execution_context.variables + data["variables"] = execution_context.variables if execution_context.operation_name: - request_data["data"][ - "operationName" - ] = execution_context.operation_name + data["operationName"] = execution_context.operation_name + + request_data["data"] = data else: try: @@ -377,13 +383,12 @@ def inner(event, hint): return inner -def _make_response_event_processor(response_data): - # type: (GraphQLHTTPResponse) -> EventProcessor - - def inner(event, hint): - # type: (Dict[str, Any], Dict[str, Any]) -> Dict[str, Any] +def _make_response_event_processor( + response_data: "GraphQLHTTPResponse", +) -> "EventProcessor": + def inner(event: "Event", hint: "dict[str, Any]") -> "Event": with capture_internal_exceptions(): - if _should_send_default_pii(): + if should_send_default_pii(): contexts = event.setdefault("contexts", {}) contexts["response"] = {"data": response_data} @@ -392,13 +397,10 @@ def inner(event, hint): return inner -def _guess_if_using_async(extensions): - # type: (List[SchemaExtension]) -> bool +def _guess_if_using_async(extensions: "List[SchemaExtension]") -> "Optional[bool]": if StrawberrySentryAsyncExtension in extensions: return True elif StrawberrySentrySyncExtension in extensions: return False - return bool( - {"starlette", "starlite", "litestar", "fastapi"} & set(_get_installed_modules()) - ) + return None diff --git a/sentry_sdk/integrations/sys_exit.py b/sentry_sdk/integrations/sys_exit.py new file mode 100644 index 0000000000..120576ed94 --- /dev/null +++ b/sentry_sdk/integrations/sys_exit.py @@ -0,0 +1,65 @@ +import functools +import sys + +import sentry_sdk +from sentry_sdk.utils import capture_internal_exceptions, event_from_exception +from sentry_sdk.integrations import Integration +from sentry_sdk._types import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Callable + from typing import NoReturn, Union + + +class SysExitIntegration(Integration): + """Captures sys.exit calls and sends them as events to Sentry. + + By default, SystemExit exceptions are not captured by the SDK. Enabling this integration will capture SystemExit + exceptions generated by sys.exit calls and send them to Sentry. + + This integration, in its default configuration, only captures the sys.exit call if the exit code is a non-zero and + non-None value (unsuccessful exits). Pass `capture_successful_exits=True` to capture successful exits as well. + Note that the integration does not capture SystemExit exceptions raised outside a call to sys.exit. + """ + + identifier = "sys_exit" + + def __init__(self, *, capture_successful_exits: bool = False) -> None: + self._capture_successful_exits = capture_successful_exits + + @staticmethod + def setup_once() -> None: + SysExitIntegration._patch_sys_exit() + + @staticmethod + def _patch_sys_exit() -> None: + old_exit: "Callable[[Union[str, int, None]], NoReturn]" = sys.exit + + @functools.wraps(old_exit) + def sentry_patched_exit(__status: "Union[str, int, None]" = 0) -> "NoReturn": + # @ensure_integration_enabled ensures that this is non-None + integration = sentry_sdk.get_client().get_integration(SysExitIntegration) + if integration is None: + old_exit(__status) + + try: + old_exit(__status) + except SystemExit as e: + with capture_internal_exceptions(): + if integration._capture_successful_exits or __status not in ( + 0, + None, + ): + _capture_exception(e) + raise e + + sys.exit = sentry_patched_exit + + +def _capture_exception(exc: "SystemExit") -> None: + event, hint = event_from_exception( + exc, + client_options=sentry_sdk.get_client().options, + mechanism={"type": SysExitIntegration.identifier, "handled": False}, + ) + sentry_sdk.capture_event(event, hint=hint) diff --git a/sentry_sdk/integrations/threading.py b/sentry_sdk/integrations/threading.py index 499cf85e6d..6311b935a0 100644 --- a/sentry_sdk/integrations/threading.py +++ b/sentry_sdk/integrations/threading.py @@ -1,14 +1,20 @@ -from __future__ import absolute_import - import sys +import warnings from functools import wraps from threading import Thread, current_thread +from concurrent.futures import ThreadPoolExecutor, Future -from sentry_sdk import Hub -from sentry_sdk._compat import reraise -from sentry_sdk._types import TYPE_CHECKING +import sentry_sdk from sentry_sdk.integrations import Integration -from sentry_sdk.utils import event_from_exception, capture_internal_exceptions +from sentry_sdk.scope import use_isolation_scope, use_scope +from sentry_sdk.utils import ( + event_from_exception, + capture_internal_exceptions, + logger, + reraise, +) + +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any @@ -19,75 +25,173 @@ from sentry_sdk._types import ExcInfo F = TypeVar("F", bound=Callable[..., Any]) + T = TypeVar("T", bound=Any) class ThreadingIntegration(Integration): identifier = "threading" - def __init__(self, propagate_hub=False): - # type: (bool) -> None - self.propagate_hub = propagate_hub + def __init__( + self, propagate_hub: "Optional[bool]" = None, propagate_scope: bool = True + ) -> None: + if propagate_hub is not None: + logger.warning( + "Deprecated: propagate_hub is deprecated. This will be removed in the future." + ) + + # Note: propagate_hub did not have any effect on propagation of scope data + # scope data was always propagated no matter what the value of propagate_hub was + # This is why the default for propagate_scope is True + + self.propagate_scope = propagate_scope + + if propagate_hub is not None: + self.propagate_scope = propagate_hub @staticmethod - def setup_once(): - # type: () -> None + def setup_once() -> None: old_start = Thread.start + try: + from django import VERSION as django_version # noqa: N811 + import channels # type: ignore[import-untyped] + + channels_version = channels.__version__ + except ImportError: + django_version = None + channels_version = None + + is_async_emulated_with_threads = ( + sys.version_info < (3, 9) + and channels_version is not None + and channels_version < "4.0.0" + and django_version is not None + and django_version >= (3, 0) + and django_version < (4, 0) + ) + @wraps(old_start) - def sentry_start(self, *a, **kw): - # type: (Thread, *Any, **Any) -> Any - hub = Hub.current - integration = hub.get_integration(ThreadingIntegration) - if integration is not None: - if not integration.propagate_hub: - hub_ = None + def sentry_start(self: "Thread", *a: "Any", **kw: "Any") -> "Any": + integration = sentry_sdk.get_client().get_integration(ThreadingIntegration) + if integration is None: + return old_start(self, *a, **kw) + + if integration.propagate_scope: + if is_async_emulated_with_threads: + warnings.warn( + "There is a known issue with Django channels 2.x and 3.x when using Python 3.8 or older. " + "(Async support is emulated using threads and some Sentry data may be leaked between those threads.) " + "Please either upgrade to Django channels 4.0+, use Django's async features " + "available in Django 3.1+ instead of Django channels, or upgrade to Python 3.9+.", + stacklevel=2, + ) + isolation_scope = sentry_sdk.get_isolation_scope() + current_scope = sentry_sdk.get_current_scope() + else: - hub_ = Hub(hub) - # Patching instance methods in `start()` creates a reference cycle if - # done in a naive way. See - # https://github.com/getsentry/sentry-python/pull/434 - # - # In threading module, using current_thread API will access current thread instance - # without holding it to avoid a reference cycle in an easier way. - with capture_internal_exceptions(): - new_run = _wrap_run(hub_, getattr(self.run, "__func__", self.run)) - self.run = new_run # type: ignore + isolation_scope = sentry_sdk.get_isolation_scope().fork() + current_scope = sentry_sdk.get_current_scope().fork() + else: + isolation_scope = None + current_scope = None + + # Patching instance methods in `start()` creates a reference cycle if + # done in a naive way. See + # https://github.com/getsentry/sentry-python/pull/434 + # + # In threading module, using current_thread API will access current thread instance + # without holding it to avoid a reference cycle in an easier way. + with capture_internal_exceptions(): + new_run = _wrap_run( + isolation_scope, + current_scope, + getattr(self.run, "__func__", self.run), + ) + self.run = new_run # type: ignore return old_start(self, *a, **kw) Thread.start = sentry_start # type: ignore + ThreadPoolExecutor.submit = _wrap_threadpool_executor_submit( # type: ignore + ThreadPoolExecutor.submit, is_async_emulated_with_threads + ) -def _wrap_run(parent_hub, old_run_func): - # type: (Optional[Hub], F) -> F +def _wrap_run( + isolation_scope_to_use: "Optional[sentry_sdk.Scope]", + current_scope_to_use: "Optional[sentry_sdk.Scope]", + old_run_func: "F", +) -> "F": @wraps(old_run_func) - def run(*a, **kw): - # type: (*Any, **Any) -> Any - hub = parent_hub or Hub.current - with hub: + def run(*a: "Any", **kw: "Any") -> "Any": + def _run_old_run_func() -> "Any": try: self = current_thread() - return old_run_func(self, *a, **kw) + return old_run_func(self, *a[1:], **kw) except Exception: reraise(*_capture_exception()) + if isolation_scope_to_use is not None and current_scope_to_use is not None: + with use_isolation_scope(isolation_scope_to_use): + with use_scope(current_scope_to_use): + return _run_old_run_func() + else: + return _run_old_run_func() + return run # type: ignore -def _capture_exception(): - # type: () -> ExcInfo - hub = Hub.current +def _wrap_threadpool_executor_submit( + func: "Callable[..., Future[T]]", is_async_emulated_with_threads: bool +) -> "Callable[..., Future[T]]": + """ + Wrap submit call to propagate scopes on task submission. + """ + + @wraps(func) + def sentry_submit( + self: "ThreadPoolExecutor", + fn: "Callable[..., T]", + *args: "Any", + **kwargs: "Any", + ) -> "Future[T]": + integration = sentry_sdk.get_client().get_integration(ThreadingIntegration) + if integration is None: + return func(self, fn, *args, **kwargs) + + if integration.propagate_scope and is_async_emulated_with_threads: + isolation_scope = sentry_sdk.get_isolation_scope() + current_scope = sentry_sdk.get_current_scope() + elif integration.propagate_scope: + isolation_scope = sentry_sdk.get_isolation_scope().fork() + current_scope = sentry_sdk.get_current_scope().fork() + else: + isolation_scope = None + current_scope = None + + def wrapped_fn(*args: "Any", **kwargs: "Any") -> "Any": + if isolation_scope is not None and current_scope is not None: + with use_isolation_scope(isolation_scope): + with use_scope(current_scope): + return fn(*args, **kwargs) + + return fn(*args, **kwargs) + + return func(self, wrapped_fn, *args, **kwargs) + + return sentry_submit + + +def _capture_exception() -> "ExcInfo": exc_info = sys.exc_info() - if hub.get_integration(ThreadingIntegration) is not None: - # If an integration is there, a client has to be there. - client = hub.client # type: Any - + client = sentry_sdk.get_client() + if client.get_integration(ThreadingIntegration) is not None: event, hint = event_from_exception( exc_info, client_options=client.options, mechanism={"type": "threading", "handled": False}, ) - hub.capture_event(event, hint=hint) + sentry_sdk.capture_event(event, hint=hint) return exc_info diff --git a/sentry_sdk/integrations/tornado.py b/sentry_sdk/integrations/tornado.py index 8af93c47f3..96a7629c53 100644 --- a/sentry_sdk/integrations/tornado.py +++ b/sentry_sdk/integrations/tornado.py @@ -2,28 +2,26 @@ import contextlib from inspect import iscoroutinefunction +import sentry_sdk from sentry_sdk.api import continue_trace from sentry_sdk.consts import OP -from sentry_sdk.hub import Hub, _should_send_default_pii -from sentry_sdk.tracing import ( - TRANSACTION_SOURCE_COMPONENT, - TRANSACTION_SOURCE_ROUTE, -) +from sentry_sdk.scope import should_send_default_pii +from sentry_sdk.tracing import TransactionSource from sentry_sdk.utils import ( HAS_REAL_CONTEXTVARS, CONTEXTVARS_ERROR_MESSAGE, + ensure_integration_enabled, event_from_exception, capture_internal_exceptions, transaction_from_function, ) -from sentry_sdk.integrations import Integration, DidNotEnable +from sentry_sdk.integrations import _check_minimum_version, Integration, DidNotEnable from sentry_sdk.integrations._wsgi_common import ( RequestExtractor, _filter_headers, _is_json_content_type, ) from sentry_sdk.integrations.logging import ignore_logger -from sentry_sdk._compat import iteritems try: from tornado import version_info as TORNADO_VERSION @@ -32,7 +30,7 @@ except ImportError: raise DidNotEnable("Tornado not installed") -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any @@ -41,17 +39,16 @@ from typing import Callable from typing import Generator - from sentry_sdk._types import EventProcessor + from sentry_sdk._types import Event, EventProcessor class TornadoIntegration(Integration): identifier = "tornado" + origin = f"auto.http.{identifier}" @staticmethod - def setup_once(): - # type: () -> None - if TORNADO_VERSION < (5, 0): - raise DidNotEnable("Tornado 5+ required") + def setup_once() -> None: + _check_minimum_version(TornadoIntegration, TORNADO_VERSION) if not HAS_REAL_CONTEXTVARS: # Tornado is async. We better have contextvars or we're going to leak @@ -70,16 +67,18 @@ def setup_once(): if awaitable: # Starting Tornado 6 RequestHandler._execute method is a standard Python coroutine (async/await) # In that case our method should be a coroutine function too - async def sentry_execute_request_handler(self, *args, **kwargs): - # type: (RequestHandler, *Any, **Any) -> Any + async def sentry_execute_request_handler( + self: "RequestHandler", *args: "Any", **kwargs: "Any" + ) -> "Any": with _handle_request_impl(self): return await old_execute(self, *args, **kwargs) else: @coroutine # type: ignore - def sentry_execute_request_handler(self, *args, **kwargs): - # type: (RequestHandler, *Any, **Any) -> Any + def sentry_execute_request_handler( + self: "RequestHandler", *args: "Any", **kwargs: "Any" + ) -> "Any": with _handle_request_impl(self): result = yield from old_execute(self, *args, **kwargs) return result @@ -88,8 +87,14 @@ def sentry_execute_request_handler(self, *args, **kwargs): old_log_exception = RequestHandler.log_exception - def sentry_log_exception(self, ty, value, tb, *args, **kwargs): - # type: (Any, type, BaseException, Any, *Any, **Any) -> Optional[Any] + def sentry_log_exception( + self: "Any", + ty: type, + value: BaseException, + tb: "Any", + *args: "Any", + **kwargs: "Any", + ) -> "Optional[Any]": _capture_exception(ty, value, tb) return old_log_exception(self, ty, value, tb, *args, **kwargs) @@ -97,23 +102,20 @@ def sentry_log_exception(self, ty, value, tb, *args, **kwargs): @contextlib.contextmanager -def _handle_request_impl(self): - # type: (RequestHandler) -> Generator[None, None, None] - hub = Hub.current - integration = hub.get_integration(TornadoIntegration) +def _handle_request_impl(self: "RequestHandler") -> "Generator[None, None, None]": + integration = sentry_sdk.get_client().get_integration(TornadoIntegration) if integration is None: yield weak_handler = weakref.ref(self) - with Hub(hub) as hub: + with sentry_sdk.isolation_scope() as scope: headers = self.request.headers - with hub.configure_scope() as scope: - scope.clear_breadcrumbs() - processor = _make_event_processor(weak_handler) - scope.add_event_processor(processor) + scope.clear_breadcrumbs() + processor = _make_event_processor(weak_handler) + scope.add_event_processor(processor) transaction = continue_trace( headers, @@ -123,39 +125,34 @@ def _handle_request_impl(self): # sentry_urldispatcher_resolve is responsible for # setting a transaction name later. name="generic Tornado request", - source=TRANSACTION_SOURCE_ROUTE, + source=TransactionSource.ROUTE, + origin=TornadoIntegration.origin, ) - with hub.start_transaction( + with sentry_sdk.start_transaction( transaction, custom_sampling_context={"tornado_request": self.request} ): yield -def _capture_exception(ty, value, tb): - # type: (type, BaseException, Any) -> None - hub = Hub.current - if hub.get_integration(TornadoIntegration) is None: - return +@ensure_integration_enabled(TornadoIntegration) +def _capture_exception(ty: type, value: BaseException, tb: "Any") -> None: if isinstance(value, HTTPError): return - # If an integration is there, a client has to be there. - client = hub.client # type: Any - event, hint = event_from_exception( (ty, value, tb), - client_options=client.options, + client_options=sentry_sdk.get_client().options, mechanism={"type": "tornado", "handled": False}, ) - hub.capture_event(event, hint=hint) + sentry_sdk.capture_event(event, hint=hint) -def _make_event_processor(weak_handler): - # type: (Callable[[], RequestHandler]) -> EventProcessor - def tornado_processor(event, hint): - # type: (Dict[str, Any], Dict[str, Any]) -> Dict[str, Any] +def _make_event_processor( + weak_handler: "Callable[[], RequestHandler]", +) -> "EventProcessor": + def tornado_processor(event: "Event", hint: "dict[str, Any]") -> "Event": handler = weak_handler() if handler is None: return event @@ -164,8 +161,8 @@ def tornado_processor(event, hint): with capture_internal_exceptions(): method = getattr(handler, handler.request.method.lower()) - event["transaction"] = transaction_from_function(method) - event["transaction_info"] = {"source": TRANSACTION_SOURCE_COMPONENT} + event["transaction"] = transaction_from_function(method) or "" + event["transaction_info"] = {"source": TransactionSource.COMPONENT} with capture_internal_exceptions(): extractor = TornadoRequestExtractor(request) @@ -184,8 +181,13 @@ def tornado_processor(event, hint): request_info["env"] = {"REMOTE_ADDR": request.remote_ip} request_info["headers"] = _filter_headers(dict(request.headers)) - with capture_internal_exceptions(): - if handler.current_user and _should_send_default_pii(): + if should_send_default_pii(): + try: + current_user = handler.current_user + except Exception: + current_user = None + + if current_user: event.setdefault("user", {}).setdefault("is_authenticated", True) return event @@ -194,35 +196,28 @@ def tornado_processor(event, hint): class TornadoRequestExtractor(RequestExtractor): - def content_length(self): - # type: () -> int + def content_length(self) -> int: if self.request.body is None: return 0 return len(self.request.body) - def cookies(self): - # type: () -> Dict[str, str] - return {k: v.value for k, v in iteritems(self.request.cookies)} + def cookies(self) -> "Dict[str, str]": + return {k: v.value for k, v in self.request.cookies.items()} - def raw_data(self): - # type: () -> bytes + def raw_data(self) -> bytes: return self.request.body - def form(self): - # type: () -> Dict[str, Any] + def form(self) -> "Dict[str, Any]": return { k: [v.decode("latin1", "replace") for v in vs] - for k, vs in iteritems(self.request.body_arguments) + for k, vs in self.request.body_arguments.items() } - def is_json(self): - # type: () -> bool + def is_json(self) -> bool: return _is_json_content_type(self.request.headers.get("content-type")) - def files(self): - # type: () -> Dict[str, Any] - return {k: v[0] for k, v in iteritems(self.request.files) if v} + def files(self) -> "Dict[str, Any]": + return {k: v[0] for k, v in self.request.files.items() if v} - def size_of_file(self, file): - # type: (Any) -> int + def size_of_file(self, file: "Any") -> int: return len(file.body or ()) diff --git a/sentry_sdk/integrations/trytond.py b/sentry_sdk/integrations/trytond.py index 6f1aff2f15..382e7385e2 100644 --- a/sentry_sdk/integrations/trytond.py +++ b/sentry_sdk/integrations/trytond.py @@ -1,45 +1,44 @@ -import sentry_sdk.hub -import sentry_sdk.utils -import sentry_sdk.integrations -import sentry_sdk.integrations.wsgi -from sentry_sdk._types import TYPE_CHECKING - -from trytond.exceptions import TrytonException # type: ignore -from trytond.wsgi import app # type: ignore - -if TYPE_CHECKING: - from typing import Any - +import sentry_sdk +from sentry_sdk.integrations import Integration +from sentry_sdk.integrations.wsgi import SentryWsgiMiddleware +from sentry_sdk.utils import ensure_integration_enabled, event_from_exception +from sentry_sdk.integrations import DidNotEnable + +try: + from trytond.exceptions import TrytonException # type: ignore + from trytond.wsgi import app # type: ignore +except ImportError: + raise DidNotEnable("Trytond is not installed.") # TODO: trytond-worker, trytond-cron and trytond-admin intergations -class TrytondWSGIIntegration(sentry_sdk.integrations.Integration): +class TrytondWSGIIntegration(Integration): identifier = "trytond_wsgi" + origin = f"auto.http.{identifier}" - def __init__(self): # type: () -> None + def __init__(self) -> None: pass @staticmethod - def setup_once(): # type: () -> None - app.wsgi_app = sentry_sdk.integrations.wsgi.SentryWsgiMiddleware(app.wsgi_app) - - def error_handler(e): # type: (Exception) -> None - hub = sentry_sdk.hub.Hub.current - - if hub.get_integration(TrytondWSGIIntegration) is None: - return - elif isinstance(e, TrytonException): + def setup_once() -> None: + app.wsgi_app = SentryWsgiMiddleware( + app.wsgi_app, + span_origin=TrytondWSGIIntegration.origin, + ) + + @ensure_integration_enabled(TrytondWSGIIntegration) + def error_handler(e: Exception) -> None: + if isinstance(e, TrytonException): return else: - # If an integration is there, a client has to be there. - client = hub.client # type: Any - event, hint = sentry_sdk.utils.event_from_exception( + client = sentry_sdk.get_client() + event, hint = event_from_exception( e, client_options=client.options, mechanism={"type": "trytond", "handled": False}, ) - hub.capture_event(event, hint=hint) + sentry_sdk.capture_event(event, hint=hint) # Expected error handlers signature was changed # when the error_handler decorator was introduced diff --git a/sentry_sdk/integrations/typer.py b/sentry_sdk/integrations/typer.py new file mode 100644 index 0000000000..be3d7d1d68 --- /dev/null +++ b/sentry_sdk/integrations/typer.py @@ -0,0 +1,61 @@ +import sentry_sdk +from sentry_sdk.utils import ( + capture_internal_exceptions, + event_from_exception, +) +from sentry_sdk.integrations import Integration, DidNotEnable + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Callable + from typing import Any + from typing import Type + from typing import Optional + + from types import TracebackType + + Excepthook = Callable[ + [Type[BaseException], BaseException, Optional[TracebackType]], + Any, + ] + +try: + import typer +except ImportError: + raise DidNotEnable("Typer not installed") + + +class TyperIntegration(Integration): + identifier = "typer" + + @staticmethod + def setup_once() -> None: + typer.main.except_hook = _make_excepthook(typer.main.except_hook) # type: ignore + + +def _make_excepthook(old_excepthook: "Excepthook") -> "Excepthook": + def sentry_sdk_excepthook( + type_: "Type[BaseException]", + value: BaseException, + traceback: "Optional[TracebackType]", + ) -> None: + integration = sentry_sdk.get_client().get_integration(TyperIntegration) + + # Note: If we replace this with ensure_integration_enabled then + # we break the exceptiongroup backport; + # See: https://github.com/getsentry/sentry-python/issues/3097 + if integration is None: + return old_excepthook(type_, value, traceback) + + with capture_internal_exceptions(): + event, hint = event_from_exception( + (type_, value, traceback), + client_options=sentry_sdk.get_client().options, + mechanism={"type": "typer", "handled": False}, + ) + sentry_sdk.capture_event(event, hint=hint) + + return old_excepthook(type_, value, traceback) + + return sentry_sdk_excepthook diff --git a/sentry_sdk/integrations/unleash.py b/sentry_sdk/integrations/unleash.py new file mode 100644 index 0000000000..304f5c3bd1 --- /dev/null +++ b/sentry_sdk/integrations/unleash.py @@ -0,0 +1,33 @@ +from functools import wraps +from typing import Any + +from sentry_sdk.feature_flags import add_feature_flag +from sentry_sdk.integrations import Integration, DidNotEnable + +try: + from UnleashClient import UnleashClient +except ImportError: + raise DidNotEnable("UnleashClient is not installed") + + +class UnleashIntegration(Integration): + identifier = "unleash" + + @staticmethod + def setup_once() -> None: + # Wrap and patch evaluation methods (class methods) + old_is_enabled = UnleashClient.is_enabled + + @wraps(old_is_enabled) + def sentry_is_enabled( + self: "UnleashClient", feature: str, *args: "Any", **kwargs: "Any" + ) -> "Any": + enabled = old_is_enabled(self, feature, *args, **kwargs) + + # We have no way of knowing what type of unleash feature this is, so we have to treat + # it as a boolean / toggle feature. + add_feature_flag(feature, enabled) + + return enabled + + UnleashClient.is_enabled = sentry_is_enabled # type: ignore diff --git a/sentry_sdk/integrations/unraisablehook.py b/sentry_sdk/integrations/unraisablehook.py new file mode 100644 index 0000000000..61ef8a008c --- /dev/null +++ b/sentry_sdk/integrations/unraisablehook.py @@ -0,0 +1,52 @@ +import sys + +import sentry_sdk +from sentry_sdk.utils import ( + capture_internal_exceptions, + event_from_exception, +) +from sentry_sdk.integrations import Integration + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Callable + from typing import Any + + +class UnraisablehookIntegration(Integration): + identifier = "unraisablehook" + + @staticmethod + def setup_once() -> None: + sys.unraisablehook = _make_unraisable(sys.unraisablehook) + + +def _make_unraisable( + old_unraisablehook: "Callable[[sys.UnraisableHookArgs], Any]", +) -> "Callable[[sys.UnraisableHookArgs], Any]": + def sentry_sdk_unraisablehook(unraisable: "sys.UnraisableHookArgs") -> None: + integration = sentry_sdk.get_client().get_integration(UnraisablehookIntegration) + + # Note: If we replace this with ensure_integration_enabled then + # we break the exceptiongroup backport; + # See: https://github.com/getsentry/sentry-python/issues/3097 + if integration is None: + return old_unraisablehook(unraisable) + + if unraisable.exc_value and unraisable.exc_traceback: + with capture_internal_exceptions(): + event, hint = event_from_exception( + ( + unraisable.exc_type, + unraisable.exc_value, + unraisable.exc_traceback, + ), + client_options=sentry_sdk.get_client().options, + mechanism={"type": "unraisablehook", "handled": False}, + ) + sentry_sdk.capture_event(event, hint=hint) + + return old_unraisablehook(unraisable) + + return sentry_sdk_unraisablehook diff --git a/sentry_sdk/integrations/wsgi.py b/sentry_sdk/integrations/wsgi.py index 0d53766efb..1576e21a17 100644 --- a/sentry_sdk/integrations/wsgi.py +++ b/sentry_sdk/integrations/wsgi.py @@ -1,135 +1,154 @@ import sys +from functools import partial +from typing import TYPE_CHECKING -from sentry_sdk._compat import PY2, reraise -from sentry_sdk._functools import partial -from sentry_sdk._types import TYPE_CHECKING -from sentry_sdk._werkzeug import get_host, _get_headers +import sentry_sdk +from sentry_sdk._werkzeug import _get_headers, get_host from sentry_sdk.api import continue_trace from sentry_sdk.consts import OP -from sentry_sdk.hub import Hub, _should_send_default_pii +from sentry_sdk.integrations._wsgi_common import ( + DEFAULT_HTTP_METHODS_TO_CAPTURE, + _filter_headers, + nullcontext, +) +from sentry_sdk.scope import should_send_default_pii, use_isolation_scope +from sentry_sdk.sessions import track_session +from sentry_sdk.tracing import Transaction, TransactionSource from sentry_sdk.utils import ( ContextVar, capture_internal_exceptions, event_from_exception, + reraise, ) -from sentry_sdk.tracing import Transaction, TRANSACTION_SOURCE_ROUTE -from sentry_sdk.sessions import auto_session_tracking -from sentry_sdk.integrations._wsgi_common import _filter_headers if TYPE_CHECKING: - from typing import Callable - from typing import Dict - from typing import Iterator - from typing import Any - from typing import Tuple - from typing import Optional - from typing import TypeVar - from typing import Protocol + from typing import Any, Callable, Dict, Iterator, Optional, Protocol, Tuple, TypeVar + from sentry_sdk._types import Event, EventProcessor from sentry_sdk.utils import ExcInfo - from sentry_sdk._types import EventProcessor WsgiResponseIter = TypeVar("WsgiResponseIter") WsgiResponseHeaders = TypeVar("WsgiResponseHeaders") WsgiExcInfo = TypeVar("WsgiExcInfo") class StartResponse(Protocol): - def __call__(self, status, response_headers, exc_info=None): # type: ignore - # type: (str, WsgiResponseHeaders, Optional[WsgiExcInfo]) -> WsgiResponseIter + def __call__( + self, + status: str, + response_headers: "WsgiResponseHeaders", + exc_info: "Optional[WsgiExcInfo]" = None, + ) -> "WsgiResponseIter": # type: ignore pass _wsgi_middleware_applied = ContextVar("sentry_wsgi_middleware_applied") -if PY2: - - def wsgi_decoding_dance(s, charset="utf-8", errors="replace"): - # type: (str, str, str) -> str - return s.decode(charset, errors) - -else: - - def wsgi_decoding_dance(s, charset="utf-8", errors="replace"): - # type: (str, str, str) -> str - return s.encode("latin1").decode(charset, errors) +def wsgi_decoding_dance(s: str, charset: str = "utf-8", errors: str = "replace") -> str: + return s.encode("latin1").decode(charset, errors) -def get_request_url(environ, use_x_forwarded_for=False): - # type: (Dict[str, str], bool) -> str +def get_request_url( + environ: "Dict[str, str]", use_x_forwarded_for: bool = False +) -> str: """Return the absolute URL without query string for the given WSGI environment.""" + script_name = environ.get("SCRIPT_NAME", "").rstrip("/") + path_info = environ.get("PATH_INFO", "").lstrip("/") + path = f"{script_name}/{path_info}" + return "%s://%s/%s" % ( environ.get("wsgi.url_scheme"), get_host(environ, use_x_forwarded_for), - wsgi_decoding_dance(environ.get("PATH_INFO") or "").lstrip("/"), + wsgi_decoding_dance(path).lstrip("/"), ) -class SentryWsgiMiddleware(object): - __slots__ = ("app", "use_x_forwarded_for") +class SentryWsgiMiddleware: + __slots__ = ( + "app", + "use_x_forwarded_for", + "span_origin", + "http_methods_to_capture", + ) - def __init__(self, app, use_x_forwarded_for=False): - # type: (Callable[[Dict[str, str], Callable[..., Any]], Any], bool) -> None + def __init__( + self, + app: "Callable[[Dict[str, str], Callable[..., Any]], Any]", + use_x_forwarded_for: bool = False, + span_origin: str = "manual", + http_methods_to_capture: "Tuple[str, ...]" = DEFAULT_HTTP_METHODS_TO_CAPTURE, + ) -> None: self.app = app self.use_x_forwarded_for = use_x_forwarded_for + self.span_origin = span_origin + self.http_methods_to_capture = http_methods_to_capture - def __call__(self, environ, start_response): - # type: (Dict[str, str], Callable[..., Any]) -> _ScopedResponse + def __call__( + self, environ: "Dict[str, str]", start_response: "Callable[..., Any]" + ) -> "_ScopedResponse": if _wsgi_middleware_applied.get(False): return self.app(environ, start_response) _wsgi_middleware_applied.set(True) try: - hub = Hub(Hub.current) - with auto_session_tracking(hub, session_mode="request"): - with hub: + with sentry_sdk.isolation_scope() as scope: + with track_session(scope, session_mode="request"): with capture_internal_exceptions(): - with hub.configure_scope() as scope: - scope.clear_breadcrumbs() - scope._name = "wsgi" - scope.add_event_processor( - _make_wsgi_event_processor( - environ, self.use_x_forwarded_for - ) + scope.clear_breadcrumbs() + scope._name = "wsgi" + scope.add_event_processor( + _make_wsgi_event_processor( + environ, self.use_x_forwarded_for ) - - transaction = continue_trace( - environ, - op=OP.HTTP_SERVER, - name="generic WSGI request", - source=TRANSACTION_SOURCE_ROUTE, + ) + + method = environ.get("REQUEST_METHOD", "").upper() + transaction = None + if method in self.http_methods_to_capture: + transaction = continue_trace( + environ, + op=OP.HTTP_SERVER, + name="generic WSGI request", + source=TransactionSource.ROUTE, + origin=self.span_origin, + ) + + transaction_context = ( + sentry_sdk.start_transaction( + transaction, + custom_sampling_context={"wsgi_environ": environ}, + ) + if transaction is not None + else nullcontext() ) - - with hub.start_transaction( - transaction, custom_sampling_context={"wsgi_environ": environ} - ): + with transaction_context: try: - rv = self.app( + response = self.app( environ, partial( _sentry_start_response, start_response, transaction ), ) except BaseException: - reraise(*_capture_exception(hub)) + reraise(*_capture_exception()) finally: _wsgi_middleware_applied.set(False) - return _ScopedResponse(hub, rv) + return _ScopedResponse(scope, response) -def _sentry_start_response( # type: ignore - old_start_response, # type: StartResponse - transaction, # type: Transaction - status, # type: str - response_headers, # type: WsgiResponseHeaders - exc_info=None, # type: Optional[WsgiExcInfo] -): - # type: (...) -> WsgiResponseIter +def _sentry_start_response( + old_start_response: "StartResponse", + transaction: "Optional[Transaction]", + status: str, + response_headers: "WsgiResponseHeaders", + exc_info: "Optional[WsgiExcInfo]" = None, +) -> "WsgiResponseIter": # type: ignore[type-var] with capture_internal_exceptions(): status_int = int(status.split(" ", 1)[0]) - transaction.set_http_status(status_int) + if transaction is not None: + transaction.set_http_status(status_int) if exc_info is None: # The Django Rest Framework WSGI test client, and likely other @@ -140,14 +159,13 @@ def _sentry_start_response( # type: ignore return old_start_response(status, response_headers, exc_info) -def _get_environ(environ): - # type: (Dict[str, str]) -> Iterator[Tuple[str, str]] +def _get_environ(environ: "Dict[str, str]") -> "Iterator[Tuple[str, str]]": """ Returns our explicitly included environment variables we want to capture (server name, port and remote addr if pii is enabled). """ keys = ["SERVER_NAME", "SERVER_PORT"] - if _should_send_default_pii(): + if should_send_default_pii(): # make debugging of proxy setup easier. Proxy headers are # in headers. keys += ["REMOTE_ADDR"] @@ -157,8 +175,7 @@ def _get_environ(environ): yield key, environ[key] -def get_client_ip(environ): - # type: (Dict[str, str]) -> Optional[Any] +def get_client_ip(environ: "Dict[str, str]") -> "Optional[Any]": """ Infer the user IP address from various headers. This cannot be used in security sensitive situations since the value may be forged from a client, @@ -177,67 +194,77 @@ def get_client_ip(environ): return environ.get("REMOTE_ADDR") -def _capture_exception(hub): - # type: (Hub) -> ExcInfo +def _capture_exception() -> "ExcInfo": + """ + Captures the current exception and sends it to Sentry. + Returns the ExcInfo tuple to it can be reraised afterwards. + """ exc_info = sys.exc_info() + e = exc_info[1] + + # SystemExit(0) is the only uncaught exception that is expected behavior + should_skip_capture = isinstance(e, SystemExit) and e.code in (0, None) + if not should_skip_capture: + event, hint = event_from_exception( + exc_info, + client_options=sentry_sdk.get_client().options, + mechanism={"type": "wsgi", "handled": False}, + ) + sentry_sdk.capture_event(event, hint=hint) - # Check client here as it might have been unset while streaming response - if hub.client is not None: - e = exc_info[1] + return exc_info - # SystemExit(0) is the only uncaught exception that is expected behavior - should_skip_capture = isinstance(e, SystemExit) and e.code in (0, None) - if not should_skip_capture: - event, hint = event_from_exception( - exc_info, - client_options=hub.client.options, - mechanism={"type": "wsgi", "handled": False}, - ) - hub.capture_event(event, hint=hint) - return exc_info +class _ScopedResponse: + """ + Users a separate scope for each response chunk. + This will make WSGI apps more tolerant against: + - WSGI servers streaming responses from a different thread/from + different threads than the one that called start_response + - close() not being called + - WSGI servers streaming responses interleaved from the same thread + """ -class _ScopedResponse(object): - __slots__ = ("_response", "_hub") + __slots__ = ("_response", "_scope") - def __init__(self, hub, response): - # type: (Hub, Iterator[bytes]) -> None - self._hub = hub + def __init__( + self, scope: "sentry_sdk.scope.Scope", response: "Iterator[bytes]" + ) -> None: + self._scope = scope self._response = response - def __iter__(self): - # type: () -> Iterator[bytes] + def __iter__(self) -> "Iterator[bytes]": iterator = iter(self._response) while True: - with self._hub: + with use_isolation_scope(self._scope): try: chunk = next(iterator) except StopIteration: break except BaseException: - reraise(*_capture_exception(self._hub)) + reraise(*_capture_exception()) yield chunk - def close(self): - # type: () -> None - with self._hub: + def close(self) -> None: + with use_isolation_scope(self._scope): try: self._response.close() # type: ignore except AttributeError: pass except BaseException: - reraise(*_capture_exception(self._hub)) + reraise(*_capture_exception()) -def _make_wsgi_event_processor(environ, use_x_forwarded_for): - # type: (Dict[str, str], bool) -> EventProcessor +def _make_wsgi_event_processor( + environ: "Dict[str, str]", use_x_forwarded_for: bool +) -> "EventProcessor": # It's a bit unfortunate that we have to extract and parse the request data # from the environ so eagerly, but there are a few good reasons for this. # - # We might be in a situation where the scope/hub never gets torn down + # We might be in a situation where the scope never gets torn down # properly. In that case we will have an unnecessary strong reference to # all objects in the environ (some of which may take a lot of memory) when # we're really just interested in a few of them. @@ -253,13 +280,12 @@ def _make_wsgi_event_processor(environ, use_x_forwarded_for): env = dict(_get_environ(environ)) headers = _filter_headers(dict(_get_headers(environ))) - def event_processor(event, hint): - # type: (Dict[str, Any], Dict[str, Any]) -> Dict[str, Any] + def event_processor(event: "Event", hint: "Dict[str, Any]") -> "Event": with capture_internal_exceptions(): # if the code below fails halfway through we at least have some data request_info = event.setdefault("request", {}) - if _should_send_default_pii(): + if should_send_default_pii(): user_info = event.setdefault("user", {}) if client_ip: user_info.setdefault("ip_address", client_ip) diff --git a/sentry_sdk/logger.py b/sentry_sdk/logger.py new file mode 100644 index 0000000000..4a90fef70b --- /dev/null +++ b/sentry_sdk/logger.py @@ -0,0 +1,88 @@ +# NOTE: this is the logger sentry exposes to users, not some generic logger. +import functools +import time +from typing import Any, TYPE_CHECKING + +import sentry_sdk +from sentry_sdk.utils import format_attribute, safe_repr, capture_internal_exceptions + +if TYPE_CHECKING: + from sentry_sdk._types import Attributes, Log + + +OTEL_RANGES = [ + # ((severity level range), severity text) + # https://opentelemetry.io/docs/specs/otel/logs/data-model + ((1, 4), "trace"), + ((5, 8), "debug"), + ((9, 12), "info"), + ((13, 16), "warn"), + ((17, 20), "error"), + ((21, 24), "fatal"), +] + + +class _dict_default_key(dict): # type: ignore[type-arg] + """dict that returns the key if missing.""" + + def __missing__(self, key: str) -> str: + return "{" + key + "}" + + +def _capture_log( + severity_text: str, severity_number: int, template: str, **kwargs: "Any" +) -> None: + body = template + + attributes: "Attributes" = {} + + if "attributes" in kwargs: + provided_attributes = kwargs.pop("attributes") or {} + for attribute, value in provided_attributes.items(): + attributes[attribute] = format_attribute(value) + + for k, v in kwargs.items(): + attributes[f"sentry.message.parameter.{k}"] = format_attribute(v) + + if kwargs: + # only attach template if there are parameters + attributes["sentry.message.template"] = format_attribute(template) + + with capture_internal_exceptions(): + body = template.format_map(_dict_default_key(kwargs)) + + sentry_sdk.get_current_scope()._capture_log( + { + "severity_text": severity_text, + "severity_number": severity_number, + "attributes": attributes, + "body": body, + "time_unix_nano": time.time_ns(), + "trace_id": None, + "span_id": None, + } + ) + + +trace = functools.partial(_capture_log, "trace", 1) +debug = functools.partial(_capture_log, "debug", 5) +info = functools.partial(_capture_log, "info", 9) +warning = functools.partial(_capture_log, "warn", 13) +error = functools.partial(_capture_log, "error", 17) +fatal = functools.partial(_capture_log, "fatal", 21) + + +def _otel_severity_text(otel_severity_number: int) -> str: + for (lower, upper), severity in OTEL_RANGES: + if lower <= otel_severity_number <= upper: + return severity + + return "default" + + +def _log_level_to_otel(level: int, mapping: "dict[Any, int]") -> "tuple[int, str]": + for py_level, otel_severity_number in sorted(mapping.items(), reverse=True): + if level >= py_level: + return otel_severity_number, _otel_severity_text(otel_severity_number) + + return 0, "default" diff --git a/sentry_sdk/metrics.py b/sentry_sdk/metrics.py index 5230391f9e..30f8191126 100644 --- a/sentry_sdk/metrics.py +++ b/sentry_sdk/metrics.py @@ -1,650 +1,67 @@ -import os -import io -import re -import threading -import random +""" +NOTE: This file contains experimental code that may be changed or removed at any +time without prior notice. +""" + import time -import zlib -from functools import wraps, partial -from threading import Event, Lock, Thread -from contextlib import contextmanager +from typing import Any, Optional, TYPE_CHECKING, Union -from sentry_sdk._compat import text_type -from sentry_sdk.hub import Hub -from sentry_sdk.utils import now, nanosecond_time -from sentry_sdk.envelope import Envelope, Item -from sentry_sdk.tracing import ( - TRANSACTION_SOURCE_ROUTE, - TRANSACTION_SOURCE_VIEW, - TRANSACTION_SOURCE_COMPONENT, - TRANSACTION_SOURCE_TASK, -) -from sentry_sdk._types import TYPE_CHECKING +import sentry_sdk +from sentry_sdk.utils import format_attribute, safe_repr if TYPE_CHECKING: - from typing import Any - from typing import Dict - from typing import Iterable - from typing import Callable - from typing import Optional - from typing import Generator - from typing import Tuple - - from sentry_sdk._types import BucketKey - from sentry_sdk._types import DurationUnit - from sentry_sdk._types import FlushedMetricValue - from sentry_sdk._types import MeasurementUnit - from sentry_sdk._types import MetricTagValue - from sentry_sdk._types import MetricTags - from sentry_sdk._types import MetricTagsInternal - from sentry_sdk._types import MetricType - from sentry_sdk._types import MetricValue - - -_thread_local = threading.local() -_sanitize_key = partial(re.compile(r"[^a-zA-Z0-9_/.-]+").sub, "_") -_sanitize_value = partial(re.compile(r"[^\w\d_:/@\.{}\[\]$-]+", re.UNICODE).sub, "_") - -GOOD_TRANSACTION_SOURCES = frozenset( - [ - TRANSACTION_SOURCE_ROUTE, - TRANSACTION_SOURCE_VIEW, - TRANSACTION_SOURCE_COMPONENT, - TRANSACTION_SOURCE_TASK, - ] -) - - -@contextmanager -def recursion_protection(): - # type: () -> Generator[bool, None, None] - """Enters recursion protection and returns the old flag.""" - try: - in_metrics = _thread_local.in_metrics - except AttributeError: - in_metrics = False - _thread_local.in_metrics = True - try: - yield in_metrics - finally: - _thread_local.in_metrics = in_metrics - - -def metrics_noop(func): - # type: (Any) -> Any - """Convenient decorator that uses `recursion_protection` to - make a function a noop. - """ - - @wraps(func) - def new_func(*args, **kwargs): - # type: (*Any, **Any) -> Any - with recursion_protection() as in_metrics: - if not in_metrics: - return func(*args, **kwargs) - - return new_func - - -class Metric(object): - __slots__ = () - - @property - def weight(self): - # type: (...) -> int - raise NotImplementedError() - - def add( - self, value # type: MetricValue - ): - # type: (...) -> None - raise NotImplementedError() - - def serialize_value(self): - # type: (...) -> Iterable[FlushedMetricValue] - raise NotImplementedError() - - -class CounterMetric(Metric): - __slots__ = ("value",) - - def __init__( - self, first # type: MetricValue - ): - # type: (...) -> None - self.value = float(first) - - @property - def weight(self): - # type: (...) -> int - return 1 - - def add( - self, value # type: MetricValue - ): - # type: (...) -> None - self.value += float(value) - - def serialize_value(self): - # type: (...) -> Iterable[FlushedMetricValue] - return (self.value,) - - -class GaugeMetric(Metric): - __slots__ = ( - "last", - "min", - "max", - "sum", - "count", - ) - - def __init__( - self, first # type: MetricValue - ): - # type: (...) -> None - first = float(first) - self.last = first - self.min = first - self.max = first - self.sum = first - self.count = 1 - - @property - def weight(self): - # type: (...) -> int - # Number of elements. - return 5 - - def add( - self, value # type: MetricValue - ): - # type: (...) -> None - value = float(value) - self.last = value - self.min = min(self.min, value) - self.max = max(self.max, value) - self.sum += value - self.count += 1 - - def serialize_value(self): - # type: (...) -> Iterable[FlushedMetricValue] - return ( - self.last, - self.min, - self.max, - self.sum, - self.count, - ) - - -class DistributionMetric(Metric): - __slots__ = ("value",) - - def __init__( - self, first # type: MetricValue - ): - # type(...) -> None - self.value = [float(first)] - - @property - def weight(self): - # type: (...) -> int - return len(self.value) - - def add( - self, value # type: MetricValue - ): - # type: (...) -> None - self.value.append(float(value)) - - def serialize_value(self): - # type: (...) -> Iterable[FlushedMetricValue] - return self.value - - -class SetMetric(Metric): - __slots__ = ("value",) - - def __init__( - self, first # type: MetricValue - ): - # type: (...) -> None - self.value = {first} - - @property - def weight(self): - # type: (...) -> int - return len(self.value) - - def add( - self, value # type: MetricValue - ): - # type: (...) -> None - self.value.add(value) - - def serialize_value(self): - # type: (...) -> Iterable[FlushedMetricValue] - def _hash(x): - # type: (MetricValue) -> int - if isinstance(x, str): - return zlib.crc32(x.encode("utf-8")) & 0xFFFFFFFF - return int(x) - - return (_hash(value) for value in self.value) - - -def _encode_metrics(flushable_buckets): - # type: (Iterable[Tuple[int, Dict[BucketKey, Metric]]]) -> bytes - out = io.BytesIO() - _write = out.write - - # Note on sanetization: we intentionally sanetize in emission (serialization) - # and not during aggregation for performance reasons. This means that the - # envelope can in fact have duplicate buckets stored. This is acceptable for - # relay side emission and should not happen commonly. - - for timestamp, buckets in flushable_buckets: - for bucket_key, metric in buckets.items(): - metric_type, metric_name, metric_unit, metric_tags = bucket_key - metric_name = _sanitize_key(metric_name) - _write(metric_name.encode("utf-8")) - _write(b"@") - _write(metric_unit.encode("utf-8")) - - for serialized_value in metric.serialize_value(): - _write(b":") - _write(str(serialized_value).encode("utf-8")) - - _write(b"|") - _write(metric_type.encode("ascii")) - - if metric_tags: - _write(b"|#") - first = True - for tag_key, tag_value in metric_tags: - tag_key = _sanitize_key(tag_key) - if not tag_key: - continue - if first: - first = False - else: - _write(b",") - _write(tag_key.encode("utf-8")) - _write(b":") - _write(_sanitize_value(tag_value).encode("utf-8")) - - _write(b"|T") - _write(str(timestamp).encode("ascii")) - _write(b"\n") + from sentry_sdk._types import Attributes, Metric, MetricType - return out.getvalue() +def _capture_metric( + name: str, + metric_type: "MetricType", + value: float, + unit: "Optional[str]" = None, + attributes: "Optional[Attributes]" = None, +) -> None: + attrs: "Attributes" = {} -METRIC_TYPES = { - "c": CounterMetric, - "g": GaugeMetric, - "d": DistributionMetric, - "s": SetMetric, -} + if attributes: + for k, v in attributes.items(): + attrs[k] = format_attribute(v) -# some of these are dumb -TIMING_FUNCTIONS = { - "nanosecond": nanosecond_time, - "microsecond": lambda: nanosecond_time() / 1000.0, - "millisecond": lambda: nanosecond_time() / 1000000.0, - "second": now, - "minute": lambda: now() / 60.0, - "hour": lambda: now() / 3600.0, - "day": lambda: now() / 3600.0 / 24.0, - "week": lambda: now() / 3600.0 / 24.0 / 7.0, -} + metric: "Metric" = { + "timestamp": time.time(), + "trace_id": None, + "span_id": None, + "name": name, + "type": metric_type, + "value": float(value), + "unit": unit, + "attributes": attrs, + } + sentry_sdk.get_current_scope()._capture_metric(metric) -class MetricsAggregator(object): - ROLLUP_IN_SECONDS = 10.0 - MAX_WEIGHT = 100000 - FLUSHER_SLEEP_TIME = 5.0 - def __init__( - self, - capture_func, # type: Callable[[Envelope], None] - ): - # type: (...) -> None - self.buckets = {} # type: Dict[int, Any] - self._buckets_total_weight = 0 - self._capture_func = capture_func - self._lock = Lock() - self._running = True - self._flush_event = Event() - self._force_flush = False +def count( + name: str, + value: float, + unit: "Optional[str]" = None, + attributes: "Optional[dict[str, Any]]" = None, +) -> None: + _capture_metric(name, "counter", value, unit, attributes) - # The aggregator shifts it's flushing by up to an entire rollup window to - # avoid multiple clients trampling on end of a 10 second window as all the - # buckets are anchored to multiples of ROLLUP seconds. We randomize this - # number once per aggregator boot to achieve some level of offsetting - # across a fleet of deployed SDKs. Relay itself will also apply independent - # jittering. - self._flush_shift = random.random() * self.ROLLUP_IN_SECONDS - self._flusher = None # type: Optional[Thread] - self._flusher_pid = None # type: Optional[int] - self._ensure_thread() - - def _ensure_thread(self): - # type: (...) -> None - """For forking processes we might need to restart this thread. - This ensures that our process actually has that thread running. - """ - pid = os.getpid() - if self._flusher_pid == pid: - return - with self._lock: - self._flusher_pid = pid - self._flusher = Thread(target=self._flush_loop) - self._flusher.daemon = True - self._flusher.start() - - def _flush_loop(self): - # type: (...) -> None - _thread_local.in_metrics = True - while self._running or self._force_flush: - self._flush() - if self._running: - self._flush_event.wait(self.FLUSHER_SLEEP_TIME) - - def _flush(self): - # type: (...) -> None - flushable_buckets = self._flushable_buckets() - if flushable_buckets: - self._emit(flushable_buckets) - - def _flushable_buckets(self): - # type: (...) -> (Iterable[Tuple[int, Dict[BucketKey, Metric]]]) - with self._lock: - force_flush = self._force_flush - cutoff = time.time() - self.ROLLUP_IN_SECONDS - self._flush_shift - flushable_buckets = () # type: Iterable[Tuple[int, Dict[BucketKey, Metric]]] - weight_to_remove = 0 - - if force_flush: - flushable_buckets = self.buckets.items() - self.buckets = {} - self._buckets_total_weight = 0 - self._force_flush = False - else: - flushable_buckets = [] - for buckets_timestamp, buckets in self.buckets.items(): - # If the timestamp of the bucket is newer that the rollup we want to skip it. - if buckets_timestamp <= cutoff: - flushable_buckets.append((buckets_timestamp, buckets)) - - # We will clear the elements while holding the lock, in order to avoid requesting it downstream again. - for buckets_timestamp, buckets in flushable_buckets: - for _, metric in buckets.items(): - weight_to_remove += metric.weight - del self.buckets[buckets_timestamp] - - self._buckets_total_weight -= weight_to_remove - - return flushable_buckets - - @metrics_noop - def add( - self, - ty, # type: MetricType - key, # type: str - value, # type: MetricValue - unit, # type: MeasurementUnit - tags, # type: Optional[MetricTags] - timestamp=None, # type: Optional[float] - ): - # type: (...) -> None - self._ensure_thread() - - if self._flusher is None: - return - - if timestamp is None: - timestamp = time.time() - - bucket_timestamp = int( - (timestamp // self.ROLLUP_IN_SECONDS) * self.ROLLUP_IN_SECONDS - ) - bucket_key = ( - ty, - key, - unit, - self._serialize_tags(tags), - ) - - with self._lock: - local_buckets = self.buckets.setdefault(bucket_timestamp, {}) - metric = local_buckets.get(bucket_key) - if metric is not None: - previous_weight = metric.weight - metric.add(value) - else: - metric = local_buckets[bucket_key] = METRIC_TYPES[ty](value) - previous_weight = 0 - - self._buckets_total_weight += metric.weight - previous_weight - - # Given the new weight we consider whether we want to force flush. - self._consider_force_flush() - - def kill(self): - # type: (...) -> None - if self._flusher is None: - return - - self._running = False - self._flush_event.set() - self._flusher.join() - self._flusher = None - - @metrics_noop - def flush(self): - # type: (...) -> None - self._force_flush = True - self._flush() - - def _consider_force_flush(self): - # type: (...) -> None - # It's important to acquire a lock around this method, since it will touch shared data structures. - total_weight = len(self.buckets) + self._buckets_total_weight - if total_weight >= self.MAX_WEIGHT: - self._force_flush = True - self._flush_event.set() - - def _emit( - self, - flushable_buckets, # type: (Iterable[Tuple[int, Dict[BucketKey, Metric]]]) - ): - # type: (...) -> Envelope - encoded_metrics = _encode_metrics(flushable_buckets) - metric_item = Item(payload=encoded_metrics, type="statsd") - envelope = Envelope(items=[metric_item]) - self._capture_func(envelope) - return envelope - - def _serialize_tags( - self, tags # type: Optional[MetricTags] - ): - # type: (...) -> MetricTagsInternal - if not tags: - return () - - rv = [] - for key, value in tags.items(): - # If the value is a collection, we want to flatten it. - if isinstance(value, (list, tuple)): - for inner_value in value: - if inner_value is not None: - rv.append((key, text_type(inner_value))) - elif value is not None: - rv.append((key, text_type(value))) - - # It's very important to sort the tags in order to obtain the - # same bucket key. - return tuple(sorted(rv)) - - -def _get_aggregator_and_update_tags(key, tags): - # type: (str, Optional[MetricTags]) -> Tuple[Optional[MetricsAggregator], Optional[MetricTags]] - """Returns the current metrics aggregator if there is one.""" - hub = Hub.current - client = hub.client - if client is None or client.metrics_aggregator is None: - return None, tags - - updated_tags = dict(tags or ()) # type: Dict[str, MetricTagValue] - updated_tags.setdefault("release", client.options["release"]) - updated_tags.setdefault("environment", client.options["environment"]) - - scope = hub.scope - transaction_source = scope._transaction_info.get("source") - if transaction_source in GOOD_TRANSACTION_SOURCES: - transaction = scope._transaction - if transaction: - updated_tags.setdefault("transaction", transaction) - - callback = client.options.get("_experiments", {}).get("before_emit_metric") - if callback is not None: - with recursion_protection() as in_metrics: - if not in_metrics: - if not callback(key, updated_tags): - return None, updated_tags - - return client.metrics_aggregator, updated_tags - - -def incr( - key, # type: str - value=1.0, # type: float - unit="none", # type: MeasurementUnit - tags=None, # type: Optional[MetricTags] - timestamp=None, # type: Optional[float] -): - # type: (...) -> None - """Increments a counter.""" - aggregator, tags = _get_aggregator_and_update_tags(key, tags) - if aggregator is not None: - aggregator.add("c", key, value, unit, tags, timestamp) - - -class _Timing(object): - def __init__( - self, - key, # type: str - tags, # type: Optional[MetricTags] - timestamp, # type: Optional[float] - value, # type: Optional[float] - unit, # type: DurationUnit - ): - # type: (...) -> None - self.key = key - self.tags = tags - self.timestamp = timestamp - self.value = value - self.unit = unit - self.entered = None # type: Optional[float] - - def _validate_invocation(self, context): - # type: (str) -> None - if self.value is not None: - raise TypeError( - "cannot use timing as %s when a value is provided" % context - ) - - def __enter__(self): - # type: (...) -> _Timing - self.entered = TIMING_FUNCTIONS[self.unit]() - self._validate_invocation("context-manager") - return self - - def __exit__(self, exc_type, exc_value, tb): - # type: (Any, Any, Any) -> None - aggregator, tags = _get_aggregator_and_update_tags(self.key, self.tags) - if aggregator is not None: - elapsed = TIMING_FUNCTIONS[self.unit]() - self.entered # type: ignore - aggregator.add("d", self.key, elapsed, self.unit, tags, self.timestamp) - - def __call__(self, f): - # type: (Any) -> Any - self._validate_invocation("decorator") - - @wraps(f) - def timed_func(*args, **kwargs): - # type: (*Any, **Any) -> Any - with timing( - key=self.key, tags=self.tags, timestamp=self.timestamp, unit=self.unit - ): - return f(*args, **kwargs) - - return timed_func - - -def timing( - key, # type: str - value=None, # type: Optional[float] - unit="second", # type: DurationUnit - tags=None, # type: Optional[MetricTags] - timestamp=None, # type: Optional[float] -): - # type: (...) -> _Timing - """Emits a distribution with the time it takes to run the given code block. - - This method supports three forms of invocation: - - - when a `value` is provided, it functions similar to `distribution` but with - - it can be used as a context manager - - it can be used as a decorator - """ - if value is not None: - aggregator, tags = _get_aggregator_and_update_tags(key, tags) - if aggregator is not None: - aggregator.add("d", key, value, unit, tags, timestamp) - return _Timing(key, tags, timestamp, value, unit) +def gauge( + name: str, + value: float, + unit: "Optional[str]" = None, + attributes: "Optional[dict[str, Any]]" = None, +) -> None: + _capture_metric(name, "gauge", value, unit, attributes) def distribution( - key, # type: str - value, # type: float - unit="none", # type: MeasurementUnit - tags=None, # type: Optional[MetricTags] - timestamp=None, # type: Optional[float] -): - # type: (...) -> None - """Emits a distribution.""" - aggregator, tags = _get_aggregator_and_update_tags(key, tags) - if aggregator is not None: - aggregator.add("d", key, value, unit, tags, timestamp) - - -def set( - key, # type: str - value, # type: MetricValue - unit="none", # type: MeasurementUnit - tags=None, # type: Optional[MetricTags] - timestamp=None, # type: Optional[float] -): - # type: (...) -> None - """Emits a set.""" - aggregator, tags = _get_aggregator_and_update_tags(key, tags) - if aggregator is not None: - aggregator.add("s", key, value, unit, tags, timestamp) - - -def gauge( - key, # type: str - value, # type: float - unit="none", # type: MetricValue - tags=None, # type: Optional[MetricTags] - timestamp=None, # type: Optional[float] -): - # type: (...) -> None - """Emits a gauge.""" - aggregator, tags = _get_aggregator_and_update_tags(key, tags) - if aggregator is not None: - aggregator.add("g", key, value, unit, tags, timestamp) + name: str, + value: float, + unit: "Optional[str]" = None, + attributes: "Optional[dict[str, Any]]" = None, +) -> None: + _capture_metric(name, "distribution", value, unit, attributes) diff --git a/sentry_sdk/monitor.py b/sentry_sdk/monitor.py index 5a45010297..eeb262a84a 100644 --- a/sentry_sdk/monitor.py +++ b/sentry_sdk/monitor.py @@ -4,7 +4,8 @@ import sentry_sdk from sentry_sdk.utils import logger -from sentry_sdk._types import TYPE_CHECKING + +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Optional @@ -13,7 +14,7 @@ MAX_DOWNSAMPLE_FACTOR = 10 -class Monitor(object): +class Monitor: """ Performs health checks in a separate thread once every interval seconds and updates the internal state. Other parts of the SDK only read this state @@ -22,21 +23,28 @@ class Monitor(object): name = "sentry.monitor" - def __init__(self, transport, interval=10): - # type: (sentry_sdk.transport.Transport, float) -> None - self.transport = transport # type: sentry_sdk.transport.Transport - self.interval = interval # type: float + def __init__( + self, transport: "sentry_sdk.transport.Transport", interval: float = 10 + ) -> None: + self.transport: "sentry_sdk.transport.Transport" = transport + self.interval: float = interval self._healthy = True - self._downsample_factor = 0 # type: int + self._downsample_factor: int = 0 - self._thread = None # type: Optional[Thread] + self._thread: "Optional[Thread]" = None self._thread_lock = Lock() - self._thread_for_pid = None # type: Optional[int] + self._thread_for_pid: "Optional[int]" = None self._running = True - def _ensure_running(self): - # type: () -> None + def _ensure_running(self) -> None: + """ + Check that the monitor has an active thread to run in, or create one if not. + + Note that this might fail (e.g. in Python 3.12 it's not possible to + spawn new threads at interpreter shutdown). In that case self._running + will be False after running this function. + """ if self._thread_for_pid == os.getpid() and self._thread is not None: return None @@ -44,8 +52,7 @@ def _ensure_running(self): if self._thread_for_pid == os.getpid() and self._thread is not None: return None - def _thread(): - # type: (...) -> None + def _thread() -> None: while self._running: time.sleep(self.interval) if self._running: @@ -53,19 +60,24 @@ def _thread(): thread = Thread(name=self.name, target=_thread) thread.daemon = True - thread.start() + try: + thread.start() + except RuntimeError: + # Unfortunately at this point the interpreter is in a state that no + # longer allows us to spawn a thread and we have to bail. + self._running = False + return None + self._thread = thread self._thread_for_pid = os.getpid() return None - def run(self): - # type: () -> None + def run(self) -> None: self.check_health() self.set_downsample_factor() - def set_downsample_factor(self): - # type: () -> None + def set_downsample_factor(self) -> None: if self._healthy: if self._downsample_factor > 0: logger.debug( @@ -80,8 +92,7 @@ def set_downsample_factor(self): self._downsample_factor, ) - def check_health(self): - # type: () -> None + def check_health(self) -> None: """ Perform the actual health checks, currently only checks if the transport is rate-limited. @@ -89,21 +100,14 @@ def check_health(self): """ self._healthy = self.transport.is_healthy() - def is_healthy(self): - # type: () -> bool + def is_healthy(self) -> bool: self._ensure_running() return self._healthy @property - def downsample_factor(self): - # type: () -> int + def downsample_factor(self) -> int: self._ensure_running() return self._downsample_factor - def kill(self): - # type: () -> None + def kill(self) -> None: self._running = False - - def __del__(self): - # type: () -> None - self.kill() diff --git a/sentry_sdk/profiler/__init__.py b/sentry_sdk/profiler/__init__.py new file mode 100644 index 0000000000..0bc63e3a6d --- /dev/null +++ b/sentry_sdk/profiler/__init__.py @@ -0,0 +1,49 @@ +from sentry_sdk.profiler.continuous_profiler import ( + start_profile_session, + start_profiler, + stop_profile_session, + stop_profiler, +) +from sentry_sdk.profiler.transaction_profiler import ( + MAX_PROFILE_DURATION_NS, + PROFILE_MINIMUM_SAMPLES, + Profile, + Scheduler, + ThreadScheduler, + GeventScheduler, + has_profiling_enabled, + setup_profiler, + teardown_profiler, +) +from sentry_sdk.profiler.utils import ( + DEFAULT_SAMPLING_FREQUENCY, + MAX_STACK_DEPTH, + get_frame_name, + extract_frame, + extract_stack, + frame_id, +) + +__all__ = [ + "start_profile_session", # TODO: Deprecate this in favor of `start_profiler` + "start_profiler", + "stop_profile_session", # TODO: Deprecate this in favor of `stop_profiler` + "stop_profiler", + # DEPRECATED: The following was re-exported for backwards compatibility. It + # will be removed from sentry_sdk.profiler in a future release. + "MAX_PROFILE_DURATION_NS", + "PROFILE_MINIMUM_SAMPLES", + "Profile", + "Scheduler", + "ThreadScheduler", + "GeventScheduler", + "has_profiling_enabled", + "setup_profiler", + "teardown_profiler", + "DEFAULT_SAMPLING_FREQUENCY", + "MAX_STACK_DEPTH", + "get_frame_name", + "extract_frame", + "extract_stack", + "frame_id", +] diff --git a/sentry_sdk/profiler/continuous_profiler.py b/sentry_sdk/profiler/continuous_profiler.py new file mode 100644 index 0000000000..a4c16a63d5 --- /dev/null +++ b/sentry_sdk/profiler/continuous_profiler.py @@ -0,0 +1,714 @@ +import atexit +import os +import random +import sys +import threading +import time +import uuid +import warnings +from collections import deque +from datetime import datetime, timezone + +from sentry_sdk.consts import VERSION +from sentry_sdk.envelope import Envelope +from sentry_sdk._lru_cache import LRUCache +from sentry_sdk.profiler.utils import ( + DEFAULT_SAMPLING_FREQUENCY, + extract_stack, +) +from sentry_sdk.utils import ( + capture_internal_exception, + is_gevent, + logger, + now, + set_in_app_in_frames, +) + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Any + from typing import Callable + from typing import Deque + from typing import Dict + from typing import List + from typing import Optional + from typing import Set + from typing import Type + from typing import Union + from typing_extensions import TypedDict + from sentry_sdk._types import ContinuousProfilerMode, SDKInfo + from sentry_sdk.profiler.utils import ( + ExtractedSample, + FrameId, + StackId, + ThreadId, + ProcessedFrame, + ProcessedStack, + ) + + ProcessedSample = TypedDict( + "ProcessedSample", + { + "timestamp": float, + "thread_id": ThreadId, + "stack_id": int, + }, + ) + + +try: + from gevent.monkey import get_original + from gevent.threadpool import ThreadPool as _ThreadPool + + ThreadPool: "Optional[Type[_ThreadPool]]" = _ThreadPool + thread_sleep = get_original("time", "sleep") +except ImportError: + thread_sleep = time.sleep + ThreadPool = None + + +_scheduler: "Optional[ContinuousScheduler]" = None + + +def setup_continuous_profiler( + options: "Dict[str, Any]", + sdk_info: "SDKInfo", + capture_func: "Callable[[Envelope], None]", +) -> bool: + global _scheduler + + already_initialized = _scheduler is not None + + if already_initialized: + logger.debug("[Profiling] Continuous Profiler is already setup") + teardown_continuous_profiler() + + if is_gevent(): + # If gevent has patched the threading modules then we cannot rely on + # them to spawn a native thread for sampling. + # Instead we default to the GeventContinuousScheduler which is capable of + # spawning native threads within gevent. + default_profiler_mode = GeventContinuousScheduler.mode + else: + default_profiler_mode = ThreadContinuousScheduler.mode + + if options.get("profiler_mode") is not None: + profiler_mode = options["profiler_mode"] + else: + # TODO: deprecate this and just use the existing `profiler_mode` + experiments = options.get("_experiments", {}) + + profiler_mode = ( + experiments.get("continuous_profiling_mode") or default_profiler_mode + ) + + frequency = DEFAULT_SAMPLING_FREQUENCY + + if profiler_mode == ThreadContinuousScheduler.mode: + _scheduler = ThreadContinuousScheduler( + frequency, options, sdk_info, capture_func + ) + elif profiler_mode == GeventContinuousScheduler.mode: + _scheduler = GeventContinuousScheduler( + frequency, options, sdk_info, capture_func + ) + else: + raise ValueError("Unknown continuous profiler mode: {}".format(profiler_mode)) + + logger.debug( + "[Profiling] Setting up continuous profiler in {mode} mode".format( + mode=_scheduler.mode + ) + ) + + if not already_initialized: + atexit.register(teardown_continuous_profiler) + + return True + + +def is_profile_session_sampled() -> bool: + if _scheduler is None: + return False + return _scheduler.sampled + + +def try_autostart_continuous_profiler() -> None: + # TODO: deprecate this as it'll be replaced by the auto lifecycle option + + if _scheduler is None: + return + + if not _scheduler.is_auto_start_enabled(): + return + + _scheduler.manual_start() + + +def try_profile_lifecycle_trace_start() -> "Union[ContinuousProfile, None]": + if _scheduler is None: + return None + + return _scheduler.auto_start() + + +def start_profiler() -> None: + if _scheduler is None: + return + + _scheduler.manual_start() + + +def start_profile_session() -> None: + warnings.warn( + "The `start_profile_session` function is deprecated. Please use `start_profile` instead.", + DeprecationWarning, + stacklevel=2, + ) + start_profiler() + + +def stop_profiler() -> None: + if _scheduler is None: + return + + _scheduler.manual_stop() + + +def stop_profile_session() -> None: + warnings.warn( + "The `stop_profile_session` function is deprecated. Please use `stop_profile` instead.", + DeprecationWarning, + stacklevel=2, + ) + stop_profiler() + + +def teardown_continuous_profiler() -> None: + stop_profiler() + + global _scheduler + _scheduler = None + + +def get_profiler_id() -> "Union[str, None]": + if _scheduler is None: + return None + return _scheduler.profiler_id + + +def determine_profile_session_sampling_decision( + sample_rate: "Union[float, None]", +) -> bool: + # `None` is treated as `0.0` + if not sample_rate: + return False + + return random.random() < float(sample_rate) + + +class ContinuousProfile: + active: bool = True + + def stop(self) -> None: + self.active = False + + +class ContinuousScheduler: + mode: "ContinuousProfilerMode" = "unknown" + + def __init__( + self, + frequency: int, + options: "Dict[str, Any]", + sdk_info: "SDKInfo", + capture_func: "Callable[[Envelope], None]", + ) -> None: + self.interval = 1.0 / frequency + self.options = options + self.sdk_info = sdk_info + self.capture_func = capture_func + + self.lifecycle = self.options.get("profile_lifecycle") + profile_session_sample_rate = self.options.get("profile_session_sample_rate") + self.sampled = determine_profile_session_sampling_decision( + profile_session_sample_rate + ) + + self.sampler = self.make_sampler() + self.buffer: "Optional[ProfileBuffer]" = None + self.pid: "Optional[int]" = None + + self.running = False + self.soft_shutdown = False + + self.new_profiles: "Deque[ContinuousProfile]" = deque(maxlen=128) + self.active_profiles: "Set[ContinuousProfile]" = set() + + def is_auto_start_enabled(self) -> bool: + # Ensure that the scheduler only autostarts once per process. + # This is necessary because many web servers use forks to spawn + # additional processes. And the profiler is only spawned on the + # master process, then it often only profiles the main process + # and not the ones where the requests are being handled. + if self.pid == os.getpid(): + return False + + experiments = self.options.get("_experiments") + if not experiments: + return False + + return experiments.get("continuous_profiling_auto_start") + + def auto_start(self) -> "Union[ContinuousProfile, None]": + if not self.sampled: + return None + + if self.lifecycle != "trace": + return None + + logger.debug("[Profiling] Auto starting profiler") + + profile = ContinuousProfile() + + self.new_profiles.append(profile) + self.ensure_running() + + return profile + + def manual_start(self) -> None: + if not self.sampled: + return + + if self.lifecycle != "manual": + return + + self.ensure_running() + + def manual_stop(self) -> None: + if self.lifecycle != "manual": + return + + self.teardown() + + def ensure_running(self) -> None: + raise NotImplementedError + + def teardown(self) -> None: + raise NotImplementedError + + def pause(self) -> None: + raise NotImplementedError + + def reset_buffer(self) -> None: + self.buffer = ProfileBuffer( + self.options, self.sdk_info, PROFILE_BUFFER_SECONDS, self.capture_func + ) + + @property + def profiler_id(self) -> "Union[str, None]": + if self.buffer is None: + return None + return self.buffer.profiler_id + + def make_sampler(self) -> "Callable[..., bool]": + cwd = os.getcwd() + + cache = LRUCache(max_size=256) + + if self.lifecycle == "trace": + + def _sample_stack(*args: "Any", **kwargs: "Any") -> bool: + """ + Take a sample of the stack on all the threads in the process. + This should be called at a regular interval to collect samples. + """ + + # no profiles taking place, so we can stop early + if not self.new_profiles and not self.active_profiles: + return True + + # This is the number of profiles we want to pop off. + # It's possible another thread adds a new profile to + # the list and we spend longer than we want inside + # the loop below. + # + # Also make sure to set this value before extracting + # frames so we do not write to any new profiles that + # were started after this point. + new_profiles = len(self.new_profiles) + + ts = now() + + try: + sample = [ + (str(tid), extract_stack(frame, cache, cwd)) + for tid, frame in sys._current_frames().items() + ] + except AttributeError: + # For some reason, the frame we get doesn't have certain attributes. + # When this happens, we abandon the current sample as it's bad. + capture_internal_exception(sys.exc_info()) + return False + + # Move the new profiles into the active_profiles set. + # + # We cannot directly add the to active_profiles set + # in `start_profiling` because it is called from other + # threads which can cause a RuntimeError when it the + # set sizes changes during iteration without a lock. + # + # We also want to avoid using a lock here so threads + # that are starting profiles are not blocked until it + # can acquire the lock. + for _ in range(new_profiles): + self.active_profiles.add(self.new_profiles.popleft()) + inactive_profiles = [] + + for profile in self.active_profiles: + if not profile.active: + # If a profile is marked inactive, we buffer it + # to `inactive_profiles` so it can be removed. + # We cannot remove it here as it would result + # in a RuntimeError. + inactive_profiles.append(profile) + + for profile in inactive_profiles: + self.active_profiles.remove(profile) + + if self.buffer is not None: + self.buffer.write(ts, sample) + + return False + + else: + + def _sample_stack(*args: "Any", **kwargs: "Any") -> bool: + """ + Take a sample of the stack on all the threads in the process. + This should be called at a regular interval to collect samples. + """ + + ts = now() + + try: + sample = [ + (str(tid), extract_stack(frame, cache, cwd)) + for tid, frame in sys._current_frames().items() + ] + except AttributeError: + # For some reason, the frame we get doesn't have certain attributes. + # When this happens, we abandon the current sample as it's bad. + capture_internal_exception(sys.exc_info()) + return False + + if self.buffer is not None: + self.buffer.write(ts, sample) + + return False + + return _sample_stack + + def run(self) -> None: + last = time.perf_counter() + + while self.running: + self.soft_shutdown = self.sampler() + + # some time may have elapsed since the last time + # we sampled, so we need to account for that and + # not sleep for too long + elapsed = time.perf_counter() - last + if elapsed < self.interval: + thread_sleep(self.interval - elapsed) + + # the soft shutdown happens here to give it a chance + # for the profiler to be reused + if self.soft_shutdown: + self.running = False + + # make sure to explicitly exit the profiler here or there might + # be multiple profilers at once + break + + # after sleeping, make sure to take the current + # timestamp so we can use it next iteration + last = time.perf_counter() + + if self.buffer is not None: + self.buffer.flush() + self.buffer = None + + +class ThreadContinuousScheduler(ContinuousScheduler): + """ + This scheduler is based on running a daemon thread that will call + the sampler at a regular interval. + """ + + mode: "ContinuousProfilerMode" = "thread" + name = "sentry.profiler.ThreadContinuousScheduler" + + def __init__( + self, + frequency: int, + options: "Dict[str, Any]", + sdk_info: "SDKInfo", + capture_func: "Callable[[Envelope], None]", + ) -> None: + super().__init__(frequency, options, sdk_info, capture_func) + + self.thread: "Optional[threading.Thread]" = None + self.lock = threading.Lock() + + def ensure_running(self) -> None: + self.soft_shutdown = False + + pid = os.getpid() + + # is running on the right process + if self.running and self.pid == pid: + return + + with self.lock: + # another thread may have tried to acquire the lock + # at the same time so it may start another thread + # make sure to check again before proceeding + if self.running and self.pid == pid: + return + + self.pid = pid + self.running = True + + # if the profiler thread is changing, + # we should create a new buffer along with it + self.reset_buffer() + + # make sure the thread is a daemon here otherwise this + # can keep the application running after other threads + # have exited + self.thread = threading.Thread(name=self.name, target=self.run, daemon=True) + + try: + self.thread.start() + except RuntimeError: + # Unfortunately at this point the interpreter is in a state that no + # longer allows us to spawn a thread and we have to bail. + self.running = False + self.thread = None + + def teardown(self) -> None: + if self.running: + self.running = False + + if self.thread is not None: + self.thread.join() + self.thread = None + + self.buffer = None + + +class GeventContinuousScheduler(ContinuousScheduler): + """ + This scheduler is based on the thread scheduler but adapted to work with + gevent. When using gevent, it may monkey patch the threading modules + (`threading` and `_thread`). This results in the use of greenlets instead + of native threads. + + This is an issue because the sampler CANNOT run in a greenlet because + 1. Other greenlets doing sync work will prevent the sampler from running + 2. The greenlet runs in the same thread as other greenlets so when taking + a sample, other greenlets will have been evicted from the thread. This + results in a sample containing only the sampler's code. + """ + + mode: "ContinuousProfilerMode" = "gevent" + + def __init__( + self, + frequency: int, + options: "Dict[str, Any]", + sdk_info: "SDKInfo", + capture_func: "Callable[[Envelope], None]", + ) -> None: + if ThreadPool is None: + raise ValueError("Profiler mode: {} is not available".format(self.mode)) + + super().__init__(frequency, options, sdk_info, capture_func) + + self.thread: "Optional[_ThreadPool]" = None + self.lock = threading.Lock() + + def ensure_running(self) -> None: + self.soft_shutdown = False + + pid = os.getpid() + + # is running on the right process + if self.running and self.pid == pid: + return + + with self.lock: + # another thread may have tried to acquire the lock + # at the same time so it may start another thread + # make sure to check again before proceeding + if self.running and self.pid == pid: + return + + self.pid = pid + self.running = True + + # if the profiler thread is changing, + # we should create a new buffer along with it + self.reset_buffer() + + self.thread = ThreadPool(1) # type: ignore[misc] + try: + self.thread.spawn(self.run) + except RuntimeError: + # Unfortunately at this point the interpreter is in a state that no + # longer allows us to spawn a thread and we have to bail. + self.running = False + self.thread = None + + def teardown(self) -> None: + if self.running: + self.running = False + + if self.thread is not None: + self.thread.join() + self.thread = None + + self.buffer = None + + +PROFILE_BUFFER_SECONDS = 60 + + +class ProfileBuffer: + def __init__( + self, + options: "Dict[str, Any]", + sdk_info: "SDKInfo", + buffer_size: int, + capture_func: "Callable[[Envelope], None]", + ) -> None: + self.options = options + self.sdk_info = sdk_info + self.buffer_size = buffer_size + self.capture_func = capture_func + + self.profiler_id = uuid.uuid4().hex + self.chunk = ProfileChunk() + + # Make sure to use the same clock to compute a sample's monotonic timestamp + # to ensure the timestamps are correctly aligned. + self.start_monotonic_time = now() + + # Make sure the start timestamp is defined only once per profiler id. + # This prevents issues with clock drift within a single profiler session. + # + # Subtracting the start_monotonic_time here to find a fixed starting position + # for relative monotonic timestamps for each sample. + self.start_timestamp = ( + datetime.now(timezone.utc).timestamp() - self.start_monotonic_time + ) + + def write(self, monotonic_time: float, sample: "ExtractedSample") -> None: + if self.should_flush(monotonic_time): + self.flush() + self.chunk = ProfileChunk() + self.start_monotonic_time = now() + + self.chunk.write(self.start_timestamp + monotonic_time, sample) + + def should_flush(self, monotonic_time: float) -> bool: + # If the delta between the new monotonic time and the start monotonic time + # exceeds the buffer size, it means we should flush the chunk + return monotonic_time - self.start_monotonic_time >= self.buffer_size + + def flush(self) -> None: + chunk = self.chunk.to_json(self.profiler_id, self.options, self.sdk_info) + envelope = Envelope() + envelope.add_profile_chunk(chunk) + self.capture_func(envelope) + + +class ProfileChunk: + def __init__(self) -> None: + self.chunk_id = uuid.uuid4().hex + + self.indexed_frames: "Dict[FrameId, int]" = {} + self.indexed_stacks: "Dict[StackId, int]" = {} + self.frames: "List[ProcessedFrame]" = [] + self.stacks: "List[ProcessedStack]" = [] + self.samples: "List[ProcessedSample]" = [] + + def write(self, ts: float, sample: "ExtractedSample") -> None: + for tid, (stack_id, frame_ids, frames) in sample: + try: + # Check if the stack is indexed first, this lets us skip + # indexing frames if it's not necessary + if stack_id not in self.indexed_stacks: + for i, frame_id in enumerate(frame_ids): + if frame_id not in self.indexed_frames: + self.indexed_frames[frame_id] = len(self.indexed_frames) + self.frames.append(frames[i]) + + self.indexed_stacks[stack_id] = len(self.indexed_stacks) + self.stacks.append( + [self.indexed_frames[frame_id] for frame_id in frame_ids] + ) + + self.samples.append( + { + "timestamp": ts, + "thread_id": tid, + "stack_id": self.indexed_stacks[stack_id], + } + ) + except AttributeError: + # For some reason, the frame we get doesn't have certain attributes. + # When this happens, we abandon the current sample as it's bad. + capture_internal_exception(sys.exc_info()) + + def to_json( + self, profiler_id: str, options: "Dict[str, Any]", sdk_info: "SDKInfo" + ) -> "Dict[str, Any]": + profile = { + "frames": self.frames, + "stacks": self.stacks, + "samples": self.samples, + "thread_metadata": { + str(thread.ident): { + "name": str(thread.name), + } + for thread in threading.enumerate() + }, + } + + set_in_app_in_frames( + profile["frames"], + options["in_app_exclude"], + options["in_app_include"], + options["project_root"], + ) + + payload = { + "chunk_id": self.chunk_id, + "client_sdk": { + "name": sdk_info["name"], + "version": VERSION, + }, + "platform": "python", + "profile": profile, + "profiler_id": profiler_id, + "version": "2", + } + + for key in "release", "environment", "dist": + if options[key] is not None: + payload[key] = str(options[key]).strip() + + return payload diff --git a/sentry_sdk/profiler.py b/sentry_sdk/profiler/transaction_profiler.py similarity index 59% rename from sentry_sdk/profiler.py rename to sentry_sdk/profiler/transaction_profiler.py index 7ae73b056e..822d9cb742 100644 --- a/sentry_sdk/profiler.py +++ b/sentry_sdk/profiler/transaction_profiler.py @@ -33,23 +33,30 @@ import threading import time import uuid +import warnings +from abc import ABC, abstractmethod from collections import deque import sentry_sdk -from sentry_sdk._compat import PY33, PY311 from sentry_sdk._lru_cache import LRUCache -from sentry_sdk._types import TYPE_CHECKING +from sentry_sdk.profiler.utils import ( + DEFAULT_SAMPLING_FREQUENCY, + extract_stack, +) from sentry_sdk.utils import ( capture_internal_exception, - filename_for_module, + capture_internal_exceptions, + get_current_thread_meta, + is_gevent, is_valid_sample_rate, logger, nanosecond_time, set_in_app_in_frames, ) +from typing import TYPE_CHECKING + if TYPE_CHECKING: - from types import FrameType from typing import Any from typing import Callable from typing import Deque @@ -57,14 +64,19 @@ from typing import List from typing import Optional from typing import Set - from typing import Sequence - from typing import Tuple + from typing import Type from typing_extensions import TypedDict - import sentry_sdk.tracing - from sentry_sdk._types import SamplingContext, ProfilerMode - - ThreadId = str + from sentry_sdk.profiler.utils import ( + ProcessedStack, + ProcessedFrame, + ProcessedThreadMetadata, + FrameId, + StackId, + ThreadId, + ExtractedSample, + ) + from sentry_sdk._types import Event, SamplingContext, ProfilerMode ProcessedSample = TypedDict( "ProcessedSample", @@ -75,24 +87,6 @@ }, ) - ProcessedStack = List[int] - - ProcessedFrame = TypedDict( - "ProcessedFrame", - { - "abs_path": str, - "filename": Optional[str], - "function": str, - "lineno": int, - "module": Optional[str], - }, - ) - - ProcessedThreadMetadata = TypedDict( - "ProcessedThreadMetadata", - {"name": str}, - ) - ProcessedProfile = TypedDict( "ProcessedProfile", { @@ -103,60 +97,20 @@ }, ) - ProfileContext = TypedDict( - "ProfileContext", - {"profile_id": str}, - ) - - FrameId = Tuple[ - str, # abs_path - int, # lineno - str, # function - ] - FrameIds = Tuple[FrameId, ...] - - # The exact value of this id is not very meaningful. The purpose - # of this id is to give us a compact and unique identifier for a - # raw stack that can be used as a key to a dictionary so that it - # can be used during the sampled format generation. - StackId = Tuple[int, int] - - ExtractedStack = Tuple[StackId, FrameIds, List[ProcessedFrame]] - ExtractedSample = Sequence[Tuple[ThreadId, ExtractedStack]] - try: - from gevent import get_hub as get_gevent_hub # type: ignore - from gevent.monkey import get_original, is_module_patched # type: ignore - from gevent.threadpool import ThreadPool # type: ignore + from gevent.monkey import get_original + from gevent.threadpool import ThreadPool as _ThreadPool + ThreadPool: "Optional[Type[_ThreadPool]]" = _ThreadPool thread_sleep = get_original("time", "sleep") except ImportError: - - def get_gevent_hub(): - # type: () -> Any - return None - thread_sleep = time.sleep - def is_module_patched(*args, **kwargs): - # type: (*Any, **Any) -> bool - # unable to import from gevent means no modules have been patched - return False - ThreadPool = None -def is_gevent(): - # type: () -> bool - return is_module_patched("threading") or is_module_patched("_thread") - - -_scheduler = None # type: Optional[Scheduler] - -# The default sampling frequency to use. This is set at 101 in order to -# mitigate the effects of lockstep sampling. -DEFAULT_SAMPLING_FREQUENCY = 101 +_scheduler: "Optional[Scheduler]" = None # The minimum number of unique samples that must exist in a profile to be @@ -164,8 +118,7 @@ def is_gevent(): PROFILE_MINIMUM_SAMPLES = 2 -def has_profiling_enabled(options): - # type: (Dict[str, Any]) -> bool +def has_profiling_enabled(options: "Dict[str, Any]") -> bool: profiles_sampler = options["profiles_sampler"] if profiles_sampler is not None: return True @@ -175,24 +128,25 @@ def has_profiling_enabled(options): return True profiles_sample_rate = options["_experiments"].get("profiles_sample_rate") - if profiles_sample_rate is not None and profiles_sample_rate > 0: - return True + if profiles_sample_rate is not None: + logger.warning( + "_experiments['profiles_sample_rate'] is deprecated. " + "Please use the non-experimental profiles_sample_rate option " + "directly." + ) + if profiles_sample_rate > 0: + return True return False -def setup_profiler(options): - # type: (Dict[str, Any]) -> bool +def setup_profiler(options: "Dict[str, Any]") -> bool: global _scheduler if _scheduler is not None: logger.debug("[Profiling] Profiler is already setup") return False - if not PY33: - logger.warn("[Profiling] Profiler requires Python >= 3.3") - return False - frequency = DEFAULT_SAMPLING_FREQUENCY if is_gevent(): @@ -207,10 +161,13 @@ def setup_profiler(options): if options.get("profiler_mode") is not None: profiler_mode = options["profiler_mode"] else: - profiler_mode = ( - options.get("_experiments", {}).get("profiler_mode") - or default_profiler_mode - ) + profiler_mode = options.get("_experiments", {}).get("profiler_mode") + if profiler_mode is not None: + logger.warning( + "_experiments['profiler_mode'] is deprecated. Please use the " + "non-experimental profiler_mode option directly." + ) + profiler_mode = profiler_mode or default_profiler_mode if ( profiler_mode == ThreadScheduler.mode @@ -233,9 +190,7 @@ def setup_profiler(options): return True -def teardown_profiler(): - # type: () -> None - +def teardown_profiler() -> None: global _scheduler if _scheduler is not None: @@ -244,253 +199,68 @@ def teardown_profiler(): _scheduler = None -# We want to impose a stack depth limit so that samples aren't too large. -MAX_STACK_DEPTH = 128 - - -def extract_stack( - raw_frame, # type: Optional[FrameType] - cache, # type: LRUCache - cwd, # type: str - max_stack_depth=MAX_STACK_DEPTH, # type: int -): - # type: (...) -> ExtractedStack - """ - Extracts the stack starting the specified frame. The extracted stack - assumes the specified frame is the top of the stack, and works back - to the bottom of the stack. - - In the event that the stack is more than `MAX_STACK_DEPTH` frames deep, - only the first `MAX_STACK_DEPTH` frames will be returned. - """ - - raw_frames = deque(maxlen=max_stack_depth) # type: Deque[FrameType] - - while raw_frame is not None: - f_back = raw_frame.f_back - raw_frames.append(raw_frame) - raw_frame = f_back - - frame_ids = tuple(frame_id(raw_frame) for raw_frame in raw_frames) - frames = [] - for i, fid in enumerate(frame_ids): - frame = cache.get(fid) - if frame is None: - frame = extract_frame(fid, raw_frames[i], cwd) - cache.set(fid, frame) - frames.append(frame) - - # Instead of mapping the stack into frame ids and hashing - # that as a tuple, we can directly hash the stack. - # This saves us from having to generate yet another list. - # Additionally, using the stack as the key directly is - # costly because the stack can be large, so we pre-hash - # the stack, and use the hash as the key as this will be - # needed a few times to improve performance. - # - # To Reduce the likelihood of hash collisions, we include - # the stack depth. This means that only stacks of the same - # depth can suffer from hash collisions. - stack_id = len(raw_frames), hash(frame_ids) - - return stack_id, frame_ids, frames - - -def frame_id(raw_frame): - # type: (FrameType) -> FrameId - return (raw_frame.f_code.co_filename, raw_frame.f_lineno, get_frame_name(raw_frame)) - - -def extract_frame(fid, raw_frame, cwd): - # type: (FrameId, FrameType, str) -> ProcessedFrame - abs_path = raw_frame.f_code.co_filename - - try: - module = raw_frame.f_globals["__name__"] - except Exception: - module = None - - # namedtuples can be many times slower when initialing - # and accessing attribute so we opt to use a tuple here instead - return { - # This originally was `os.path.abspath(abs_path)` but that had - # a large performance overhead. - # - # According to docs, this is equivalent to - # `os.path.normpath(os.path.join(os.getcwd(), path))`. - # The `os.getcwd()` call is slow here, so we precompute it. - # - # Additionally, since we are using normalized path already, - # we skip calling `os.path.normpath` entirely. - "abs_path": os.path.join(cwd, abs_path), - "module": module, - "filename": filename_for_module(module, abs_path) or None, - "function": fid[2], - "lineno": raw_frame.f_lineno, - } - - -if PY311: - - def get_frame_name(frame): - # type: (FrameType) -> str - return frame.f_code.co_qualname - -else: - - def get_frame_name(frame): - # type: (FrameType) -> str - - f_code = frame.f_code - co_varnames = f_code.co_varnames - - # co_name only contains the frame name. If the frame was a method, - # the class name will NOT be included. - name = f_code.co_name - - # if it was a method, we can get the class name by inspecting - # the f_locals for the `self` argument - try: - if ( - # the co_varnames start with the frame's positional arguments - # and we expect the first to be `self` if its an instance method - co_varnames - and co_varnames[0] == "self" - and "self" in frame.f_locals - ): - for cls in frame.f_locals["self"].__class__.__mro__: - if name in cls.__dict__: - return "{}.{}".format(cls.__name__, name) - except AttributeError: - pass - - # if it was a class method, (decorated with `@classmethod`) - # we can get the class name by inspecting the f_locals for the `cls` argument - try: - if ( - # the co_varnames start with the frame's positional arguments - # and we expect the first to be `cls` if its a class method - co_varnames - and co_varnames[0] == "cls" - and "cls" in frame.f_locals - ): - for cls in frame.f_locals["cls"].__mro__: - if name in cls.__dict__: - return "{}.{}".format(cls.__name__, name) - except AttributeError: - pass - - # nothing we can do if it is a staticmethod (decorated with @staticmethod) - - # we've done all we can, time to give up and return what we have - return name - - MAX_PROFILE_DURATION_NS = int(3e10) # 30 seconds -def get_current_thread_id(thread=None): - # type: (Optional[threading.Thread]) -> Optional[int] - """ - Try to get the id of the current thread, with various fall backs. - """ - - # if a thread is specified, that takes priority - if thread is not None: - try: - thread_id = thread.ident - if thread_id is not None: - return thread_id - except AttributeError: - pass - - # if the app is using gevent, we should look at the gevent hub first - # as the id there differs from what the threading module reports - if is_gevent(): - gevent_hub = get_gevent_hub() - if gevent_hub is not None: - try: - # this is undocumented, so wrap it in try except to be safe - return gevent_hub.thread_ident - except AttributeError: - pass - - # use the current thread's id if possible - try: - current_thread_id = threading.current_thread().ident - if current_thread_id is not None: - return current_thread_id - except AttributeError: - pass - - # if we can't get the current thread id, fall back to the main thread id - try: - main_thread_id = threading.main_thread().ident - if main_thread_id is not None: - return main_thread_id - except AttributeError: - pass - - # we've tried everything, time to give up - return None - - -class Profile(object): +class Profile: def __init__( self, - transaction, # type: sentry_sdk.tracing.Transaction - hub=None, # type: Optional[sentry_sdk.Hub] - scheduler=None, # type: Optional[Scheduler] - ): - # type: (...) -> None + sampled: "Optional[bool]", + start_ns: int, + hub: "Optional[sentry_sdk.Hub]" = None, + scheduler: "Optional[Scheduler]" = None, + ) -> None: self.scheduler = _scheduler if scheduler is None else scheduler - self.hub = hub - self.event_id = uuid.uuid4().hex # type: str + self.event_id: str = uuid.uuid4().hex - # Here, we assume that the sampling decision on the transaction has been finalized. - # - # We cannot keep a reference to the transaction around here because it'll create - # a reference cycle. So we opt to pull out just the necessary attributes. - self.sampled = transaction.sampled # type: Optional[bool] + self.sampled: "Optional[bool]" = sampled # Various framework integrations are capable of overwriting the active thread id. # If it is set to `None` at the end of the profile, we fall back to the default. - self._default_active_thread_id = get_current_thread_id() or 0 # type: int - self.active_thread_id = None # type: Optional[int] + self._default_active_thread_id: int = get_current_thread_meta()[0] or 0 + self.active_thread_id: "Optional[int]" = None try: - self.start_ns = transaction._start_timestamp_monotonic_ns # type: int + self.start_ns: int = start_ns except AttributeError: self.start_ns = 0 - self.stop_ns = 0 # type: int - self.active = False # type: bool + self.stop_ns: int = 0 + self.active: bool = False - self.indexed_frames = {} # type: Dict[FrameId, int] - self.indexed_stacks = {} # type: Dict[StackId, int] - self.frames = [] # type: List[ProcessedFrame] - self.stacks = [] # type: List[ProcessedStack] - self.samples = [] # type: List[ProcessedSample] + self.indexed_frames: "Dict[FrameId, int]" = {} + self.indexed_stacks: "Dict[StackId, int]" = {} + self.frames: "List[ProcessedFrame]" = [] + self.stacks: "List[ProcessedStack]" = [] + self.samples: "List[ProcessedSample]" = [] self.unique_samples = 0 - transaction._profile = self + # Backwards compatibility with the old hub property + self._hub: "Optional[sentry_sdk.Hub]" = None + if hub is not None: + self._hub = hub + warnings.warn( + "The `hub` parameter is deprecated. Please do not use it.", + DeprecationWarning, + stacklevel=2, + ) - def update_active_thread_id(self): - # type: () -> None - self.active_thread_id = get_current_thread_id() + def update_active_thread_id(self) -> None: + self.active_thread_id = get_current_thread_meta()[0] logger.debug( "[Profiling] updating active thread id to {tid}".format( tid=self.active_thread_id ) ) - def _set_initial_sampling_decision(self, sampling_context): - # type: (SamplingContext) -> None + def _set_initial_sampling_decision( + self, sampling_context: "SamplingContext" + ) -> None: """ Sets the profile's sampling decision according to the following - precdence rules: + precedence rules: 1. If the transaction to be profiled is not sampled, that decision will be used, regardless of anything else. @@ -515,11 +285,8 @@ def _set_initial_sampling_decision(self, sampling_context): self.sampled = False return - hub = self.hub or sentry_sdk.Hub.current - client = hub.client - - # The client is None, so we can't get the sample rate. - if client is None: + client = sentry_sdk.get_client() + if not client.is_active(): self.sampled = False return @@ -562,8 +329,7 @@ def _set_initial_sampling_decision(self, sampling_context): ) ) - def start(self): - # type: () -> None + def start(self) -> None: if not self.sampled or self.active: return @@ -574,42 +340,38 @@ def start(self): self.start_ns = nanosecond_time() self.scheduler.start_profiling(self) - def stop(self): - # type: () -> None + def stop(self) -> None: if not self.sampled or not self.active: return assert self.scheduler, "No scheduler specified" logger.debug("[Profiling] Stopping profile") self.active = False - self.scheduler.stop_profiling(self) self.stop_ns = nanosecond_time() - def __enter__(self): - # type: () -> Profile - hub = self.hub or sentry_sdk.Hub.current - - _, scope = hub._stack[-1] + def __enter__(self) -> "Profile": + scope = sentry_sdk.get_isolation_scope() old_profile = scope.profile scope.profile = self - self._context_manager_state = (hub, scope, old_profile) + self._context_manager_state = (scope, old_profile) self.start() return self - def __exit__(self, ty, value, tb): - # type: (Optional[Any], Optional[Any], Optional[Any]) -> None - self.stop() + def __exit__( + self, ty: "Optional[Any]", value: "Optional[Any]", tb: "Optional[Any]" + ) -> None: + with capture_internal_exceptions(): + self.stop() - _, scope, old_profile = self._context_manager_state - del self._context_manager_state + scope, old_profile = self._context_manager_state + del self._context_manager_state - scope.profile = old_profile + scope.profile = old_profile - def write(self, ts, sample): - # type: (int, ExtractedSample) -> None + def write(self, ts: int, sample: "ExtractedSample") -> None: if not self.active: return @@ -652,18 +414,16 @@ def write(self, ts, sample): # When this happens, we abandon the current sample as it's bad. capture_internal_exception(sys.exc_info()) - def process(self): - # type: () -> ProcessedProfile - + def process(self) -> "ProcessedProfile": # This collects the thread metadata at the end of a profile. Doing it # this way means that any threads that terminate before the profile ends # will not have any metadata associated with it. - thread_metadata = { + thread_metadata: "Dict[str, ProcessedThreadMetadata]" = { str(thread.ident): { "name": str(thread.name), } for thread in threading.enumerate() - } # type: Dict[str, ProcessedThreadMetadata] + } return { "frames": self.frames, @@ -672,8 +432,9 @@ def process(self): "thread_metadata": thread_metadata, } - def to_json(self, event_opt, options): - # type: (Any, Dict[str, Any], Dict[str, Any]) -> Dict[str, Any] + def to_json( + self, event_opt: "Event", options: "Dict[str, Any]" + ) -> "Dict[str, Any]": profile = self.process() set_in_app_in_frames( @@ -723,11 +484,9 @@ def to_json(self, event_opt, options): ], } - def valid(self): - # type: () -> bool - hub = self.hub or sentry_sdk.Hub.current - client = hub.client - if client is None: + def valid(self) -> bool: + client = sentry_sdk.get_client() + if not client.is_active(): return False if not has_profiling_enabled(client.options): @@ -750,58 +509,72 @@ def valid(self): return True + @property + def hub(self) -> "Optional[sentry_sdk.Hub]": + warnings.warn( + "The `hub` attribute is deprecated. Please do not access it.", + DeprecationWarning, + stacklevel=2, + ) + return self._hub + + @hub.setter + def hub(self, value: "Optional[sentry_sdk.Hub]") -> None: + warnings.warn( + "The `hub` attribute is deprecated. Please do not set it.", + DeprecationWarning, + stacklevel=2, + ) + self._hub = value -class Scheduler(object): - mode = "unknown" # type: ProfilerMode - def __init__(self, frequency): - # type: (int) -> None +class Scheduler(ABC): + mode: "ProfilerMode" = "unknown" + + def __init__(self, frequency: int) -> None: self.interval = 1.0 / frequency self.sampler = self.make_sampler() # cap the number of new profiles at any time so it does not grow infinitely - self.new_profiles = deque(maxlen=128) # type: Deque[Profile] - self.active_profiles = set() # type: Set[Profile] + self.new_profiles: "Deque[Profile]" = deque(maxlen=128) + self.active_profiles: "Set[Profile]" = set() - def __enter__(self): - # type: () -> Scheduler + def __enter__(self) -> "Scheduler": self.setup() return self - def __exit__(self, ty, value, tb): - # type: (Optional[Any], Optional[Any], Optional[Any]) -> None + def __exit__( + self, ty: "Optional[Any]", value: "Optional[Any]", tb: "Optional[Any]" + ) -> None: self.teardown() - def setup(self): - # type: () -> None - raise NotImplementedError + @abstractmethod + def setup(self) -> None: + pass - def teardown(self): - # type: () -> None - raise NotImplementedError + @abstractmethod + def teardown(self) -> None: + pass - def ensure_running(self): - # type: () -> None - raise NotImplementedError + def ensure_running(self) -> None: + """ + Ensure the scheduler is running. By default, this method is a no-op. + The method should be overridden by any implementation for which it is + relevant. + """ + return None - def start_profiling(self, profile): - # type: (Profile) -> None + def start_profiling(self, profile: "Profile") -> None: self.ensure_running() self.new_profiles.append(profile) - def stop_profiling(self, profile): - # type: (Profile) -> None - pass - - def make_sampler(self): - # type: () -> Callable[..., None] + def make_sampler(self) -> "Callable[..., None]": cwd = os.getcwd() cache = LRUCache(max_size=256) - def _sample_stack(*args, **kwargs): - # type: (*Any, **Any) -> None + def _sample_stack(*args: "Any", **kwargs: "Any") -> None: """ Take a sample of the stack on all the threads in the process. This should be called at a regular interval to collect samples. @@ -854,7 +627,7 @@ def _sample_stack(*args, **kwargs): if profile.active: profile.write(now, sample) else: - # If a thread is marked inactive, we buffer it + # If a profile is marked inactive, we buffer it # to `inactive_profiles` so it can be removed. # We cannot remove it here as it would result # in a RuntimeError. @@ -872,32 +645,36 @@ class ThreadScheduler(Scheduler): the sampler at a regular interval. """ - mode = "thread" # type: ProfilerMode + mode: "ProfilerMode" = "thread" name = "sentry.profiler.ThreadScheduler" - def __init__(self, frequency): - # type: (int) -> None - super(ThreadScheduler, self).__init__(frequency=frequency) + def __init__(self, frequency: int) -> None: + super().__init__(frequency=frequency) # used to signal to the thread that it should stop self.running = False - self.thread = None # type: Optional[threading.Thread] - self.pid = None # type: Optional[int] + self.thread: "Optional[threading.Thread]" = None + self.pid: "Optional[int]" = None self.lock = threading.Lock() - def setup(self): - # type: () -> None + def setup(self) -> None: pass - def teardown(self): - # type: () -> None + def teardown(self) -> None: if self.running: self.running = False if self.thread is not None: self.thread.join() - def ensure_running(self): - # type: () -> None + def ensure_running(self) -> None: + """ + Check that the profiler has an active thread to run in, and start one if + that's not the case. + + Note that this might fail (e.g. in Python 3.12 it's not possible to + spawn new threads at interpreter shutdown). In that case self.running + will be False after running this function. + """ pid = os.getpid() # is running on the right process @@ -918,10 +695,16 @@ def ensure_running(self): # can keep the application running after other threads # have exited self.thread = threading.Thread(name=self.name, target=self.run, daemon=True) - self.thread.start() + try: + self.thread.start() + except RuntimeError: + # Unfortunately at this point the interpreter is in a state that no + # longer allows us to spawn a thread and we have to bail. + self.running = False + self.thread = None + return - def run(self): - # type: () -> None + def run(self) -> None: last = time.perf_counter() while self.running: @@ -953,40 +736,35 @@ class GeventScheduler(Scheduler): results in a sample containing only the sampler's code. """ - mode = "gevent" # type: ProfilerMode + mode: "ProfilerMode" = "gevent" name = "sentry.profiler.GeventScheduler" - def __init__(self, frequency): - # type: (int) -> None - + def __init__(self, frequency: int) -> None: if ThreadPool is None: raise ValueError("Profiler mode: {} is not available".format(self.mode)) - super(GeventScheduler, self).__init__(frequency=frequency) + super().__init__(frequency=frequency) # used to signal to the thread that it should stop self.running = False - self.thread = None # type: Optional[ThreadPool] - self.pid = None # type: Optional[int] + self.thread: "Optional[_ThreadPool]" = None + self.pid: "Optional[int]" = None # This intentionally uses the gevent patched threading.Lock. # The lock will be required when first trying to start profiles # as we need to spawn the profiler thread from the greenlets. self.lock = threading.Lock() - def setup(self): - # type: () -> None + def setup(self) -> None: pass - def teardown(self): - # type: () -> None + def teardown(self) -> None: if self.running: self.running = False if self.thread is not None: self.thread.join() - def ensure_running(self): - # type: () -> None + def ensure_running(self) -> None: pid = os.getpid() # is running on the right process @@ -1003,11 +781,17 @@ def ensure_running(self): self.pid = pid self.running = True - self.thread = ThreadPool(1) - self.thread.spawn(self.run) + self.thread = ThreadPool(1) # type: ignore[misc] + try: + self.thread.spawn(self.run) + except RuntimeError: + # Unfortunately at this point the interpreter is in a state that no + # longer allows us to spawn a thread and we have to bail. + self.running = False + self.thread = None + return - def run(self): - # type: () -> None + def run(self) -> None: last = time.perf_counter() while self.running: diff --git a/sentry_sdk/profiler/utils.py b/sentry_sdk/profiler/utils.py new file mode 100644 index 0000000000..3d122101ad --- /dev/null +++ b/sentry_sdk/profiler/utils.py @@ -0,0 +1,189 @@ +import os +from collections import deque + +from sentry_sdk._compat import PY311 +from sentry_sdk.utils import filename_for_module + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from sentry_sdk._lru_cache import LRUCache + from types import FrameType + from typing import Deque + from typing import List + from typing import Optional + from typing import Sequence + from typing import Tuple + from typing_extensions import TypedDict + + ThreadId = str + + ProcessedStack = List[int] + + ProcessedFrame = TypedDict( + "ProcessedFrame", + { + "abs_path": str, + "filename": Optional[str], + "function": str, + "lineno": int, + "module": Optional[str], + }, + ) + + ProcessedThreadMetadata = TypedDict( + "ProcessedThreadMetadata", + {"name": str}, + ) + + FrameId = Tuple[ + str, # abs_path + int, # lineno + str, # function + ] + FrameIds = Tuple[FrameId, ...] + + # The exact value of this id is not very meaningful. The purpose + # of this id is to give us a compact and unique identifier for a + # raw stack that can be used as a key to a dictionary so that it + # can be used during the sampled format generation. + StackId = Tuple[int, int] + + ExtractedStack = Tuple[StackId, FrameIds, List[ProcessedFrame]] + ExtractedSample = Sequence[Tuple[ThreadId, ExtractedStack]] + +# The default sampling frequency to use. This is set at 101 in order to +# mitigate the effects of lockstep sampling. +DEFAULT_SAMPLING_FREQUENCY = 101 + + +# We want to impose a stack depth limit so that samples aren't too large. +MAX_STACK_DEPTH = 128 + + +if PY311: + + def get_frame_name(frame: "FrameType") -> str: + return frame.f_code.co_qualname + +else: + + def get_frame_name(frame: "FrameType") -> str: + f_code = frame.f_code + co_varnames = f_code.co_varnames + + # co_name only contains the frame name. If the frame was a method, + # the class name will NOT be included. + name = f_code.co_name + + # if it was a method, we can get the class name by inspecting + # the f_locals for the `self` argument + try: + if ( + # the co_varnames start with the frame's positional arguments + # and we expect the first to be `self` if its an instance method + co_varnames and co_varnames[0] == "self" and "self" in frame.f_locals + ): + for cls in type(frame.f_locals["self"]).__mro__: + if name in cls.__dict__: + return "{}.{}".format(cls.__name__, name) + except (AttributeError, ValueError): + pass + + # if it was a class method, (decorated with `@classmethod`) + # we can get the class name by inspecting the f_locals for the `cls` argument + try: + if ( + # the co_varnames start with the frame's positional arguments + # and we expect the first to be `cls` if its a class method + co_varnames and co_varnames[0] == "cls" and "cls" in frame.f_locals + ): + for cls in frame.f_locals["cls"].__mro__: + if name in cls.__dict__: + return "{}.{}".format(cls.__name__, name) + except (AttributeError, ValueError): + pass + + # nothing we can do if it is a staticmethod (decorated with @staticmethod) + + # we've done all we can, time to give up and return what we have + return name + + +def frame_id(raw_frame: "FrameType") -> "FrameId": + return (raw_frame.f_code.co_filename, raw_frame.f_lineno, get_frame_name(raw_frame)) + + +def extract_frame(fid: "FrameId", raw_frame: "FrameType", cwd: str) -> "ProcessedFrame": + abs_path = raw_frame.f_code.co_filename + + try: + module = raw_frame.f_globals["__name__"] + except Exception: + module = None + + # namedtuples can be many times slower when initialing + # and accessing attribute so we opt to use a tuple here instead + return { + # This originally was `os.path.abspath(abs_path)` but that had + # a large performance overhead. + # + # According to docs, this is equivalent to + # `os.path.normpath(os.path.join(os.getcwd(), path))`. + # The `os.getcwd()` call is slow here, so we precompute it. + # + # Additionally, since we are using normalized path already, + # we skip calling `os.path.normpath` entirely. + "abs_path": os.path.join(cwd, abs_path), + "module": module, + "filename": filename_for_module(module, abs_path) or None, + "function": fid[2], + "lineno": raw_frame.f_lineno, + } + + +def extract_stack( + raw_frame: "Optional[FrameType]", + cache: "LRUCache", + cwd: str, + max_stack_depth: int = MAX_STACK_DEPTH, +) -> "ExtractedStack": + """ + Extracts the stack starting the specified frame. The extracted stack + assumes the specified frame is the top of the stack, and works back + to the bottom of the stack. + + In the event that the stack is more than `MAX_STACK_DEPTH` frames deep, + only the first `MAX_STACK_DEPTH` frames will be returned. + """ + + raw_frames: "Deque[FrameType]" = deque(maxlen=max_stack_depth) + + while raw_frame is not None: + f_back = raw_frame.f_back + raw_frames.append(raw_frame) + raw_frame = f_back + + frame_ids = tuple(frame_id(raw_frame) for raw_frame in raw_frames) + frames = [] + for i, fid in enumerate(frame_ids): + frame = cache.get(fid) + if frame is None: + frame = extract_frame(fid, raw_frames[i], cwd) + cache.set(fid, frame) + frames.append(frame) + + # Instead of mapping the stack into frame ids and hashing + # that as a tuple, we can directly hash the stack. + # This saves us from having to generate yet another list. + # Additionally, using the stack as the key directly is + # costly because the stack can be large, so we pre-hash + # the stack, and use the hash as the key as this will be + # needed a few times to improve performance. + # + # To Reduce the likelihood of hash collisions, we include + # the stack depth. This means that only stacks of the same + # depth can suffer from hash collisions. + stack_id = len(raw_frames), hash(frame_ids) + + return stack_id, frame_ids, frames diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index d2768fb374..1c3fe884e8 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -1,75 +1,188 @@ -from copy import copy +import os +import sys +import warnings +from copy import copy, deepcopy from collections import deque +from contextlib import contextmanager +from enum import Enum +from datetime import datetime, timezone +from functools import wraps from itertools import chain -import os -import uuid +from sentry_sdk._types import AnnotatedValue from sentry_sdk.attachments import Attachment -from sentry_sdk._functools import wraps +from sentry_sdk.consts import ( + DEFAULT_MAX_BREADCRUMBS, + FALSE_VALUES, + INSTRUMENTER, + SPANDATA, +) +from sentry_sdk.feature_flags import FlagBuffer, DEFAULT_FLAG_CAPACITY +from sentry_sdk.profiler.continuous_profiler import ( + get_profiler_id, + try_autostart_continuous_profiler, + try_profile_lifecycle_trace_start, +) +from sentry_sdk.profiler.transaction_profiler import Profile +from sentry_sdk.session import Session from sentry_sdk.tracing_utils import ( Baggage, - extract_sentrytrace_data, has_tracing_enabled, normalize_incoming_data, + PropagationContext, ) from sentry_sdk.tracing import ( BAGGAGE_HEADER_NAME, SENTRY_TRACE_HEADER_NAME, + NoOpSpan, + Span, Transaction, ) -from sentry_sdk._types import TYPE_CHECKING -from sentry_sdk.utils import logger, capture_internal_exceptions - -from sentry_sdk.consts import FALSE_VALUES +from sentry_sdk.utils import ( + capture_internal_exception, + capture_internal_exceptions, + ContextVar, + datetime_from_isoformat, + disable_capture_event, + event_from_exception, + exc_info_from_error, + format_attribute, + logger, + has_logs_enabled, + has_metrics_enabled, +) +import typing +from typing import TYPE_CHECKING, cast if TYPE_CHECKING: + from collections.abc import Mapping + from typing import Any + from typing import Callable + from typing import Deque from typing import Dict + from typing import Generator from typing import Iterator - from typing import Optional - from typing import Deque from typing import List - from typing import Callable + from typing import Optional + from typing import ParamSpec from typing import Tuple from typing import TypeVar + from typing import Union + + from typing_extensions import Unpack from sentry_sdk._types import ( + Attributes, + AttributeValue, Breadcrumb, + BreadcrumbHint, + ErrorProcessor, Event, EventProcessor, - ErrorProcessor, ExcInfo, Hint, + Log, + LogLevelStr, + Metric, + SamplingContext, Type, ) - from sentry_sdk.profiler import Profile - from sentry_sdk.tracing import Span - from sentry_sdk.session import Session + from sentry_sdk.tracing import TransactionKwargs + + import sentry_sdk + + P = ParamSpec("P") + R = TypeVar("R") F = TypeVar("F", bound=Callable[..., Any]) T = TypeVar("T") -global_event_processors = [] # type: List[EventProcessor] +# Holds data that will be added to **all** events sent by this process. +# In case this is a http server (think web framework) with multiple users +# the data will be added to events of all users. +# Typically this is used for process wide data such as the release. +_global_scope: "Optional[Scope]" = None + +# Holds data for the active request. +# This is used to isolate data for different requests or users. +# The isolation scope is usually created by integrations, but may also +# be created manually +_isolation_scope = ContextVar("isolation_scope", default=None) + +# Holds data for the active span. +# This can be used to manually add additional data to a span. +_current_scope = ContextVar("current_scope", default=None) + +global_event_processors: "List[EventProcessor]" = [] + +# A function returning a (trace_id, span_id) tuple +# from an external tracing source (such as otel) +_external_propagation_context_fn: "Optional[Callable[[], Optional[Tuple[str, str]]]]" = None + + +class ScopeType(Enum): + CURRENT = "current" + ISOLATION = "isolation" + GLOBAL = "global" + MERGED = "merged" -def add_global_event_processor(processor): - # type: (EventProcessor) -> None +class _ScopeManager: + def __init__(self, hub: "Optional[Any]" = None) -> None: + self._old_scopes: "List[Scope]" = [] + + def __enter__(self) -> "Scope": + isolation_scope = Scope.get_isolation_scope() + + self._old_scopes.append(isolation_scope) + + forked_scope = isolation_scope.fork() + _isolation_scope.set(forked_scope) + + return forked_scope + + def __exit__(self, exc_type: "Any", exc_value: "Any", tb: "Any") -> None: + old_scope = self._old_scopes.pop() + _isolation_scope.set(old_scope) + + +def add_global_event_processor(processor: "EventProcessor") -> None: global_event_processors.append(processor) -def _attr_setter(fn): - # type: (Any) -> Any +def register_external_propagation_context( + fn: "Callable[[], Optional[Tuple[str, str]]]", +) -> None: + global _external_propagation_context_fn + _external_propagation_context_fn = fn + + +def remove_external_propagation_context() -> None: + global _external_propagation_context_fn + _external_propagation_context_fn = None + + +def get_external_propagation_context() -> "Optional[Tuple[str, str]]": + return ( + _external_propagation_context_fn() if _external_propagation_context_fn else None + ) + + +def has_external_propagation_context() -> bool: + return _external_propagation_context_fn is not None + + +def _attr_setter(fn: "Any") -> "Any": return property(fset=fn, doc=fn.__doc__) -def _disable_capture(fn): - # type: (F) -> F +def _disable_capture(fn: "F") -> "F": @wraps(fn) - def wrapper(self, *args, **kwargs): - # type: (Any, *Dict[str, Any], **Any) -> Any + def wrapper(self: "Any", *args: "Dict[str, Any]", **kwargs: "Any") -> "Any": if not self._should_capture: return try: @@ -81,7 +194,7 @@ def wrapper(self, *args, **kwargs): return wrapper # type: ignore -class Scope(object): +class Scope: """The scope holds extra information that should be sent with all events that belong to it. """ @@ -105,6 +218,8 @@ class Scope(object): "_contexts", "_extras", "_breadcrumbs", + "_n_breadcrumbs_truncated", + "_gen_ai_original_message_count", "_event_processors", "_error_processors", "_should_capture", @@ -114,23 +229,256 @@ class Scope(object): "_force_auto_session_tracking", "_profile", "_propagation_context", + "client", + "_type", + "_last_event_id", + "_flags", + "_attributes", ) - def __init__(self): - # type: () -> None - self._event_processors = [] # type: List[EventProcessor] - self._error_processors = [] # type: List[ErrorProcessor] + def __init__( + self, + ty: "Optional[ScopeType]" = None, + client: "Optional[sentry_sdk.Client]" = None, + ) -> None: + self._type = ty + + self._event_processors: "List[EventProcessor]" = [] + self._error_processors: "List[ErrorProcessor]" = [] + + self._name: "Optional[str]" = None + self._propagation_context: "Optional[PropagationContext]" = None + self._n_breadcrumbs_truncated: int = 0 + self._gen_ai_original_message_count: "Dict[str, int]" = {} + + self.client: "sentry_sdk.client.BaseClient" = NonRecordingClient() - self._name = None # type: Optional[str] - self._propagation_context = None # type: Optional[Dict[str, Any]] + if client is not None: + self.set_client(client) self.clear() incoming_trace_information = self._load_trace_data_from_env() self.generate_propagation_context(incoming_data=incoming_trace_information) - def _load_trace_data_from_env(self): - # type: () -> Optional[Dict[str, str]] + def __copy__(self) -> "Scope": + """ + Returns a copy of this scope. + This also creates a copy of all referenced data structures. + """ + rv: "Scope" = object.__new__(self.__class__) + + rv._type = self._type + rv.client = self.client + rv._level = self._level + rv._name = self._name + rv._fingerprint = self._fingerprint + rv._transaction = self._transaction + rv._transaction_info = self._transaction_info.copy() + rv._user = self._user + + rv._tags = self._tags.copy() + rv._contexts = self._contexts.copy() + rv._extras = self._extras.copy() + + rv._breadcrumbs = copy(self._breadcrumbs) + rv._n_breadcrumbs_truncated = self._n_breadcrumbs_truncated + rv._gen_ai_original_message_count = self._gen_ai_original_message_count.copy() + rv._event_processors = self._event_processors.copy() + rv._error_processors = self._error_processors.copy() + rv._propagation_context = self._propagation_context + + rv._should_capture = self._should_capture + rv._span = self._span + rv._session = self._session + rv._force_auto_session_tracking = self._force_auto_session_tracking + rv._attachments = self._attachments.copy() + + rv._profile = self._profile + + rv._last_event_id = self._last_event_id + + rv._flags = deepcopy(self._flags) + + rv._attributes = self._attributes.copy() + + return rv + + @classmethod + def get_current_scope(cls) -> "Scope": + """ + .. versionadded:: 2.0.0 + + Returns the current scope. + """ + current_scope = _current_scope.get() + if current_scope is None: + current_scope = Scope(ty=ScopeType.CURRENT) + _current_scope.set(current_scope) + + return current_scope + + @classmethod + def set_current_scope(cls, new_current_scope: "Scope") -> None: + """ + .. versionadded:: 2.0.0 + + Sets the given scope as the new current scope overwriting the existing current scope. + :param new_current_scope: The scope to set as the new current scope. + """ + _current_scope.set(new_current_scope) + + @classmethod + def get_isolation_scope(cls) -> "Scope": + """ + .. versionadded:: 2.0.0 + + Returns the isolation scope. + """ + isolation_scope = _isolation_scope.get() + if isolation_scope is None: + isolation_scope = Scope(ty=ScopeType.ISOLATION) + _isolation_scope.set(isolation_scope) + + return isolation_scope + + @classmethod + def set_isolation_scope(cls, new_isolation_scope: "Scope") -> None: + """ + .. versionadded:: 2.0.0 + + Sets the given scope as the new isolation scope overwriting the existing isolation scope. + :param new_isolation_scope: The scope to set as the new isolation scope. + """ + _isolation_scope.set(new_isolation_scope) + + @classmethod + def get_global_scope(cls) -> "Scope": + """ + .. versionadded:: 2.0.0 + + Returns the global scope. + """ + global _global_scope + if _global_scope is None: + _global_scope = Scope(ty=ScopeType.GLOBAL) + + return _global_scope + + @classmethod + def last_event_id(cls) -> "Optional[str]": + """ + .. versionadded:: 2.2.0 + + Returns event ID of the event most recently captured by the isolation scope, or None if no event + has been captured. We do not consider events that are dropped, e.g. by a before_send hook. + Transactions also are not considered events in this context. + + The event corresponding to the returned event ID is NOT guaranteed to actually be sent to Sentry; + whether the event is sent depends on the transport. The event could be sent later or not at all. + Even a sent event could fail to arrive in Sentry due to network issues, exhausted quotas, or + various other reasons. + """ + return cls.get_isolation_scope()._last_event_id + + def _merge_scopes( + self, + additional_scope: "Optional[Scope]" = None, + additional_scope_kwargs: "Optional[Dict[str, Any]]" = None, + ) -> "Scope": + """ + Merges global, isolation and current scope into a new scope and + adds the given additional scope or additional scope kwargs to it. + """ + if additional_scope and additional_scope_kwargs: + raise TypeError("cannot provide scope and kwargs") + + final_scope = copy(_global_scope) if _global_scope is not None else Scope() + final_scope._type = ScopeType.MERGED + + isolation_scope = _isolation_scope.get() + if isolation_scope is not None: + final_scope.update_from_scope(isolation_scope) + + current_scope = _current_scope.get() + if current_scope is not None: + final_scope.update_from_scope(current_scope) + + if self != current_scope and self != isolation_scope: + final_scope.update_from_scope(self) + + if additional_scope is not None: + if callable(additional_scope): + additional_scope(final_scope) + else: + final_scope.update_from_scope(additional_scope) + + elif additional_scope_kwargs: + final_scope.update_from_kwargs(**additional_scope_kwargs) + + return final_scope + + @classmethod + def get_client(cls) -> "sentry_sdk.client.BaseClient": + """ + .. versionadded:: 2.0.0 + + Returns the currently used :py:class:`sentry_sdk.Client`. + This checks the current scope, the isolation scope and the global scope for a client. + If no client is available a :py:class:`sentry_sdk.client.NonRecordingClient` is returned. + """ + current_scope = _current_scope.get() + try: + client = current_scope.client + except AttributeError: + client = None + + if client is not None and client.is_active(): + return client + + isolation_scope = _isolation_scope.get() + try: + client = isolation_scope.client + except AttributeError: + client = None + + if client is not None and client.is_active(): + return client + + try: + client = _global_scope.client # type: ignore + except AttributeError: + client = None + + if client is not None and client.is_active(): + return client + + return NonRecordingClient() + + def set_client( + self, client: "Optional[sentry_sdk.client.BaseClient]" = None + ) -> None: + """ + .. versionadded:: 2.0.0 + + Sets the client for this scope. + + :param client: The client to use in this scope. + If `None` the client of the scope will be replaced by a :py:class:`sentry_sdk.NonRecordingClient`. + + """ + self.client = client if client is not None else NonRecordingClient() + + def fork(self) -> "Scope": + """ + .. versionadded:: 2.0.0 + + Returns a fork of this scope. + """ + forked_scope = copy(self) + return forked_scope + + def _load_trace_data_from_env(self) -> "Optional[Dict[str, str]]": """ Load Sentry trace id and baggage from environment variables. Can be disabled by setting SENTRY_USE_ENVIRONMENT to "false". @@ -156,198 +504,225 @@ def _load_trace_data_from_env(self): return incoming_trace_information or None - def _extract_propagation_context(self, data): - # type: (Dict[str, Any]) -> Optional[Dict[str, Any]] - context = {} # type: Dict[str, Any] - normalized_data = normalize_incoming_data(data) - - baggage_header = normalized_data.get(BAGGAGE_HEADER_NAME) - if baggage_header: - context["dynamic_sampling_context"] = Baggage.from_incoming_header( - baggage_header - ).dynamic_sampling_context() - - sentry_trace_header = normalized_data.get(SENTRY_TRACE_HEADER_NAME) - if sentry_trace_header: - sentrytrace_data = extract_sentrytrace_data(sentry_trace_header) - if sentrytrace_data is not None: - context.update(sentrytrace_data) - - only_baggage_no_sentry_trace = ( - "dynamic_sampling_context" in context and "trace_id" not in context - ) - if only_baggage_no_sentry_trace: - context.update(self._create_new_propagation_context()) - - if context: - if not context.get("span_id"): - context["span_id"] = uuid.uuid4().hex[16:] - - return context - - return None - - def _create_new_propagation_context(self): - # type: () -> Dict[str, Any] - return { - "trace_id": uuid.uuid4().hex, - "span_id": uuid.uuid4().hex[16:], - "parent_span_id": None, - "dynamic_sampling_context": None, - } - - def set_new_propagation_context(self): - # type: () -> None + def set_new_propagation_context(self) -> None: """ Creates a new propagation context and sets it as `_propagation_context`. Overwriting existing one. """ - self._propagation_context = self._create_new_propagation_context() - logger.debug( - "[Tracing] Create new propagation context: %s", - self._propagation_context, - ) + self._propagation_context = PropagationContext() - def generate_propagation_context(self, incoming_data=None): - # type: (Optional[Dict[str, str]]) -> None + def generate_propagation_context( + self, incoming_data: "Optional[Dict[str, str]]" = None + ) -> None: """ - Makes sure `_propagation_context` is set. - If there is `incoming_data` overwrite existing `_propagation_context`. - if there is no `incoming_data` create new `_propagation_context`, but do NOT overwrite if already existing. + Makes sure the propagation context is set on the scope. + If there is `incoming_data` overwrite existing propagation context. + If there is no `incoming_data` create new propagation context, but do NOT overwrite if already existing. """ - if incoming_data: - context = self._extract_propagation_context(incoming_data) - - if context is not None: - self._propagation_context = context - logger.debug( - "[Tracing] Extracted propagation context from incoming data: %s", - self._propagation_context, - ) + if incoming_data is not None: + self._propagation_context = PropagationContext.from_incoming_data( + incoming_data + ) - if self._propagation_context is None: - self.set_new_propagation_context() + # TODO-neel this below is a BIG code smell but requires a bunch of other refactoring + if self._type != ScopeType.CURRENT: + if self._propagation_context is None: + self.set_new_propagation_context() - def get_dynamic_sampling_context(self): - # type: () -> Optional[Dict[str, str]] + def get_dynamic_sampling_context(self) -> "Optional[Dict[str, str]]": """ Returns the Dynamic Sampling Context from the Propagation Context. If not existing, creates a new one. + + Deprecated: Logic moved to PropagationContext, don't use directly. """ if self._propagation_context is None: return None - baggage = self.get_baggage() - if baggage is not None: - self._propagation_context[ - "dynamic_sampling_context" - ] = baggage.dynamic_sampling_context() - - return self._propagation_context["dynamic_sampling_context"] + return self._propagation_context.dynamic_sampling_context - def get_traceparent(self): - # type: () -> Optional[str] + def get_traceparent(self, *args: "Any", **kwargs: "Any") -> "Optional[str]": """ - Returns the Sentry "sentry-trace" header (aka the traceparent) from the Propagation Context. + Returns the Sentry "sentry-trace" header (aka the traceparent) from the + currently active span or the scopes Propagation Context. """ - if self._propagation_context is None: - return None + client = self.get_client() - traceparent = "%s-%s" % ( - self._propagation_context["trace_id"], - self._propagation_context["span_id"], - ) - return traceparent + # If we have an active span, return traceparent from there + if has_tracing_enabled(client.options) and self.span is not None: + return self.span.to_traceparent() - def get_baggage(self): - # type: () -> Optional[Baggage] - if self._propagation_context is None: - return None + # else return traceparent from the propagation context + return self.get_active_propagation_context().to_traceparent() - dynamic_sampling_context = self._propagation_context.get( - "dynamic_sampling_context" - ) - if dynamic_sampling_context is None: - return Baggage.from_options(self) - else: - return Baggage(dynamic_sampling_context) + def get_baggage(self, *args: "Any", **kwargs: "Any") -> "Optional[Baggage]": + """ + Returns the Sentry "baggage" header containing trace information from the + currently active span or the scopes Propagation Context. + """ + client = self.get_client() + + # If we have an active span, return baggage from there + if has_tracing_enabled(client.options) and self.span is not None: + return self.span.to_baggage() - def get_trace_context(self): - # type: () -> Any + # else return baggage from the propagation context + return self.get_active_propagation_context().get_baggage() + + def get_trace_context(self) -> "Dict[str, Any]": """ Returns the Sentry "trace" context from the Propagation Context. """ - if self._propagation_context is None: - return None + if has_tracing_enabled(self.get_client().options) and self._span is not None: + return self._span.get_trace_context() + + # if we are tracing externally (otel), those values take precedence + external_propagation_context = get_external_propagation_context() + if external_propagation_context: + trace_id, span_id = external_propagation_context + return {"trace_id": trace_id, "span_id": span_id} + + propagation_context = self.get_active_propagation_context() + + return { + "trace_id": propagation_context.trace_id, + "span_id": propagation_context.span_id, + "parent_span_id": propagation_context.parent_span_id, + "dynamic_sampling_context": propagation_context.dynamic_sampling_context, + } + + def trace_propagation_meta(self, *args: "Any", **kwargs: "Any") -> str: + """ + Return meta tags which should be injected into HTML templates + to allow propagation of trace information. + """ + span = kwargs.pop("span", None) + if span is not None: + logger.warning( + "The parameter `span` in trace_propagation_meta() is deprecated and will be removed in the future." + ) + + meta = "" - trace_context = { - "trace_id": self._propagation_context["trace_id"], - "span_id": self._propagation_context["span_id"], - "parent_span_id": self._propagation_context["parent_span_id"], - "dynamic_sampling_context": self.get_dynamic_sampling_context(), - } # type: Dict[str, Any] + for name, content in self.iter_trace_propagation_headers(): + meta += f'' - return trace_context + return meta - def iter_headers(self): - # type: () -> Iterator[Tuple[str, str]] + def iter_headers(self) -> "Iterator[Tuple[str, str]]": """ Creates a generator which returns the `sentry-trace` and `baggage` headers from the Propagation Context. + Deprecated: use PropagationContext.iter_headers instead. + """ + if self._propagation_context is not None: + yield from self._propagation_context.iter_headers() + + def iter_trace_propagation_headers( + self, *args: "Any", **kwargs: "Any" + ) -> "Generator[Tuple[str, str], None, None]": + """ + Return HTTP headers which allow propagation of trace data. + + If a span is given, the trace data will taken from the span. + If no span is given, the trace data is taken from the scope. """ + client = self.get_client() + if not client.options.get("propagate_traces"): + warnings.warn( + "The `propagate_traces` parameter is deprecated. Please use `trace_propagation_targets` instead.", + DeprecationWarning, + stacklevel=2, + ) + return + + span = kwargs.pop("span", None) + span = span or self.span + + if has_tracing_enabled(client.options) and span is not None: + for header in span.iter_headers(): + yield header + elif has_external_propagation_context(): + # when we have an external_propagation_context (otlp) + # we leave outgoing propagation to the propagator + return + else: + for header in self.get_active_propagation_context().iter_headers(): + yield header + + def get_active_propagation_context(self) -> "PropagationContext": if self._propagation_context is not None: - traceparent = self.get_traceparent() - if traceparent is not None: - yield SENTRY_TRACE_HEADER_NAME, traceparent + return self._propagation_context - dsc = self.get_dynamic_sampling_context() - if dsc is not None: - baggage = Baggage(dsc).serialize() - yield BAGGAGE_HEADER_NAME, baggage + current_scope = self.get_current_scope() + if current_scope._propagation_context is not None: + return current_scope._propagation_context - def clear(self): - # type: () -> None + isolation_scope = self.get_isolation_scope() + # should actually never happen, but just in case someone calls scope.clear + if isolation_scope._propagation_context is None: + isolation_scope._propagation_context = PropagationContext() + return isolation_scope._propagation_context + + def clear(self) -> None: """Clears the entire scope.""" - self._level = None # type: Optional[str] - self._fingerprint = None # type: Optional[List[str]] - self._transaction = None # type: Optional[str] - self._transaction_info = {} # type: Dict[str, str] - self._user = None # type: Optional[Dict[str, Any]] + self._level: "Optional[LogLevelStr]" = None + self._fingerprint: "Optional[List[str]]" = None + self._transaction: "Optional[str]" = None + self._transaction_info: "dict[str, str]" = {} + self._user: "Optional[Dict[str, Any]]" = None - self._tags = {} # type: Dict[str, Any] - self._contexts = {} # type: Dict[str, Dict[str, Any]] - self._extras = {} # type: Dict[str, Any] - self._attachments = [] # type: List[Attachment] + self._tags: "Dict[str, Any]" = {} + self._contexts: "Dict[str, Dict[str, Any]]" = {} + self._extras: "dict[str, Any]" = {} + self._attachments: "List[Attachment]" = [] self.clear_breadcrumbs() - self._should_capture = True + self._should_capture: bool = True - self._span = None # type: Optional[Span] - self._session = None # type: Optional[Session] - self._force_auto_session_tracking = None # type: Optional[bool] + self._span: "Optional[Span]" = None + self._session: "Optional[Session]" = None + self._force_auto_session_tracking: "Optional[bool]" = None - self._profile = None # type: Optional[Profile] + self._profile: "Optional[Profile]" = None self._propagation_context = None + # self._last_event_id is only applicable to isolation scopes + self._last_event_id: "Optional[str]" = None + self._flags: "Optional[FlagBuffer]" = None + + self._attributes: "Attributes" = {} + @_attr_setter - def level(self, value): - # type: (Optional[str]) -> None - """When set this overrides the level. Deprecated in favor of set_level.""" + def level(self, value: "LogLevelStr") -> None: + """ + When set this overrides the level. + + .. deprecated:: 1.0.0 + Use :func:`set_level` instead. + + :param value: The level to set. + """ + logger.warning( + "Deprecated: use .set_level() instead. This will be removed in the future." + ) + self._level = value - def set_level(self, value): - # type: (Optional[str]) -> None - """Sets the level for the scope.""" + def set_level(self, value: "LogLevelStr") -> None: + """ + Sets the level for the scope. + + :param value: The level to set. + """ self._level = value @_attr_setter - def fingerprint(self, value): - # type: (Optional[List[str]]) -> None + def fingerprint(self, value: "Optional[List[str]]") -> None: """When set this overrides the default fingerprint.""" self._fingerprint = value @property - def transaction(self): - # type: () -> Any + def transaction(self) -> "Any": # would be type: () -> Optional[Transaction], see https://github.com/python/mypy/issues/3004 """Return the transaction (root span) in the scope, if any.""" @@ -364,8 +739,7 @@ def transaction(self): return self._span.containing_transaction @transaction.setter - def transaction(self, value): - # type: (Any) -> None + def transaction(self, value: "Any") -> None: # would be type: (Optional[str]) -> None, see https://github.com/python/mypy/issues/3004 """When set this forces a specific transaction name to be set. @@ -388,8 +762,7 @@ def transaction(self, value): if self._span and self._span.containing_transaction: self._span.containing_transaction.name = value - def set_transaction_name(self, name, source=None): - # type: (str, Optional[str]) -> None + def set_transaction_name(self, name: str, source: "Optional[str]" = None) -> None: """Set the transaction name and optionally the transaction source.""" self._transaction = name @@ -402,27 +775,29 @@ def set_transaction_name(self, name, source=None): self._transaction_info["source"] = source @_attr_setter - def user(self, value): - # type: (Optional[Dict[str, Any]]) -> None + def user(self, value: "Optional[Dict[str, Any]]") -> None: """When set a specific user is bound to the scope. Deprecated in favor of set_user.""" + warnings.warn( + "The `Scope.user` setter is deprecated in favor of `Scope.set_user()`.", + DeprecationWarning, + stacklevel=2, + ) self.set_user(value) - def set_user(self, value): - # type: (Optional[Dict[str, Any]]) -> None + def set_user(self, value: "Optional[Dict[str, Any]]") -> None: """Sets a user for the scope.""" self._user = value - if self._session is not None: - self._session.update(user=value) + session = self.get_isolation_scope()._session + if session is not None: + session.update(user=value) @property - def span(self): - # type: () -> Optional[Span] + def span(self) -> "Optional[Span]": """Get/set current tracing span or transaction.""" return self._span @span.setter - def span(self, span): - # type: (Optional[Span]) -> None + def span(self, span: "Optional[Span]") -> None: self._span = span # XXX: this differs from the implementation in JS, there Scope.setSpan # does not set Scope._transactionName. @@ -430,81 +805,102 @@ def span(self, span): transaction = span if transaction.name: self._transaction = transaction.name + if transaction.source: + self._transaction_info["source"] = transaction.source @property - def profile(self): - # type: () -> Optional[Profile] + def profile(self) -> "Optional[Profile]": return self._profile @profile.setter - def profile(self, profile): - # type: (Optional[Profile]) -> None - + def profile(self, profile: "Optional[Profile]") -> None: self._profile = profile - def set_tag( - self, - key, # type: str - value, # type: Any - ): - # type: (...) -> None - """Sets a tag for a key to a specific value.""" + def set_tag(self, key: str, value: "Any") -> None: + """ + Sets a tag for a key to a specific value. + + :param key: Key of the tag to set. + + :param value: Value of the tag to set. + """ self._tags[key] = value - def remove_tag( - self, key # type: str - ): - # type: (...) -> None - """Removes a specific tag.""" + def set_tags(self, tags: "Mapping[str, object]") -> None: + """Sets multiple tags at once. + + This method updates multiple tags at once. The tags are passed as a dictionary + or other mapping type. + + Calling this method is equivalent to calling `set_tag` on each key-value pair + in the mapping. If a tag key already exists in the scope, its value will be + updated. If the tag key does not exist in the scope, the key-value pair will + be added to the scope. + + This method only modifies tag keys in the `tags` mapping passed to the method. + `scope.set_tags({})` is, therefore, a no-op. + + :param tags: A mapping of tag keys to tag values to set. + """ + self._tags.update(tags) + + def remove_tag(self, key: str) -> None: + """ + Removes a specific tag. + + :param key: Key of the tag to remove. + """ self._tags.pop(key, None) def set_context( self, - key, # type: str - value, # type: Dict[str, Any] - ): - # type: (...) -> None - """Binds a context at a certain key to a specific value.""" + key: str, + value: "Dict[str, Any]", + ) -> None: + """ + Binds a context at a certain key to a specific value. + """ self._contexts[key] = value def remove_context( - self, key # type: str - ): - # type: (...) -> None + self, + key: str, + ) -> None: """Removes a context.""" self._contexts.pop(key, None) def set_extra( self, - key, # type: str - value, # type: Any - ): - # type: (...) -> None + key: str, + value: "Any", + ) -> None: """Sets an extra key to a specific value.""" self._extras[key] = value def remove_extra( - self, key # type: str - ): - # type: (...) -> None + self, + key: str, + ) -> None: """Removes a specific extra key.""" self._extras.pop(key, None) - def clear_breadcrumbs(self): - # type: () -> None + def clear_breadcrumbs(self) -> None: """Clears breadcrumb buffer.""" - self._breadcrumbs = deque() # type: Deque[Breadcrumb] + self._breadcrumbs: "Deque[Breadcrumb]" = deque() + self._n_breadcrumbs_truncated = 0 def add_attachment( self, - bytes=None, # type: Optional[bytes] - filename=None, # type: Optional[str] - path=None, # type: Optional[str] - content_type=None, # type: Optional[str] - add_to_transactions=False, # type: bool - ): - # type: (...) -> None - """Adds an attachment to future events sent.""" + bytes: "Union[None, bytes, Callable[[], bytes]]" = None, + filename: "Optional[str]" = None, + path: "Optional[str]" = None, + content_type: "Optional[str]" = None, + add_to_transactions: bool = False, + ) -> None: + """Adds an attachment to future events sent from this scope. + + The parameters are the same as for the :py:class:`sentry_sdk.attachments.Attachment` constructor. + """ self._attachments.append( Attachment( bytes=bytes, @@ -515,144 +911,736 @@ def add_attachment( ) ) - def add_event_processor( - self, func # type: EventProcessor - ): - # type: (...) -> None - """Register a scope local event processor on the scope. - - :param func: This function behaves like `before_send.` - """ - if len(self._event_processors) > 20: - logger.warning( - "Too many event processors on scope! Clearing list to free up some memory: %r", - self._event_processors, - ) - del self._event_processors[:] - - self._event_processors.append(func) - - def add_error_processor( + def add_breadcrumb( self, - func, # type: ErrorProcessor - cls=None, # type: Optional[Type[BaseException]] - ): - # type: (...) -> None - """Register a scope local error processor on the scope. + crumb: "Optional[Breadcrumb]" = None, + hint: "Optional[BreadcrumbHint]" = None, + **kwargs: "Any", + ) -> None: + """ + Adds a breadcrumb. - :param func: A callback that works similar to an event processor but is invoked with the original exception info triple as second argument. + :param crumb: Dictionary with the data as the sentry v7/v8 protocol expects. - :param cls: Optionally, only process exceptions of this type. + :param hint: An optional value that can be used by `before_breadcrumb` + to customize the breadcrumbs that are emitted. """ - if cls is not None: - cls_ = cls # For mypy. - real_func = func + client = self.get_client() - def func(event, exc_info): - # type: (Event, ExcInfo) -> Optional[Event] - try: - is_inst = isinstance(exc_info[1], cls_) - except Exception: - is_inst = False - if is_inst: - return real_func(event, exc_info) - return event + if not client.is_active(): + logger.info("Dropped breadcrumb because no client bound") + return - self._error_processors.append(func) + before_breadcrumb = client.options.get("before_breadcrumb") + max_breadcrumbs = client.options.get("max_breadcrumbs", DEFAULT_MAX_BREADCRUMBS) - @_disable_capture - def apply_to_event( - self, - event, # type: Event - hint, # type: Hint - options=None, # type: Optional[Dict[str, Any]] - ): - # type: (...) -> Optional[Event] - """Applies the information contained on the scope to the given event.""" + crumb: "Breadcrumb" = dict(crumb or ()) + crumb.update(kwargs) + if not crumb: + return - def _drop(cause, ty): - # type: (Any, str) -> Optional[Any] - logger.info("%s (%s) dropped event", ty, cause) - return None + hint: "Hint" = dict(hint or ()) - is_transaction = event.get("type") == "transaction" + if crumb.get("timestamp") is None: + crumb["timestamp"] = datetime.now(timezone.utc) + if crumb.get("type") is None: + crumb["type"] = "default" - # put all attachments into the hint. This lets callbacks play around - # with attachments. We also later pull this out of the hint when we - # create the envelope. - attachments_to_send = hint.get("attachments") or [] - for attachment in self._attachments: - if not is_transaction or attachment.add_to_transactions: - attachments_to_send.append(attachment) - hint["attachments"] = attachments_to_send + if before_breadcrumb is not None: + new_crumb = before_breadcrumb(crumb, hint) + else: + new_crumb = crumb + + if new_crumb is not None: + self._breadcrumbs.append(new_crumb) + else: + logger.info("before breadcrumb dropped breadcrumb (%s)", crumb) + + while len(self._breadcrumbs) > max_breadcrumbs: + self._breadcrumbs.popleft() + self._n_breadcrumbs_truncated += 1 + + def start_transaction( + self, + transaction: "Optional[Transaction]" = None, + instrumenter: str = INSTRUMENTER.SENTRY, + custom_sampling_context: "Optional[SamplingContext]" = None, + **kwargs: "Unpack[TransactionKwargs]", + ) -> "Union[Transaction, NoOpSpan]": + """ + Start and return a transaction. + + Start an existing transaction if given, otherwise create and start a new + transaction with kwargs. + + This is the entry point to manual tracing instrumentation. + + A tree structure can be built by adding child spans to the transaction, + and child spans to other spans. To start a new child span within the + transaction or any span, call the respective `.start_child()` method. + + Every child span must be finished before the transaction is finished, + otherwise the unfinished spans are discarded. + + When used as context managers, spans and transactions are automatically + finished at the end of the `with` block. If not using context managers, + call the `.finish()` method. + + When the transaction is finished, it will be sent to Sentry with all its + finished child spans. + + :param transaction: The transaction to start. If omitted, we create and + start a new transaction. + :param instrumenter: This parameter is meant for internal use only. It + will be removed in the next major version. + :param custom_sampling_context: The transaction's custom sampling context. + :param kwargs: Optional keyword arguments to be passed to the Transaction + constructor. See :py:class:`sentry_sdk.tracing.Transaction` for + available arguments. + """ + kwargs.setdefault("scope", self) + + client = self.get_client() + + configuration_instrumenter = client.options["instrumenter"] + + if instrumenter != configuration_instrumenter: + return NoOpSpan() + + try_autostart_continuous_profiler() + + custom_sampling_context = custom_sampling_context or {} + + # kwargs at this point has type TransactionKwargs, since we have removed + # the client and custom_sampling_context from it. + transaction_kwargs: "TransactionKwargs" = kwargs + + # if we haven't been given a transaction, make one + if transaction is None: + transaction = Transaction(**transaction_kwargs) + + # use traces_sample_rate, traces_sampler, and/or inheritance to make a + # sampling decision + sampling_context = { + "transaction_context": transaction.to_json(), + "parent_sampled": transaction.parent_sampled, + } + sampling_context.update(custom_sampling_context) + transaction._set_initial_sampling_decision(sampling_context=sampling_context) + + # update the sample rate in the dsc + if transaction.sample_rate is not None: + propagation_context = self.get_active_propagation_context() + baggage = propagation_context.baggage + + if baggage is not None: + baggage.sentry_items["sample_rate"] = str(transaction.sample_rate) + + if transaction._baggage: + transaction._baggage.sentry_items["sample_rate"] = str( + transaction.sample_rate + ) + + if transaction.sampled: + profile = Profile( + transaction.sampled, transaction._start_timestamp_monotonic_ns + ) + profile._set_initial_sampling_decision(sampling_context=sampling_context) + + transaction._profile = profile + + transaction._continuous_profile = try_profile_lifecycle_trace_start() + + # Typically, the profiler is set when the transaction is created. But when + # using the auto lifecycle, the profiler isn't running when the first + # transaction is started. So make sure we update the profiler id on it. + if transaction._continuous_profile is not None: + transaction.set_profiler_id(get_profiler_id()) + + # we don't bother to keep spans if we already know we're not going to + # send the transaction + max_spans = (client.options["_experiments"].get("max_spans")) or 1000 + transaction.init_span_recorder(maxlen=max_spans) + + return transaction + def start_span( + self, instrumenter: str = INSTRUMENTER.SENTRY, **kwargs: "Any" + ) -> "Span": + """ + Start a span whose parent is the currently active span or transaction, if any. + + The return value is a :py:class:`sentry_sdk.tracing.Span` instance, + typically used as a context manager to start and stop timing in a `with` + block. + + Only spans contained in a transaction are sent to Sentry. Most + integrations start a transaction at the appropriate time, for example + for every incoming HTTP request. Use + :py:meth:`sentry_sdk.start_transaction` to start a new transaction when + one is not already in progress. + + For supported `**kwargs` see :py:class:`sentry_sdk.tracing.Span`. + + The instrumenter parameter is deprecated for user code, and it will + be removed in the next major version. Going forward, it should only + be used by the SDK itself. + """ + if kwargs.get("description") is not None: + warnings.warn( + "The `description` parameter is deprecated. Please use `name` instead.", + DeprecationWarning, + stacklevel=2, + ) + + with new_scope(): + kwargs.setdefault("scope", self) + + client = self.get_client() + + configuration_instrumenter = client.options["instrumenter"] + + if instrumenter != configuration_instrumenter: + return NoOpSpan() + + # get current span or transaction + span = self.span or self.get_isolation_scope().span + + if span is None: + # New spans get the `trace_id` from the scope + if "trace_id" not in kwargs: + propagation_context = self.get_active_propagation_context() + kwargs["trace_id"] = propagation_context.trace_id + + span = Span(**kwargs) + else: + # Children take `trace_id`` from the parent span. + span = span.start_child(**kwargs) + + return span + + def continue_trace( + self, + environ_or_headers: "Dict[str, Any]", + op: "Optional[str]" = None, + name: "Optional[str]" = None, + source: "Optional[str]" = None, + origin: str = "manual", + ) -> "Transaction": + """ + Sets the propagation context from environment or headers and returns a transaction. + """ + self.generate_propagation_context(environ_or_headers) + + # generate_propagation_context ensures that the propagation_context is not None. + propagation_context = cast(PropagationContext, self._propagation_context) + + optional_kwargs = {} + if name: + optional_kwargs["name"] = name + if source: + optional_kwargs["source"] = source + + return Transaction( + op=op, + origin=origin, + baggage=propagation_context.baggage, + parent_sampled=propagation_context.parent_sampled, + trace_id=propagation_context.trace_id, + parent_span_id=propagation_context.parent_span_id, + same_process_as_parent=False, + **optional_kwargs, + ) + + def capture_event( + self, + event: "Event", + hint: "Optional[Hint]" = None, + scope: "Optional[Scope]" = None, + **scope_kwargs: "Any", + ) -> "Optional[str]": + """ + Captures an event. + + Merges given scope data and calls :py:meth:`sentry_sdk.client._Client.capture_event`. + + :param event: A ready-made event that can be directly sent to Sentry. + + :param hint: Contains metadata about the event that can be read from `before_send`, such as the original exception object or a HTTP request object. + + :param scope: An optional :py:class:`sentry_sdk.Scope` to apply to events. + The `scope` and `scope_kwargs` parameters are mutually exclusive. + + :param scope_kwargs: Optional data to apply to event. + For supported `**scope_kwargs` see :py:meth:`sentry_sdk.Scope.update_from_kwargs`. + The `scope` and `scope_kwargs` parameters are mutually exclusive. + + :returns: An `event_id` if the SDK decided to send the event (see :py:meth:`sentry_sdk.client._Client.capture_event`). + """ + if disable_capture_event.get(False): + return None + + scope = self._merge_scopes(scope, scope_kwargs) + + event_id = self.get_client().capture_event(event=event, hint=hint, scope=scope) + + if event_id is not None and event.get("type") != "transaction": + self.get_isolation_scope()._last_event_id = event_id + + return event_id + + def _capture_log(self, log: "Optional[Log]") -> None: + if log is None: + return + + client = self.get_client() + if not has_logs_enabled(client.options): + return + + merged_scope = self._merge_scopes() + + debug = client.options.get("debug", False) + if debug: + logger.debug( + f"[Sentry Logs] [{log.get('severity_text')}] {log.get('body')}" + ) + + client._capture_log(log, scope=merged_scope) + + def _capture_metric(self, metric: "Optional[Metric]") -> None: + if metric is None: + return + + client = self.get_client() + if not has_metrics_enabled(client.options): + return + + merged_scope = self._merge_scopes() + + debug = client.options.get("debug", False) + if debug: + logger.debug( + f"[Sentry Metrics] [{metric.get('type')}] {metric.get('name')}: {metric.get('value')}" + ) + + client._capture_metric(metric, scope=merged_scope) + + def capture_message( + self, + message: str, + level: "Optional[LogLevelStr]" = None, + scope: "Optional[Scope]" = None, + **scope_kwargs: "Any", + ) -> "Optional[str]": + """ + Captures a message. + + :param message: The string to send as the message. + + :param level: If no level is provided, the default level is `info`. + + :param scope: An optional :py:class:`sentry_sdk.Scope` to apply to events. + The `scope` and `scope_kwargs` parameters are mutually exclusive. + + :param scope_kwargs: Optional data to apply to event. + For supported `**scope_kwargs` see :py:meth:`sentry_sdk.Scope.update_from_kwargs`. + The `scope` and `scope_kwargs` parameters are mutually exclusive. + + :returns: An `event_id` if the SDK decided to send the event (see :py:meth:`sentry_sdk.client._Client.capture_event`). + """ + if disable_capture_event.get(False): + return None + + if level is None: + level = "info" + + event: "Event" = { + "message": message, + "level": level, + } + + return self.capture_event(event, scope=scope, **scope_kwargs) + + def capture_exception( + self, + error: "Optional[Union[BaseException, ExcInfo]]" = None, + scope: "Optional[Scope]" = None, + **scope_kwargs: "Any", + ) -> "Optional[str]": + """Captures an exception. + + :param error: An exception to capture. If `None`, `sys.exc_info()` will be used. + + :param scope: An optional :py:class:`sentry_sdk.Scope` to apply to events. + The `scope` and `scope_kwargs` parameters are mutually exclusive. + + :param scope_kwargs: Optional data to apply to event. + For supported `**scope_kwargs` see :py:meth:`sentry_sdk.Scope.update_from_kwargs`. + The `scope` and `scope_kwargs` parameters are mutually exclusive. + + :returns: An `event_id` if the SDK decided to send the event (see :py:meth:`sentry_sdk.client._Client.capture_event`). + """ + if disable_capture_event.get(False): + return None + + if error is not None: + exc_info = exc_info_from_error(error) + else: + exc_info = sys.exc_info() + + event, hint = event_from_exception( + exc_info, client_options=self.get_client().options + ) + + try: + return self.capture_event(event, hint=hint, scope=scope, **scope_kwargs) + except Exception: + capture_internal_exception(sys.exc_info()) + + return None + + def start_session(self, *args: "Any", **kwargs: "Any") -> None: + """Starts a new session.""" + session_mode = kwargs.pop("session_mode", "application") + + self.end_session() + + client = self.get_client() + self._session = Session( + release=client.options.get("release"), + environment=client.options.get("environment"), + user=self._user, + session_mode=session_mode, + ) + + def end_session(self, *args: "Any", **kwargs: "Any") -> None: + """Ends the current session if there is one.""" + session = self._session + self._session = None + + if session is not None: + session.close() + self.get_client().capture_session(session) + + def stop_auto_session_tracking(self, *args: "Any", **kwargs: "Any") -> None: + """Stops automatic session tracking. + + This temporarily session tracking for the current scope when called. + To resume session tracking call `resume_auto_session_tracking`. + """ + self.end_session() + self._force_auto_session_tracking = False + + def resume_auto_session_tracking(self) -> None: + """Resumes automatic session tracking for the current scope if + disabled earlier. This requires that generally automatic session + tracking is enabled. + """ + self._force_auto_session_tracking = None + + def add_event_processor( + self, + func: "EventProcessor", + ) -> None: + """Register a scope local event processor on the scope. + + :param func: This function behaves like `before_send.` + """ + if len(self._event_processors) > 20: + logger.warning( + "Too many event processors on scope! Clearing list to free up some memory: %r", + self._event_processors, + ) + del self._event_processors[:] + + self._event_processors.append(func) + + def add_error_processor( + self, + func: "ErrorProcessor", + cls: "Optional[Type[BaseException]]" = None, + ) -> None: + """Register a scope local error processor on the scope. + + :param func: A callback that works similar to an event processor but is invoked with the original exception info triple as second argument. + + :param cls: Optionally, only process exceptions of this type. + """ + if cls is not None: + cls_ = cls # For mypy. + real_func = func + + def func(event: "Event", exc_info: "ExcInfo") -> "Optional[Event]": + try: + is_inst = isinstance(exc_info[1], cls_) + except Exception: + is_inst = False + if is_inst: + return real_func(event, exc_info) + return event + + self._error_processors.append(func) + + def _apply_level_to_event( + self, event: "Event", hint: "Hint", options: "Optional[Dict[str, Any]]" + ) -> None: if self._level is not None: event["level"] = self._level - if not is_transaction: - event.setdefault("breadcrumbs", {}).setdefault("values", []).extend( - self._breadcrumbs - ) + def _apply_breadcrumbs_to_event( + self, event: "Event", hint: "Hint", options: "Optional[Dict[str, Any]]" + ) -> None: + event.setdefault("breadcrumbs", {}) + + # This check is just for mypy - + if not isinstance(event["breadcrumbs"], AnnotatedValue): + event["breadcrumbs"].setdefault("values", []) + event["breadcrumbs"]["values"].extend(self._breadcrumbs) + + # Attempt to sort timestamps + try: + if not isinstance(event["breadcrumbs"], AnnotatedValue): + for crumb in event["breadcrumbs"]["values"]: + if isinstance(crumb["timestamp"], str): + crumb["timestamp"] = datetime_from_isoformat(crumb["timestamp"]) + + event["breadcrumbs"]["values"].sort( + key=lambda crumb: crumb["timestamp"] + ) + except Exception as err: + logger.debug("Error when sorting breadcrumbs", exc_info=err) + pass + def _apply_user_to_event( + self, event: "Event", hint: "Hint", options: "Optional[Dict[str, Any]]" + ) -> None: if event.get("user") is None and self._user is not None: event["user"] = self._user + def _apply_transaction_name_to_event( + self, event: "Event", hint: "Hint", options: "Optional[Dict[str, Any]]" + ) -> None: if event.get("transaction") is None and self._transaction is not None: event["transaction"] = self._transaction + def _apply_transaction_info_to_event( + self, event: "Event", hint: "Hint", options: "Optional[Dict[str, Any]]" + ) -> None: if event.get("transaction_info") is None and self._transaction_info is not None: event["transaction_info"] = self._transaction_info + def _apply_fingerprint_to_event( + self, event: "Event", hint: "Hint", options: "Optional[Dict[str, Any]]" + ) -> None: if event.get("fingerprint") is None and self._fingerprint is not None: event["fingerprint"] = self._fingerprint + def _apply_extra_to_event( + self, event: "Event", hint: "Hint", options: "Optional[Dict[str, Any]]" + ) -> None: if self._extras: event.setdefault("extra", {}).update(self._extras) + def _apply_tags_to_event( + self, event: "Event", hint: "Hint", options: "Optional[Dict[str, Any]]" + ) -> None: if self._tags: event.setdefault("tags", {}).update(self._tags) + def _apply_contexts_to_event( + self, event: "Event", hint: "Hint", options: "Optional[Dict[str, Any]]" + ) -> None: if self._contexts: event.setdefault("contexts", {}).update(self._contexts) contexts = event.setdefault("contexts", {}) + # Add "trace" context if contexts.get("trace") is None: - if has_tracing_enabled(options) and self._span is not None: - contexts["trace"] = self._span.get_trace_context() - else: - contexts["trace"] = self.get_trace_context() + contexts["trace"] = self.get_trace_context() + + def _apply_flags_to_event( + self, event: "Event", hint: "Hint", options: "Optional[Dict[str, Any]]" + ) -> None: + flags = self.flags.get() + if len(flags) > 0: + event.setdefault("contexts", {}).setdefault("flags", {}).update( + {"values": flags} + ) - try: - replay_id = contexts["trace"]["dynamic_sampling_context"]["replay_id"] - except (KeyError, TypeError): - replay_id = None + def _apply_global_attributes_to_telemetry( + self, telemetry: "Union[Log, Metric]" + ) -> None: + # TODO: Global stuff like this should just be retrieved at init time and + # put onto the global scope's attributes and then applied to events + # from there + from sentry_sdk.client import SDK_INFO - if replay_id is not None: - contexts["replay"] = { - "replay_id": replay_id, - } + attributes = telemetry["attributes"] + attributes["sentry.sdk.name"] = SDK_INFO["name"] + attributes["sentry.sdk.version"] = SDK_INFO["version"] + + options = self.get_client().options + + server_name = options.get("server_name") + if server_name is not None and SPANDATA.SERVER_ADDRESS not in attributes: + attributes[SPANDATA.SERVER_ADDRESS] = server_name + + environment = options.get("environment") + if environment is not None and "sentry.environment" not in attributes: + attributes["sentry.environment"] = environment + + release = options.get("release") + if release is not None and "sentry.release" not in attributes: + attributes["sentry.release"] = release + + def _apply_scope_attributes_to_telemetry( + self, telemetry: "Union[Log, Metric]" + ) -> None: + for attribute, value in self._attributes.items(): + if attribute not in telemetry["attributes"]: + telemetry["attributes"][attribute] = value + + def _apply_user_attributes_to_telemetry( + self, telemetry: "Union[Log, Metric]" + ) -> None: + attributes = telemetry["attributes"] + + if not should_send_default_pii() or self._user is None: + return + + for attribute_name, user_attribute in ( + ("user.id", "id"), + ("user.name", "username"), + ("user.email", "email"), + ): + if user_attribute in self._user and attribute_name not in attributes: + attributes[attribute_name] = self._user[user_attribute] + + def _drop(self, cause: "Any", ty: str) -> "Optional[Any]": + logger.info("%s (%s) dropped event", ty, cause) + return None + + def run_error_processors(self, event: "Event", hint: "Hint") -> "Optional[Event]": + """ + Runs the error processors on the event and returns the modified event. + """ exc_info = hint.get("exc_info") if exc_info is not None: - for error_processor in self._error_processors: + error_processors = chain( + self.get_global_scope()._error_processors, + self.get_isolation_scope()._error_processors, + self.get_current_scope()._error_processors, + ) + + for error_processor in error_processors: new_event = error_processor(event, exc_info) if new_event is None: - return _drop(error_processor, "error processor") + return self._drop(error_processor, "error processor") + + event = new_event + + return event + + def run_event_processors(self, event: "Event", hint: "Hint") -> "Optional[Event]": + """ + Runs the event processors on the event and returns the modified event. + """ + ty = event.get("type") + is_check_in = ty == "check_in" + + if not is_check_in: + # Get scopes without creating them to prevent infinite recursion + isolation_scope = _isolation_scope.get() + current_scope = _current_scope.get() + + event_processors = chain( + global_event_processors, + _global_scope and _global_scope._event_processors or [], + isolation_scope and isolation_scope._event_processors or [], + current_scope and current_scope._event_processors or [], + ) + + for event_processor in event_processors: + new_event = event + with capture_internal_exceptions(): + new_event = event_processor(event, hint) + if new_event is None: + return self._drop(event_processor, "event processor") event = new_event - for event_processor in chain(global_event_processors, self._event_processors): - new_event = event - with capture_internal_exceptions(): - new_event = event_processor(event, hint) - if new_event is None: - return _drop(event_processor, "event processor") - event = new_event + return event + + @_disable_capture + def apply_to_event( + self, + event: "Event", + hint: "Hint", + options: "Optional[Dict[str, Any]]" = None, + ) -> "Optional[Event]": + """Applies the information contained on the scope to the given event.""" + ty = event.get("type") + is_transaction = ty == "transaction" + is_check_in = ty == "check_in" + + # put all attachments into the hint. This lets callbacks play around + # with attachments. We also later pull this out of the hint when we + # create the envelope. + attachments_to_send = hint.get("attachments") or [] + for attachment in self._attachments: + if not is_transaction or attachment.add_to_transactions: + attachments_to_send.append(attachment) + hint["attachments"] = attachments_to_send + + self._apply_contexts_to_event(event, hint, options) + + if is_check_in: + # Check-ins only support the trace context, strip all others + event["contexts"] = { + "trace": event.setdefault("contexts", {}).get("trace", {}) + } + + if not is_check_in: + self._apply_level_to_event(event, hint, options) + self._apply_fingerprint_to_event(event, hint, options) + self._apply_user_to_event(event, hint, options) + self._apply_transaction_name_to_event(event, hint, options) + self._apply_transaction_info_to_event(event, hint, options) + self._apply_tags_to_event(event, hint, options) + self._apply_extra_to_event(event, hint, options) + + if not is_transaction and not is_check_in: + self._apply_breadcrumbs_to_event(event, hint, options) + self._apply_flags_to_event(event, hint, options) + + event = self.run_error_processors(event, hint) + if event is None: + return None + + event = self.run_event_processors(event, hint) + if event is None: + return None return event - def update_from_scope(self, scope): - # type: (Scope) -> None + @_disable_capture + def apply_to_telemetry(self, telemetry: "Union[Log, Metric]") -> None: + # Attributes-based events and telemetry go through here (logs, metrics, + # spansV2) + trace_context = self.get_trace_context() + trace_id = trace_context.get("trace_id") + if telemetry.get("trace_id") is None: + telemetry["trace_id"] = trace_id or "00000000-0000-0000-0000-000000000000" + span_id = trace_context.get("span_id") + if telemetry.get("span_id") is None and span_id: + telemetry["span_id"] = span_id + + self._apply_scope_attributes_to_telemetry(telemetry) + self._apply_user_attributes_to_telemetry(telemetry) + self._apply_global_attributes_to_telemetry(telemetry) + + def update_from_scope(self, scope: "Scope") -> None: """Update the scope with another scope's data.""" if scope._level is not None: self._level = scope._level @@ -672,6 +1660,14 @@ def update_from_scope(self, scope): self._extras.update(scope._extras) if scope._breadcrumbs: self._breadcrumbs.extend(scope._breadcrumbs) + if scope._n_breadcrumbs_truncated: + self._n_breadcrumbs_truncated = ( + self._n_breadcrumbs_truncated + scope._n_breadcrumbs_truncated + ) + if scope._gen_ai_original_message_count: + self._gen_ai_original_message_count.update( + scope._gen_ai_original_message_count + ) if scope._span: self._span = scope._span if scope._attachments: @@ -680,17 +1676,27 @@ def update_from_scope(self, scope): self._profile = scope._profile if scope._propagation_context: self._propagation_context = scope._propagation_context + if scope._session: + self._session = scope._session + if scope._flags: + if not self._flags: + self._flags = deepcopy(scope._flags) + else: + for flag in scope._flags.get(): + self._flags.set(flag["flag"], flag["result"]) + if scope._attributes: + self._attributes.update(scope._attributes) def update_from_kwargs( self, - user=None, # type: Optional[Any] - level=None, # type: Optional[str] - extras=None, # type: Optional[Dict[str, Any]] - contexts=None, # type: Optional[Dict[str, Any]] - tags=None, # type: Optional[Dict[str, str]] - fingerprint=None, # type: Optional[List[str]] - ): - # type: (...) -> None + user: "Optional[Any]" = None, + level: "Optional[LogLevelStr]" = None, + extras: "Optional[Dict[str, Any]]" = None, + contexts: "Optional[Dict[str, Dict[str, Any]]]" = None, + tags: "Optional[Dict[str, str]]" = None, + fingerprint: "Optional[List[str]]" = None, + attributes: "Optional[Attributes]" = None, + ) -> None: """Update the scope's attributes.""" if level is not None: self._level = level @@ -704,41 +1710,219 @@ def update_from_kwargs( self._tags.update(tags) if fingerprint is not None: self._fingerprint = fingerprint + if attributes is not None: + self._attributes.update(attributes) - def __copy__(self): - # type: () -> Scope - rv = object.__new__(self.__class__) # type: Scope + def __repr__(self) -> str: + return "<%s id=%s name=%s type=%s>" % ( + self.__class__.__name__, + hex(id(self)), + self._name, + self._type, + ) - rv._level = self._level - rv._name = self._name - rv._fingerprint = self._fingerprint - rv._transaction = self._transaction - rv._transaction_info = dict(self._transaction_info) - rv._user = self._user + @property + def flags(self) -> "FlagBuffer": + if self._flags is None: + max_flags = ( + self.get_client().options["_experiments"].get("max_flags") + or DEFAULT_FLAG_CAPACITY + ) + self._flags = FlagBuffer(capacity=max_flags) + return self._flags - rv._tags = dict(self._tags) - rv._contexts = dict(self._contexts) - rv._extras = dict(self._extras) + def set_attribute(self, attribute: str, value: "AttributeValue") -> None: + """ + Set an attribute on the scope. - rv._breadcrumbs = copy(self._breadcrumbs) - rv._event_processors = list(self._event_processors) - rv._error_processors = list(self._error_processors) - rv._propagation_context = self._propagation_context + Any attributes-based telemetry (logs, metrics) captured while this scope + is active will inherit attributes set on the scope. + """ + self._attributes[attribute] = format_attribute(value) - rv._should_capture = self._should_capture - rv._span = self._span - rv._session = self._session - rv._force_auto_session_tracking = self._force_auto_session_tracking - rv._attachments = list(self._attachments) + def remove_attribute(self, attribute: str) -> None: + """Remove an attribute if set on the scope. No-op if there is no such attribute.""" + try: + del self._attributes[attribute] + except KeyError: + pass - rv._profile = self._profile - return rv +@contextmanager +def new_scope() -> "Generator[Scope, None, None]": + """ + .. versionadded:: 2.0.0 - def __repr__(self): - # type: () -> str - return "<%s id=%s name=%s>" % ( - self.__class__.__name__, - hex(id(self)), - self._name, - ) + Context manager that forks the current scope and runs the wrapped code in it. + After the wrapped code is executed, the original scope is restored. + + Example Usage: + + .. code-block:: python + + import sentry_sdk + + with sentry_sdk.new_scope() as scope: + scope.set_tag("color", "green") + sentry_sdk.capture_message("hello") # will include `color` tag. + + sentry_sdk.capture_message("hello, again") # will NOT include `color` tag. + + """ + # fork current scope + current_scope = Scope.get_current_scope() + new_scope = current_scope.fork() + token = _current_scope.set(new_scope) + + try: + yield new_scope + + finally: + try: + # restore original scope + _current_scope.reset(token) + except (LookupError, ValueError): + capture_internal_exception(sys.exc_info()) + + +@contextmanager +def use_scope(scope: "Scope") -> "Generator[Scope, None, None]": + """ + .. versionadded:: 2.0.0 + + Context manager that uses the given `scope` and runs the wrapped code in it. + After the wrapped code is executed, the original scope is restored. + + Example Usage: + Suppose the variable `scope` contains a `Scope` object, which is not currently + the active scope. + + .. code-block:: python + + import sentry_sdk + + with sentry_sdk.use_scope(scope): + scope.set_tag("color", "green") + sentry_sdk.capture_message("hello") # will include `color` tag. + + sentry_sdk.capture_message("hello, again") # will NOT include `color` tag. + + """ + # set given scope as current scope + token = _current_scope.set(scope) + + try: + yield scope + + finally: + try: + # restore original scope + _current_scope.reset(token) + except (LookupError, ValueError): + capture_internal_exception(sys.exc_info()) + + +@contextmanager +def isolation_scope() -> "Generator[Scope, None, None]": + """ + .. versionadded:: 2.0.0 + + Context manager that forks the current isolation scope and runs the wrapped code in it. + The current scope is also forked to not bleed data into the existing current scope. + After the wrapped code is executed, the original scopes are restored. + + Example Usage: + + .. code-block:: python + + import sentry_sdk + + with sentry_sdk.isolation_scope() as scope: + scope.set_tag("color", "green") + sentry_sdk.capture_message("hello") # will include `color` tag. + + sentry_sdk.capture_message("hello, again") # will NOT include `color` tag. + + """ + # fork current scope + current_scope = Scope.get_current_scope() + forked_current_scope = current_scope.fork() + current_token = _current_scope.set(forked_current_scope) + + # fork isolation scope + isolation_scope = Scope.get_isolation_scope() + new_isolation_scope = isolation_scope.fork() + isolation_token = _isolation_scope.set(new_isolation_scope) + + try: + yield new_isolation_scope + + finally: + # restore original scopes + try: + _current_scope.reset(current_token) + except (LookupError, ValueError): + capture_internal_exception(sys.exc_info()) + + try: + _isolation_scope.reset(isolation_token) + except (LookupError, ValueError): + capture_internal_exception(sys.exc_info()) + + +@contextmanager +def use_isolation_scope(isolation_scope: "Scope") -> "Generator[Scope, None, None]": + """ + .. versionadded:: 2.0.0 + + Context manager that uses the given `isolation_scope` and runs the wrapped code in it. + The current scope is also forked to not bleed data into the existing current scope. + After the wrapped code is executed, the original scopes are restored. + + Example Usage: + + .. code-block:: python + + import sentry_sdk + + with sentry_sdk.isolation_scope() as scope: + scope.set_tag("color", "green") + sentry_sdk.capture_message("hello") # will include `color` tag. + + sentry_sdk.capture_message("hello, again") # will NOT include `color` tag. + + """ + # fork current scope + current_scope = Scope.get_current_scope() + forked_current_scope = current_scope.fork() + current_token = _current_scope.set(forked_current_scope) + + # set given scope as isolation scope + isolation_token = _isolation_scope.set(isolation_scope) + + try: + yield isolation_scope + + finally: + # restore original scopes + try: + _current_scope.reset(current_token) + except (LookupError, ValueError): + capture_internal_exception(sys.exc_info()) + + try: + _isolation_scope.reset(isolation_token) + except (LookupError, ValueError): + capture_internal_exception(sys.exc_info()) + + +def should_send_default_pii() -> bool: + """Shortcut for `Scope.get_client().should_send_default_pii()`.""" + return Scope.get_client().should_send_default_pii() + + +# Circular imports +from sentry_sdk.client import NonRecordingClient + +if TYPE_CHECKING: + import sentry_sdk.client diff --git a/sentry_sdk/scrubber.py b/sentry_sdk/scrubber.py index 838ef08b4b..2857c4edaa 100644 --- a/sentry_sdk/scrubber.py +++ b/sentry_sdk/scrubber.py @@ -3,14 +3,11 @@ AnnotatedValue, iter_event_frames, ) -from sentry_sdk._compat import string_types -from sentry_sdk._types import TYPE_CHECKING + +from typing import TYPE_CHECKING, cast, List, Dict if TYPE_CHECKING: from sentry_sdk._types import Event - from typing import Any - from typing import Dict - from typing import List from typing import Optional @@ -27,21 +24,17 @@ "privatekey", "private_key", "token", - "ip_address", "session", # django "csrftoken", "sessionid", # wsgi - "remote_addr", "x_csrftoken", "x_forwarded_for", "set_cookie", "cookie", "authorization", "x_api_key", - "x_forwarded_for", - "x_real_ip", # other common names used in the wild "aiohttp_session", # aiohttp "connect.sid", # Express @@ -57,24 +50,75 @@ "XSRF-TOKEN", # Angular, Laravel ] +DEFAULT_PII_DENYLIST = [ + "x_forwarded_for", + "x_real_ip", + "ip_address", + "remote_addr", +] + + +class EventScrubber: + def __init__( + self, + denylist: "Optional[List[str]]" = None, + recursive: bool = False, + send_default_pii: bool = False, + pii_denylist: "Optional[List[str]]" = None, + ) -> None: + """ + A scrubber that goes through the event payload and removes sensitive data configured through denylists. + + :param denylist: A security denylist that is always scrubbed, defaults to DEFAULT_DENYLIST. + :param recursive: Whether to scrub the event payload recursively, default False. + :param send_default_pii: Whether pii is sending is on, pii fields are not scrubbed. + :param pii_denylist: The denylist to use for scrubbing when pii is not sent, defaults to DEFAULT_PII_DENYLIST. + """ + self.denylist = DEFAULT_DENYLIST.copy() if denylist is None else denylist + + if not send_default_pii: + pii_denylist = ( + DEFAULT_PII_DENYLIST.copy() if pii_denylist is None else pii_denylist + ) + self.denylist += pii_denylist -class EventScrubber(object): - def __init__(self, denylist=None): - # type: (Optional[List[str]]) -> None - self.denylist = DEFAULT_DENYLIST if denylist is None else denylist self.denylist = [x.lower() for x in self.denylist] + self.recursive = recursive + + def scrub_list(self, lst: object) -> None: + """ + If a list is passed to this method, the method recursively searches the list and any + nested lists for any dictionaries. The method calls scrub_dict on all dictionaries + it finds. + If the parameter passed to this method is not a list, the method does nothing. + """ + if not isinstance(lst, list): + return + + for v in lst: + self.scrub_dict(v) # no-op unless v is a dict + self.scrub_list(v) # no-op unless v is a list - def scrub_dict(self, d): - # type: (Dict[str, Any]) -> None + def scrub_dict(self, d: object) -> None: + """ + If a dictionary is passed to this method, the method scrubs the dictionary of any + sensitive data. The method calls itself recursively on any nested dictionaries ( + including dictionaries nested in lists) if self.recursive is True. + This method does nothing if the parameter passed to it is not a dictionary. + """ if not isinstance(d, dict): return - for k in d.keys(): - if isinstance(k, string_types) and k.lower() in self.denylist: + for k, v in d.items(): + # The cast is needed because mypy is not smart enough to figure out that k must be a + # string after the isinstance check. + if isinstance(k, str) and k.lower() in self.denylist: d[k] = AnnotatedValue.substituted_because_contains_sensitive_data() + elif self.recursive: + self.scrub_dict(v) # no-op unless v is a dict + self.scrub_list(v) # no-op unless v is a list - def scrub_request(self, event): - # type: (Event) -> None + def scrub_request(self, event: "Event") -> None: with capture_internal_exceptions(): if "request" in event: if "headers" in event["request"]: @@ -84,44 +128,41 @@ def scrub_request(self, event): if "data" in event["request"]: self.scrub_dict(event["request"]["data"]) - def scrub_extra(self, event): - # type: (Event) -> None + def scrub_extra(self, event: "Event") -> None: with capture_internal_exceptions(): if "extra" in event: self.scrub_dict(event["extra"]) - def scrub_user(self, event): - # type: (Event) -> None + def scrub_user(self, event: "Event") -> None: with capture_internal_exceptions(): if "user" in event: self.scrub_dict(event["user"]) - def scrub_breadcrumbs(self, event): - # type: (Event) -> None + def scrub_breadcrumbs(self, event: "Event") -> None: with capture_internal_exceptions(): if "breadcrumbs" in event: - if "values" in event["breadcrumbs"]: + if ( + not isinstance(event["breadcrumbs"], AnnotatedValue) + and "values" in event["breadcrumbs"] + ): for value in event["breadcrumbs"]["values"]: if "data" in value: self.scrub_dict(value["data"]) - def scrub_frames(self, event): - # type: (Event) -> None + def scrub_frames(self, event: "Event") -> None: with capture_internal_exceptions(): for frame in iter_event_frames(event): if "vars" in frame: self.scrub_dict(frame["vars"]) - def scrub_spans(self, event): - # type: (Event) -> None + def scrub_spans(self, event: "Event") -> None: with capture_internal_exceptions(): if "spans" in event: - for span in event["spans"]: + for span in cast(List[Dict[str, object]], event["spans"]): if "data" in span: self.scrub_dict(span["data"]) - def scrub_event(self, event): - # type: (Event) -> None + def scrub_event(self, event: "Event") -> None: self.scrub_request(event) self.scrub_extra(event) self.scrub_user(event) diff --git a/sentry_sdk/serializer.py b/sentry_sdk/serializer.py index 7925cf5ec8..9725d3ab53 100644 --- a/sentry_sdk/serializer.py +++ b/sentry_sdk/serializer.py @@ -1,6 +1,6 @@ import sys import math - +from collections.abc import Mapping, Sequence, Set from datetime import datetime from sentry_sdk.utils import ( @@ -11,15 +11,8 @@ safe_repr, strip_string, ) -from sentry_sdk._compat import ( - text_type, - PY2, - string_types, - number_types, - iteritems, - binary_sequence_types, -) -from sentry_sdk._types import TYPE_CHECKING + +from typing import TYPE_CHECKING if TYPE_CHECKING: from types import TracebackType @@ -33,7 +26,7 @@ from typing import Type from typing import Union - from sentry_sdk._types import NotImplementedType, Event + from sentry_sdk._types import NotImplementedType Span = Dict[str, Any] @@ -41,20 +34,8 @@ Segment = Union[str, int] -if PY2: - # Importing ABCs from collections is deprecated, and will stop working in 3.8 - # https://github.com/python/cpython/blob/master/Lib/collections/__init__.py#L49 - from collections import Mapping, Sequence, Set - - serializable_str_types = string_types + binary_sequence_types - -else: - # New in 3.3 - # https://docs.python.org/3/library/collections.abc.html - from collections.abc import Mapping, Sequence, Set - - # Bytes are technically not strings in Python 3, but we can serialize them - serializable_str_types = string_types + binary_sequence_types +# Bytes are technically not strings in Python 3, but we can serialize them +serializable_str_types = (str, bytes, bytearray, memoryview) # Maximum length of JSON-serialized event payloads that can be safely sent @@ -74,29 +55,32 @@ CYCLE_MARKER = "" -global_repr_processors = [] # type: List[ReprProcessor] +global_repr_processors: "List[ReprProcessor]" = [] -def add_global_repr_processor(processor): - # type: (ReprProcessor) -> None +def add_global_repr_processor(processor: "ReprProcessor") -> None: global_repr_processors.append(processor) -class Memo(object): +sequence_types: "List[type]" = [Sequence, Set] + + +def add_repr_sequence_type(ty: type) -> None: + sequence_types.append(ty) + + +class Memo: __slots__ = ("_ids", "_objs") - def __init__(self): - # type: () -> None - self._ids = {} # type: Dict[int, Any] - self._objs = [] # type: List[Any] + def __init__(self) -> None: + self._ids: "Dict[int, Any]" = {} + self._objs: "List[Any]" = [] - def memoize(self, obj): - # type: (Any) -> ContextManager[bool] + def memoize(self, obj: "Any") -> "ContextManager[bool]": self._objs.append(obj) return self - def __enter__(self): - # type: () -> bool + def __enter__(self) -> bool: obj = self._objs[-1] if id(obj) in self._ids: return True @@ -106,31 +90,56 @@ def __enter__(self): def __exit__( self, - ty, # type: Optional[Type[BaseException]] - value, # type: Optional[BaseException] - tb, # type: Optional[TracebackType] - ): - # type: (...) -> None + ty: "Optional[Type[BaseException]]", + value: "Optional[BaseException]", + tb: "Optional[TracebackType]", + ) -> None: self._ids.pop(id(self._objs.pop()), None) -def serialize(event, **kwargs): - # type: (Event, **Any) -> Event +def serialize(event: "Dict[str, Any]", **kwargs: "Any") -> "Dict[str, Any]": + """ + A very smart serializer that takes a dict and emits a json-friendly dict. + Currently used for serializing the final Event and also prematurely while fetching the stack + local variables for each frame in a stacktrace. + + It works internally with 'databags' which are arbitrary data structures like Mapping, Sequence and Set. + The algorithm itself is a recursive graph walk down the data structures it encounters. + + It has the following responsibilities: + * Trimming databags and keeping them within MAX_DATABAG_BREADTH and MAX_DATABAG_DEPTH. + * Calling safe_repr() on objects appropriately to keep them informative and readable in the final payload. + * Annotating the payload with the _meta field whenever trimming happens. + + :param max_request_body_size: If set to "always", will never trim request bodies. + :param max_value_length: The max length to strip strings to, defaults to sentry_sdk.consts.DEFAULT_MAX_VALUE_LENGTH + :param is_vars: If we're serializing vars early, we want to repr() things that are JSON-serializable to make their type more apparent. For example, it's useful to see the difference between a unicode-string and a bytestring when viewing a stacktrace. + :param custom_repr: A custom repr function that runs before safe_repr on the object to be serialized. If it returns None or throws internally, we will fallback to safe_repr. + + """ memo = Memo() - path = [] # type: List[Segment] - meta_stack = [] # type: List[Dict[str, Any]] + path: "List[Segment]" = [] + meta_stack: "List[Dict[str, Any]]" = [] - keep_request_bodies = ( - kwargs.pop("max_request_body_size", None) == "always" - ) # type: bool - max_value_length = kwargs.pop("max_value_length", None) # type: Optional[int] + keep_request_bodies: bool = kwargs.pop("max_request_body_size", None) == "always" + max_value_length: "Optional[int]" = kwargs.pop("max_value_length", None) + is_vars = kwargs.pop("is_vars", False) + custom_repr: "Callable[..., Optional[str]]" = kwargs.pop("custom_repr", None) - def _annotate(**meta): - # type: (**Any) -> None + def _safe_repr_wrapper(value: "Any") -> str: + try: + repr_value = None + if custom_repr is not None: + repr_value = custom_repr(value) + return repr_value or safe_repr(value) + except Exception: + return safe_repr(value) + + def _annotate(**meta: "Any") -> None: while len(meta_stack) <= len(path): try: segment = path[len(meta_stack) - 1] - node = meta_stack[-1].setdefault(text_type(segment), {}) + node = meta_stack[-1].setdefault(str(segment), {}) except IndexError: node = {} @@ -138,56 +147,16 @@ def _annotate(**meta): meta_stack[-1].setdefault("", {}).update(meta) - def _should_repr_strings(): - # type: () -> Optional[bool] - """ - By default non-serializable objects are going through - safe_repr(). For certain places in the event (local vars) we - want to repr() even things that are JSON-serializable to - make their type more apparent. For example, it's useful to - see the difference between a unicode-string and a bytestring - when viewing a stacktrace. - - For container-types we still don't do anything different. - Generally we just try to make the Sentry UI present exactly - what a pretty-printed repr would look like. - - :returns: `True` if we are somewhere in frame variables, and `False` if - we are in a position where we will never encounter frame variables - when recursing (for example, we're in `event.extra`). `None` if we - are not (yet) in frame variables, but might encounter them when - recursing (e.g. we're in `event.exception`) - """ - try: - p0 = path[0] - if p0 == "stacktrace" and path[1] == "frames" and path[3] == "vars": - return True - - if ( - p0 in ("threads", "exception") - and path[1] == "values" - and path[3] == "stacktrace" - and path[4] == "frames" - and path[6] == "vars" - ): - return True - except IndexError: - return None - - return False - - def _is_databag(): - # type: () -> Optional[bool] + def _is_databag() -> "Optional[bool]": """ A databag is any value that we need to trim. + True for stuff like vars, request bodies, breadcrumbs and extra. - :returns: Works like `_should_repr_strings()`. `True` for "yes", - `False` for :"no", `None` for "maybe soon". + :returns: `True` for "yes", `False` for :"no", `None` for "maybe soon". """ try: - rv = _should_repr_strings() - if rv in (True, None): - return rv + if is_vars: + return True is_request_body = _is_request_body() if is_request_body in (True, None): @@ -206,8 +175,16 @@ def _is_databag(): return False - def _is_request_body(): - # type: () -> Optional[bool] + def _is_span_attribute() -> "Optional[bool]": + try: + if path[0] == "spans" and path[2] == "data": + return True + except IndexError: + return None + + return False + + def _is_request_body() -> "Optional[bool]": try: if path[0] == "request" and path[1] == "data": return True @@ -217,15 +194,14 @@ def _is_request_body(): return False def _serialize_node( - obj, # type: Any - is_databag=None, # type: Optional[bool] - is_request_body=None, # type: Optional[bool] - should_repr_strings=None, # type: Optional[bool] - segment=None, # type: Optional[Segment] - remaining_breadth=None, # type: Optional[Union[int, float]] - remaining_depth=None, # type: Optional[Union[int, float]] - ): - # type: (...) -> Any + obj: "Any", + is_databag: "Optional[bool]" = None, + is_request_body: "Optional[bool]" = None, + should_repr_strings: "Optional[bool]" = None, + segment: "Optional[Segment]" = None, + remaining_breadth: "Optional[Union[int, float]]" = None, + remaining_depth: "Optional[Union[int, float]]" = None, + ) -> "Any": if segment is not None: path.append(segment) @@ -254,26 +230,24 @@ def _serialize_node( path.pop() del meta_stack[len(path) + 1 :] - def _flatten_annotated(obj): - # type: (Any) -> Any + def _flatten_annotated(obj: "Any") -> "Any": if isinstance(obj, AnnotatedValue): _annotate(**obj.metadata) obj = obj.value return obj def _serialize_node_impl( - obj, - is_databag, - is_request_body, - should_repr_strings, - remaining_depth, - remaining_breadth, - ): - # type: (Any, Optional[bool], Optional[bool], Optional[bool], Optional[Union[float, int]], Optional[Union[float, int]]) -> Any + obj: "Any", + is_databag: "Optional[bool]", + is_request_body: "Optional[bool]", + should_repr_strings: "Optional[bool]", + remaining_depth: "Optional[Union[float, int]]", + remaining_breadth: "Optional[Union[float, int]]", + ) -> "Any": if isinstance(obj, AnnotatedValue): should_repr_strings = False if should_repr_strings is None: - should_repr_strings = _should_repr_strings() + should_repr_strings = is_vars if is_databag is None: is_databag = _is_databag() @@ -297,11 +271,12 @@ def _serialize_node_impl( _annotate(rem=[["!limit", "x"]]) if is_databag: return _flatten_annotated( - strip_string(safe_repr(obj), max_length=max_value_length) + strip_string(_safe_repr_wrapper(obj), max_length=max_value_length) ) return None - if is_databag and global_repr_processors: + is_span_attribute = _is_span_attribute() + if (is_databag or is_span_attribute) and global_repr_processors: hints = {"memo": memo, "remaining_depth": remaining_depth} for processor in global_repr_processors: result = processor(obj, hints) @@ -310,11 +285,11 @@ def _serialize_node_impl( sentry_repr = getattr(type(obj), "__sentry_repr__", None) - if obj is None or isinstance(obj, (bool, number_types)): + if obj is None or isinstance(obj, (bool, int, float)): if should_repr_strings or ( isinstance(obj, float) and (math.isinf(obj) or math.isnan(obj)) ): - return safe_repr(obj) + return _safe_repr_wrapper(obj) else: return obj @@ -323,34 +298,34 @@ def _serialize_node_impl( elif isinstance(obj, datetime): return ( - text_type(format_timestamp(obj)) + str(format_timestamp(obj)) if not should_repr_strings - else safe_repr(obj) + else _safe_repr_wrapper(obj) ) elif isinstance(obj, Mapping): # Create temporary copy here to avoid calling too much code that # might mutate our dictionary while we're still iterating over it. - obj = dict(iteritems(obj)) + obj = dict(obj.items()) - rv_dict = {} # type: Dict[str, Any] + rv_dict: "Dict[str, Any]" = {} i = 0 - for k, v in iteritems(obj): + for k, v in obj.items(): if remaining_breadth is not None and i >= remaining_breadth: _annotate(len=len(obj)) break - str_k = text_type(k) + str_k = str(k) v = _serialize_node( v, segment=str_k, should_repr_strings=should_repr_strings, is_databag=is_databag, is_request_body=is_request_body, - remaining_depth=remaining_depth - 1 - if remaining_depth is not None - else None, + remaining_depth=( + remaining_depth - 1 if remaining_depth is not None else None + ), remaining_breadth=remaining_breadth, ) rv_dict[str_k] = v @@ -359,7 +334,7 @@ def _serialize_node_impl( return rv_dict elif not isinstance(obj, serializable_str_types) and isinstance( - obj, (Set, Sequence) + obj, tuple(sequence_types) ): rv_list = [] @@ -375,9 +350,9 @@ def _serialize_node_impl( should_repr_strings=should_repr_strings, is_databag=is_databag, is_request_body=is_request_body, - remaining_depth=remaining_depth - 1 - if remaining_depth is not None - else None, + remaining_depth=( + remaining_depth - 1 if remaining_depth is not None else None + ), remaining_breadth=remaining_breadth, ) ) @@ -385,13 +360,13 @@ def _serialize_node_impl( return rv_list if should_repr_strings: - obj = safe_repr(obj) + obj = _safe_repr_wrapper(obj) else: if isinstance(obj, bytes) or isinstance(obj, bytearray): obj = obj.decode("utf-8", "replace") - if not isinstance(obj, string_types): - obj = safe_repr(obj) + if not isinstance(obj, str): + obj = _safe_repr_wrapper(obj) is_span_description = ( len(path) == 3 and path[0] == "spans" and path[-1] == "description" @@ -407,7 +382,7 @@ def _serialize_node_impl( disable_capture_event.set(True) try: serialized_event = _serialize_node(event, **kwargs) - if meta_stack and isinstance(serialized_event, dict): + if not is_vars and meta_stack and isinstance(serialized_event, dict): serialized_event["_meta"] = meta_stack[0] return serialized_event diff --git a/sentry_sdk/session.py b/sentry_sdk/session.py index 45e2236ec9..315ba1bf9b 100644 --- a/sentry_sdk/session.py +++ b/sentry_sdk/session.py @@ -1,11 +1,11 @@ import uuid +from datetime import datetime, timezone -from sentry_sdk._compat import datetime_utcnow -from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.utils import format_timestamp +from typing import TYPE_CHECKING + if TYPE_CHECKING: - from datetime import datetime from typing import Optional from typing import Union from typing import Any @@ -14,53 +14,50 @@ from sentry_sdk._types import SessionStatus -def _minute_trunc(ts): - # type: (datetime) -> datetime +def _minute_trunc(ts: "datetime") -> "datetime": return ts.replace(second=0, microsecond=0) def _make_uuid( - val, # type: Union[str, uuid.UUID] -): - # type: (...) -> uuid.UUID + val: "Union[str, uuid.UUID]", +) -> "uuid.UUID": if isinstance(val, uuid.UUID): return val return uuid.UUID(val) -class Session(object): +class Session: def __init__( self, - sid=None, # type: Optional[Union[str, uuid.UUID]] - did=None, # type: Optional[str] - timestamp=None, # type: Optional[datetime] - started=None, # type: Optional[datetime] - duration=None, # type: Optional[float] - status=None, # type: Optional[SessionStatus] - release=None, # type: Optional[str] - environment=None, # type: Optional[str] - user_agent=None, # type: Optional[str] - ip_address=None, # type: Optional[str] - errors=None, # type: Optional[int] - user=None, # type: Optional[Any] - session_mode="application", # type: str - ): - # type: (...) -> None + sid: "Optional[Union[str, uuid.UUID]]" = None, + did: "Optional[str]" = None, + timestamp: "Optional[datetime]" = None, + started: "Optional[datetime]" = None, + duration: "Optional[float]" = None, + status: "Optional[SessionStatus]" = None, + release: "Optional[str]" = None, + environment: "Optional[str]" = None, + user_agent: "Optional[str]" = None, + ip_address: "Optional[str]" = None, + errors: "Optional[int]" = None, + user: "Optional[Any]" = None, + session_mode: str = "application", + ) -> None: if sid is None: sid = uuid.uuid4() if started is None: - started = datetime_utcnow() + started = datetime.now(timezone.utc) if status is None: status = "ok" self.status = status - self.did = None # type: Optional[str] + self.did: "Optional[str]" = None self.started = started - self.release = None # type: Optional[str] - self.environment = None # type: Optional[str] - self.duration = None # type: Optional[float] - self.user_agent = None # type: Optional[str] - self.ip_address = None # type: Optional[str] - self.session_mode = session_mode # type: str + self.release: "Optional[str]" = None + self.environment: "Optional[str]" = None + self.duration: "Optional[float]" = None + self.user_agent: "Optional[str]" = None + self.ip_address: "Optional[str]" = None + self.session_mode: str = session_mode self.errors = 0 self.update( @@ -77,26 +74,24 @@ def __init__( ) @property - def truncated_started(self): - # type: (...) -> datetime + def truncated_started(self) -> "datetime": return _minute_trunc(self.started) def update( self, - sid=None, # type: Optional[Union[str, uuid.UUID]] - did=None, # type: Optional[str] - timestamp=None, # type: Optional[datetime] - started=None, # type: Optional[datetime] - duration=None, # type: Optional[float] - status=None, # type: Optional[SessionStatus] - release=None, # type: Optional[str] - environment=None, # type: Optional[str] - user_agent=None, # type: Optional[str] - ip_address=None, # type: Optional[str] - errors=None, # type: Optional[int] - user=None, # type: Optional[Any] - ): - # type: (...) -> None + sid: "Optional[Union[str, uuid.UUID]]" = None, + did: "Optional[str]" = None, + timestamp: "Optional[datetime]" = None, + started: "Optional[datetime]" = None, + duration: "Optional[float]" = None, + status: "Optional[SessionStatus]" = None, + release: "Optional[str]" = None, + environment: "Optional[str]" = None, + user_agent: "Optional[str]" = None, + ip_address: "Optional[str]" = None, + errors: "Optional[int]" = None, + user: "Optional[Any]" = None, + ) -> None: # If a user is supplied we pull some data form it if user: if ip_address is None: @@ -109,7 +104,7 @@ def update( if did is not None: self.did = str(did) if timestamp is None: - timestamp = datetime_utcnow() + timestamp = datetime.now(timezone.utc) self.timestamp = timestamp if started is not None: self.started = started @@ -130,18 +125,18 @@ def update( self.status = status def close( - self, status=None # type: Optional[SessionStatus] - ): - # type: (...) -> Any + self, + status: "Optional[SessionStatus]" = None, + ) -> "Any": if status is None and self.status == "ok": status = "exited" if status is not None: self.update(status=status) def get_json_attrs( - self, with_user_info=True # type: Optional[bool] - ): - # type: (...) -> Any + self, + with_user_info: "Optional[bool]" = True, + ) -> "Any": attrs = {} if self.release is not None: attrs["release"] = self.release @@ -154,15 +149,14 @@ def get_json_attrs( attrs["user_agent"] = self.user_agent return attrs - def to_json(self): - # type: (...) -> Any - rv = { + def to_json(self) -> "Any": + rv: "Dict[str, Any]" = { "sid": str(self.sid), "init": True, "started": format_timestamp(self.started), "timestamp": format_timestamp(self.timestamp), "status": self.status, - } # type: Dict[str, Any] + } if self.errors: rv["errors"] = self.errors if self.did is not None: diff --git a/sentry_sdk/sessions.py b/sentry_sdk/sessions.py index 520fbbc059..2b7ed8487d 100644 --- a/sentry_sdk/sessions.py +++ b/sentry_sdk/sessions.py @@ -1,14 +1,15 @@ import os -import time -from threading import Thread, Lock +import warnings +from threading import Thread, Lock, Event from contextlib import contextmanager import sentry_sdk from sentry_sdk.envelope import Envelope from sentry_sdk.session import Session -from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.utils import format_timestamp +from typing import TYPE_CHECKING + if TYPE_CHECKING: from typing import Any from typing import Callable @@ -19,9 +20,19 @@ from typing import Union -def is_auto_session_tracking_enabled(hub=None): - # type: (Optional[sentry_sdk.Hub]) -> Union[Any, bool, None] - """Utility function to find out if session tracking is enabled.""" +def is_auto_session_tracking_enabled( + hub: "Optional[sentry_sdk.Hub]" = None, +) -> "Union[Any, bool, None]": + """DEPRECATED: Utility function to find out if session tracking is enabled.""" + + # Internal callers should use private _is_auto_session_tracking_enabled, instead. + warnings.warn( + "This function is deprecated and will be removed in the next major release. " + "There is no public API replacement.", + DeprecationWarning, + stacklevel=2, + ) + if hub is None: hub = sentry_sdk.Hub.current @@ -35,12 +46,24 @@ def is_auto_session_tracking_enabled(hub=None): @contextmanager -def auto_session_tracking(hub=None, session_mode="application"): - # type: (Optional[sentry_sdk.Hub], str) -> Generator[None, None, None] - """Starts and stops a session automatically around a block.""" +def auto_session_tracking( + hub: "Optional[sentry_sdk.Hub]" = None, session_mode: str = "application" +) -> "Generator[None, None, None]": + """DEPRECATED: Use track_session instead + Starts and stops a session automatically around a block. + """ + warnings.warn( + "This function is deprecated and will be removed in the next major release. " + "Use track_session instead.", + DeprecationWarning, + stacklevel=2, + ) + if hub is None: hub = sentry_sdk.Hub.current - should_track = is_auto_session_tracking_enabled(hub) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + should_track = is_auto_session_tracking_enabled(hub) if should_track: hub.start_session(session_mode=session_mode) try: @@ -50,34 +73,97 @@ def auto_session_tracking(hub=None, session_mode="application"): hub.end_session() +def is_auto_session_tracking_enabled_scope(scope: "sentry_sdk.Scope") -> bool: + """ + DEPRECATED: Utility function to find out if session tracking is enabled. + """ + + warnings.warn( + "This function is deprecated and will be removed in the next major release. " + "There is no public API replacement.", + DeprecationWarning, + stacklevel=2, + ) + + # Internal callers should use private _is_auto_session_tracking_enabled, instead. + return _is_auto_session_tracking_enabled(scope) + + +def _is_auto_session_tracking_enabled(scope: "sentry_sdk.Scope") -> bool: + """ + Utility function to find out if session tracking is enabled. + """ + + should_track = scope._force_auto_session_tracking + if should_track is None: + client_options = sentry_sdk.get_client().options + should_track = client_options.get("auto_session_tracking", False) + + return should_track + + +@contextmanager +def auto_session_tracking_scope( + scope: "sentry_sdk.Scope", session_mode: str = "application" +) -> "Generator[None, None, None]": + """DEPRECATED: This function is a deprecated alias for track_session. + Starts and stops a session automatically around a block. + """ + + warnings.warn( + "This function is a deprecated alias for track_session and will be removed in the next major release.", + DeprecationWarning, + stacklevel=2, + ) + + with track_session(scope, session_mode=session_mode): + yield + + +@contextmanager +def track_session( + scope: "sentry_sdk.Scope", session_mode: str = "application" +) -> "Generator[None, None, None]": + """ + Start a new session in the provided scope, assuming session tracking is enabled. + This is a no-op context manager if session tracking is not enabled. + """ + + should_track = _is_auto_session_tracking_enabled(scope) + if should_track: + scope.start_session(session_mode=session_mode) + try: + yield + finally: + if should_track: + scope.end_session() + + TERMINAL_SESSION_STATES = ("exited", "abnormal", "crashed") MAX_ENVELOPE_ITEMS = 100 -def make_aggregate_envelope(aggregate_states, attrs): - # type: (Any, Any) -> Any +def make_aggregate_envelope(aggregate_states: "Any", attrs: "Any") -> "Any": return {"attrs": dict(attrs), "aggregates": list(aggregate_states.values())} -class SessionFlusher(object): +class SessionFlusher: def __init__( self, - capture_func, # type: Callable[[Envelope], None] - flush_interval=60, # type: int - ): - # type: (...) -> None + capture_func: "Callable[[Envelope], None]", + flush_interval: int = 60, + ) -> None: self.capture_func = capture_func self.flush_interval = flush_interval - self.pending_sessions = [] # type: List[Any] - self.pending_aggregates = {} # type: Dict[Any, Any] - self._thread = None # type: Optional[Thread] + self.pending_sessions: "List[Any]" = [] + self.pending_aggregates: "Dict[Any, Any]" = {} + self._thread: "Optional[Thread]" = None self._thread_lock = Lock() self._aggregate_lock = Lock() - self._thread_for_pid = None # type: Optional[int] - self._running = True + self._thread_for_pid: "Optional[int]" = None + self.__shutdown_requested = Event() - def flush(self): - # type: (...) -> None + def flush(self) -> None: pending_sessions = self.pending_sessions self.pending_sessions = [] @@ -103,32 +189,45 @@ def flush(self): if len(envelope.items) > 0: self.capture_func(envelope) - def _ensure_running(self): - # type: (...) -> None + def _ensure_running(self) -> None: + """ + Check that we have an active thread to run in, or create one if not. + + Note that this might fail (e.g. in Python 3.12 it's not possible to + spawn new threads at interpreter shutdown). In that case self._running + will be False after running this function. + """ if self._thread_for_pid == os.getpid() and self._thread is not None: return None with self._thread_lock: if self._thread_for_pid == os.getpid() and self._thread is not None: return None - def _thread(): - # type: (...) -> None - while self._running: - time.sleep(self.flush_interval) - if self._running: - self.flush() + def _thread() -> None: + running = True + while running: + running = not self.__shutdown_requested.wait(self.flush_interval) + self.flush() thread = Thread(target=_thread) thread.daemon = True - thread.start() + try: + thread.start() + except RuntimeError: + # Unfortunately at this point the interpreter is in a state that no + # longer allows us to spawn a thread and we have to bail. + self.__shutdown_requested.set() + return None + self._thread = thread self._thread_for_pid = os.getpid() + return None def add_aggregate_session( - self, session # type: Session - ): - # type: (...) -> None + self, + session: "Session", + ) -> None: # NOTE on `session.did`: # the protocol can deal with buckets that have a distinct-id, however # in practice we expect the python SDK to have an extremely high cardinality @@ -157,19 +256,14 @@ def add_aggregate_session( state["exited"] = state.get("exited", 0) + 1 def add_session( - self, session # type: Session - ): - # type: (...) -> None + self, + session: "Session", + ) -> None: if session.session_mode == "request": self.add_aggregate_session(session) else: self.pending_sessions.append(session.to_json()) self._ensure_running() - def kill(self): - # type: (...) -> None - self._running = False - - def __del__(self): - # type: (...) -> None - self.kill() + def kill(self) -> None: + self.__shutdown_requested.set() diff --git a/sentry_sdk/spotlight.py b/sentry_sdk/spotlight.py new file mode 100644 index 0000000000..f70ea9d341 --- /dev/null +++ b/sentry_sdk/spotlight.py @@ -0,0 +1,331 @@ +import io +import logging +import os +import time +import urllib.parse +import urllib.request +import urllib.error +import urllib3 +import sys + +from itertools import chain, product + +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 typing import Self + +from sentry_sdk.utils import ( + logger as sentry_logger, + env_to_bool, + capture_internal_exceptions, +) +from sentry_sdk.envelope import Envelope + + +logger = logging.getLogger("spotlight") + + +DEFAULT_SPOTLIGHT_URL = "http://localhost:8969/stream" +DJANGO_SPOTLIGHT_MIDDLEWARE_PATH = "sentry_sdk.spotlight.SpotlightMiddleware" + + +class SpotlightClient: + """ + A client for sending envelopes to Sentry Spotlight. + + Implements exponential backoff retry logic per the SDK spec: + - Logs error at least once when server is unreachable + - Does not log for every failed envelope + - Uses exponential backoff to avoid hammering an unavailable server + - Never blocks normal Sentry operation + """ + + # Exponential backoff settings + INITIAL_RETRY_DELAY = 1.0 # Start with 1 second + MAX_RETRY_DELAY = 60.0 # Max 60 seconds + + def __init__(self, url: str) -> None: + self.url = url + self.http = urllib3.PoolManager() + self._retry_delay = self.INITIAL_RETRY_DELAY + self._last_error_time: float = 0.0 + + def capture_envelope(self, envelope: "Envelope") -> None: + # Check if we're in backoff period - skip sending to avoid blocking + if self._last_error_time > 0: + time_since_error = time.time() - self._last_error_time + if time_since_error < self._retry_delay: + # Still in backoff period, skip this envelope + return + + body = io.BytesIO() + envelope.serialize_into(body) + try: + req = self.http.request( + url=self.url, + body=body.getvalue(), + method="POST", + headers={ + "Content-Type": "application/x-sentry-envelope", + }, + ) + req.close() + # Success - reset backoff state + self._retry_delay = self.INITIAL_RETRY_DELAY + self._last_error_time = 0.0 + except Exception as e: + self._last_error_time = time.time() + + # Increase backoff delay exponentially first, so logged value matches actual wait + self._retry_delay = min(self._retry_delay * 2, self.MAX_RETRY_DELAY) + + # Log error once per backoff cycle (we skip sends during backoff, so only one failure per cycle) + sentry_logger.warning( + "Failed to send envelope to Spotlight at %s: %s. " + "Will retry after %.1f seconds.", + self.url, + e, + self._retry_delay, + ) + + +try: + from django.utils.deprecation import MiddlewareMixin + from django.http import HttpResponseServerError, HttpResponse, HttpRequest + from django.conf import settings + + SPOTLIGHT_JS_ENTRY_PATH = "/assets/main.js" + SPOTLIGHT_JS_SNIPPET_PATTERN = ( + "\n" + '\n' + ) + SPOTLIGHT_ERROR_PAGE_SNIPPET = ( + '\n' + '\n' + ) + CHARSET_PREFIX = "charset=" + BODY_TAG_NAME = "body" + BODY_CLOSE_TAG_POSSIBILITIES = tuple( + "".format("".join(chars)) + for chars in product(*zip(BODY_TAG_NAME.upper(), BODY_TAG_NAME.lower())) + ) + + class SpotlightMiddleware(MiddlewareMixin): # type: ignore[misc] + _spotlight_script: "Optional[str]" = None + _spotlight_url: "Optional[str]" = None + + def __init__(self: "Self", get_response: "Callable[..., HttpResponse]") -> None: + super().__init__(get_response) + + import sentry_sdk.api + + self.sentry_sdk = sentry_sdk.api + + spotlight_client = self.sentry_sdk.get_client().spotlight + if spotlight_client is None: + sentry_logger.warning( + "Cannot find Spotlight client from SpotlightMiddleware, disabling the middleware." + ) + return None + # Spotlight URL has a trailing `/stream` part at the end so split it off + self._spotlight_url = urllib.parse.urljoin(spotlight_client.url, "../") + + @property + def spotlight_script(self: "Self") -> "Optional[str]": + if self._spotlight_url is not None and self._spotlight_script is None: + try: + spotlight_js_url = urllib.parse.urljoin( + self._spotlight_url, SPOTLIGHT_JS_ENTRY_PATH + ) + req = urllib.request.Request( + spotlight_js_url, + method="HEAD", + ) + urllib.request.urlopen(req) + self._spotlight_script = SPOTLIGHT_JS_SNIPPET_PATTERN.format( + spotlight_url=self._spotlight_url, + spotlight_js_url=spotlight_js_url, + ) + except urllib.error.URLError as err: + sentry_logger.debug( + "Cannot get Spotlight JS to inject at %s. SpotlightMiddleware will not be very useful.", + spotlight_js_url, + exc_info=err, + ) + + return self._spotlight_script + + def process_response( + self: "Self", _request: "HttpRequest", response: "HttpResponse" + ) -> "Optional[HttpResponse]": + content_type_header = tuple( + p.strip() + for p in response.headers.get("Content-Type", "").lower().split(";") + ) + content_type = content_type_header[0] + if len(content_type_header) > 1 and content_type_header[1].startswith( + CHARSET_PREFIX + ): + encoding = content_type_header[1][len(CHARSET_PREFIX) :] + else: + encoding = "utf-8" + + if ( + self.spotlight_script is not None + and not response.streaming + and content_type == "text/html" + ): + content_length = len(response.content) + injection = self.spotlight_script.encode(encoding) + injection_site = next( + ( + idx + for idx in ( + response.content.rfind(body_variant.encode(encoding)) + for body_variant in BODY_CLOSE_TAG_POSSIBILITIES + ) + if idx > -1 + ), + content_length, + ) + + # This approach works even when we don't have a `` tag + response.content = ( + response.content[:injection_site] + + injection + + response.content[injection_site:] + ) + + if response.has_header("Content-Length"): + response.headers["Content-Length"] = content_length + len(injection) + + return response + + def process_exception( + self: "Self", _request: "HttpRequest", exception: Exception + ) -> "Optional[HttpResponseServerError]": + if not settings.DEBUG or not self._spotlight_url: + return None + + try: + spotlight = ( + urllib.request.urlopen(self._spotlight_url).read().decode("utf-8") + ) + except urllib.error.URLError: + return None + else: + event_id = self.sentry_sdk.capture_exception(exception) + return HttpResponseServerError( + spotlight.replace( + "", + SPOTLIGHT_ERROR_PAGE_SNIPPET.format( + spotlight_url=self._spotlight_url, event_id=event_id + ), + ) + ) + +except ImportError: + settings = None + + +def _resolve_spotlight_url( + spotlight_config: "Any", sentry_logger: "Any" +) -> "Optional[str]": + """ + Resolve the Spotlight URL based on config and environment variable. + + Implements precedence rules per the SDK spec: + https://develop.sentry.dev/sdk/expected-features/spotlight/ + + Returns the resolved URL string, or None if Spotlight should be disabled. + """ + spotlight_env_value = os.environ.get("SENTRY_SPOTLIGHT") + + # Parse env var to determine if it's a boolean or URL + spotlight_from_env: "Optional[bool]" = None + spotlight_env_url: "Optional[str]" = None + if spotlight_env_value: + parsed = env_to_bool(spotlight_env_value, strict=True) + if parsed is None: + # It's a URL string + spotlight_from_env = True + spotlight_env_url = spotlight_env_value + else: + spotlight_from_env = parsed + + # Apply precedence rules per spec: + # https://develop.sentry.dev/sdk/expected-features/spotlight/#precedence-rules + if spotlight_config is False: + # Config explicitly disables spotlight - warn if env var was set + if spotlight_from_env: + sentry_logger.warning( + "Spotlight is disabled via spotlight=False config option, " + "ignoring SENTRY_SPOTLIGHT environment variable." + ) + return None + elif spotlight_config is True: + # Config enables spotlight with boolean true + # If env var has URL, use env var URL per spec + if spotlight_env_url: + return spotlight_env_url + else: + return DEFAULT_SPOTLIGHT_URL + elif isinstance(spotlight_config, str): + # Config has URL string - use config URL, warn if env var differs + if spotlight_env_value and spotlight_env_value != spotlight_config: + sentry_logger.warning( + "Spotlight URL from config (%s) takes precedence over " + "SENTRY_SPOTLIGHT environment variable (%s).", + spotlight_config, + spotlight_env_value, + ) + return spotlight_config + elif spotlight_config is None: + # No config - use env var + if spotlight_env_url: + return spotlight_env_url + elif spotlight_from_env: + return DEFAULT_SPOTLIGHT_URL + # else: stays None (disabled) + + return None + + +def setup_spotlight(options: "Dict[str, Any]") -> "Optional[SpotlightClient]": + url = _resolve_spotlight_url(options.get("spotlight"), sentry_logger) + + if url is None: + return None + + # Only set up logging handler when spotlight is actually enabled + _handler = logging.StreamHandler(sys.stderr) + _handler.setFormatter(logging.Formatter(" [spotlight] %(levelname)s: %(message)s")) + logger.addHandler(_handler) + logger.setLevel(logging.INFO) + + # Update options with resolved URL for consistency + options["spotlight"] = url + + with capture_internal_exceptions(): + if ( + settings is not None + and settings.DEBUG + and env_to_bool(os.environ.get("SENTRY_SPOTLIGHT_ON_ERROR", "1")) + and env_to_bool(os.environ.get("SENTRY_SPOTLIGHT_MIDDLEWARE", "1")) + ): + middleware = settings.MIDDLEWARE + if DJANGO_SPOTLIGHT_MIDDLEWARE_PATH not in middleware: + settings.MIDDLEWARE = type(middleware)( + chain(middleware, (DJANGO_SPOTLIGHT_MIDDLEWARE_PATH,)) + ) + logger.info("Enabled Spotlight integration for Django") + + client = SpotlightClient(url) + logger.info("Enabled Spotlight using sidecar at %s", url) + + return client diff --git a/sentry_sdk/tracing.py b/sentry_sdk/tracing.py index 704339286f..c4b38e4528 100644 --- a/sentry_sdk/tracing.py +++ b/sentry_sdk/tracing.py @@ -1,30 +1,130 @@ import uuid -import random - -from datetime import timedelta +import warnings +from datetime import datetime, timedelta, timezone +from enum import Enum +from typing import TYPE_CHECKING import sentry_sdk -from sentry_sdk.consts import INSTRUMENTER -from sentry_sdk.utils import is_valid_sample_rate, logger, nanosecond_time -from sentry_sdk._compat import datetime_utcnow, PY2 -from sentry_sdk.consts import SPANDATA -from sentry_sdk._types import TYPE_CHECKING - +from sentry_sdk.consts import INSTRUMENTER, SPANDATA, SPANSTATUS, SPANTEMPLATE +from sentry_sdk.profiler.continuous_profiler import get_profiler_id +from sentry_sdk.utils import ( + capture_internal_exceptions, + get_current_thread_meta, + is_valid_sample_rate, + logger, + nanosecond_time, + should_be_treated_as_error, +) if TYPE_CHECKING: - import typing + from collections.abc import Callable, Mapping, MutableMapping + from typing import ( + Any, + Dict, + Iterator, + List, + Optional, + ParamSpec, + Set, + Tuple, + TypeVar, + Union, + overload, + ) + + from typing_extensions import TypedDict, Unpack + + P = ParamSpec("P") + R = TypeVar("R") + + from sentry_sdk._types import ( + Event, + MeasurementUnit, + MeasurementValue, + SamplingContext, + ) + from sentry_sdk.profiler.continuous_profiler import ContinuousProfile + from sentry_sdk.profiler.transaction_profiler import Profile + + class SpanKwargs(TypedDict, total=False): + trace_id: str + """ + The trace ID of the root span. If this new span is to be the root span, + omit this parameter, and a new trace ID will be generated. + """ + + span_id: str + """The span ID of this span. If omitted, a new span ID will be generated.""" + + parent_span_id: str + """The span ID of the parent span, if applicable.""" - from datetime import datetime - from typing import Any - from typing import Dict - from typing import Iterator - from typing import List - from typing import Optional - from typing import Tuple + same_process_as_parent: bool + """Whether this span is in the same process as the parent span.""" - import sentry_sdk.profiler - from sentry_sdk._types import Event, MeasurementUnit, SamplingContext + sampled: bool + """ + Whether the span should be sampled. Overrides the default sampling decision + for this span when provided. + """ + + op: str + """ + The span's operation. A list of recommended values is available here: + https://develop.sentry.dev/sdk/performance/span-operations/ + """ + + description: str + """A description of what operation is being performed within the span. This argument is DEPRECATED. Please use the `name` parameter, instead.""" + + hub: "Optional[sentry_sdk.Hub]" + """The hub to use for this span. This argument is DEPRECATED. Please use the `scope` parameter, instead.""" + status: str + """The span's status. Possible values are listed at https://develop.sentry.dev/sdk/event-payloads/span/""" + + containing_transaction: "Optional[Transaction]" + """The transaction that this span belongs to.""" + + start_timestamp: "Optional[Union[datetime, float]]" + """ + The timestamp when the span started. If omitted, the current time + will be used. + """ + + scope: "sentry_sdk.Scope" + """The scope to use for this span. If not provided, we use the current scope.""" + + origin: str + """ + The origin of the span. + See https://develop.sentry.dev/sdk/performance/trace-origin/ + Default "manual". + """ + + name: str + """A string describing what operation is being performed within the span/transaction.""" + + class TransactionKwargs(SpanKwargs, total=False): + source: str + """ + A string describing the source of the transaction name. This will be used to determine the transaction's type. + See https://develop.sentry.dev/sdk/event-payloads/transaction/#transaction-annotations for more information. + Default "custom". + """ + + parent_sampled: bool + """Whether the parent transaction was sampled. If True this transaction will be kept, if False it will be discarded.""" + + baggage: "Baggage" + """The W3C baggage header value. (see https://www.w3.org/TR/baggage/)""" + + ProfileContext = TypedDict( + "ProfileContext", + { + "profiler_id": str, + }, + ) BAGGAGE_HEADER_NAME = "baggage" SENTRY_TRACE_HEADER_NAME = "sentry-trace" @@ -32,66 +132,136 @@ # Transaction source # see https://develop.sentry.dev/sdk/event-payloads/transaction/#transaction-annotations -TRANSACTION_SOURCE_CUSTOM = "custom" -TRANSACTION_SOURCE_URL = "url" -TRANSACTION_SOURCE_ROUTE = "route" -TRANSACTION_SOURCE_VIEW = "view" -TRANSACTION_SOURCE_COMPONENT = "component" -TRANSACTION_SOURCE_TASK = "task" +class TransactionSource(str, Enum): + COMPONENT = "component" + CUSTOM = "custom" + ROUTE = "route" + TASK = "task" + URL = "url" + VIEW = "view" + + def __str__(self) -> str: + return self.value + # These are typically high cardinality and the server hates them LOW_QUALITY_TRANSACTION_SOURCES = [ - TRANSACTION_SOURCE_URL, + TransactionSource.URL, ] SOURCE_FOR_STYLE = { - "endpoint": TRANSACTION_SOURCE_COMPONENT, - "function_name": TRANSACTION_SOURCE_COMPONENT, - "handler_name": TRANSACTION_SOURCE_COMPONENT, - "method_and_path_pattern": TRANSACTION_SOURCE_ROUTE, - "path": TRANSACTION_SOURCE_URL, - "route_name": TRANSACTION_SOURCE_COMPONENT, - "route_pattern": TRANSACTION_SOURCE_ROUTE, - "uri_template": TRANSACTION_SOURCE_ROUTE, - "url": TRANSACTION_SOURCE_ROUTE, + "endpoint": TransactionSource.COMPONENT, + "function_name": TransactionSource.COMPONENT, + "handler_name": TransactionSource.COMPONENT, + "method_and_path_pattern": TransactionSource.ROUTE, + "path": TransactionSource.URL, + "route_name": TransactionSource.COMPONENT, + "route_pattern": TransactionSource.ROUTE, + "uri_template": TransactionSource.ROUTE, + "url": TransactionSource.ROUTE, } -class _SpanRecorder(object): +def get_span_status_from_http_code(http_status_code: int) -> str: + """ + Returns the Sentry status corresponding to the given HTTP status code. + + See: https://develop.sentry.dev/sdk/event-payloads/contexts/#trace-context + """ + if http_status_code < 400: + return SPANSTATUS.OK + + elif 400 <= http_status_code < 500: + if http_status_code == 403: + return SPANSTATUS.PERMISSION_DENIED + elif http_status_code == 404: + return SPANSTATUS.NOT_FOUND + elif http_status_code == 429: + return SPANSTATUS.RESOURCE_EXHAUSTED + elif http_status_code == 413: + return SPANSTATUS.FAILED_PRECONDITION + elif http_status_code == 401: + return SPANSTATUS.UNAUTHENTICATED + elif http_status_code == 409: + return SPANSTATUS.ALREADY_EXISTS + else: + return SPANSTATUS.INVALID_ARGUMENT + + elif 500 <= http_status_code < 600: + if http_status_code == 504: + return SPANSTATUS.DEADLINE_EXCEEDED + elif http_status_code == 501: + return SPANSTATUS.UNIMPLEMENTED + elif http_status_code == 503: + return SPANSTATUS.UNAVAILABLE + else: + return SPANSTATUS.INTERNAL_ERROR + + return SPANSTATUS.UNKNOWN_ERROR + + +class _SpanRecorder: """Limits the number of spans recorded in a transaction.""" - __slots__ = ("maxlen", "spans") + __slots__ = ("maxlen", "spans", "dropped_spans") - def __init__(self, maxlen): - # type: (int) -> None + def __init__(self, maxlen: int) -> None: # FIXME: this is `maxlen - 1` only to preserve historical behavior # enforced by tests. # Either this should be changed to `maxlen` or the JS SDK implementation # should be changed to match a consistent interpretation of what maxlen # limits: either transaction+spans or only child spans. self.maxlen = maxlen - 1 - self.spans = [] # type: List[Span] + self.spans: "List[Span]" = [] + self.dropped_spans: int = 0 - def add(self, span): - # type: (Span) -> None + def add(self, span: "Span") -> None: if len(self.spans) > self.maxlen: span._span_recorder = None + self.dropped_spans += 1 else: self.spans.append(span) -class Span(object): +class Span: """A span holds timing information of a block of code. - Spans can have multiple child spans thus forming a span tree.""" + Spans can have multiple child spans thus forming a span tree. + + :param trace_id: The trace ID of the root span. If this new span is to be the root span, + omit this parameter, and a new trace ID will be generated. + :param span_id: The span ID of this span. If omitted, a new span ID will be generated. + :param parent_span_id: The span ID of the parent span, if applicable. + :param same_process_as_parent: Whether this span is in the same process as the parent span. + :param sampled: Whether the span should be sampled. Overrides the default sampling decision + for this span when provided. + :param op: The span's operation. A list of recommended values is available here: + https://develop.sentry.dev/sdk/performance/span-operations/ + :param description: A description of what operation is being performed within the span. + + .. deprecated:: 2.15.0 + Please use the `name` parameter, instead. + :param name: A string describing what operation is being performed within the span. + :param hub: The hub to use for this span. + + .. deprecated:: 2.0.0 + Please use the `scope` parameter, instead. + :param status: The span's status. Possible values are listed at + https://develop.sentry.dev/sdk/event-payloads/span/ + :param containing_transaction: The transaction that this span belongs to. + :param start_timestamp: The timestamp when the span started. If omitted, the current time + will be used. + :param scope: The scope to use for this span. If not provided, we use the current scope. + """ __slots__ = ( - "trace_id", - "span_id", + "_trace_id", + "_span_id", "parent_span_id", "same_process_as_parent", "sampled", "op", "description", + "_measurements", "start_timestamp", "_start_timestamp_monotonic_ns", "status", @@ -102,51 +272,62 @@ class Span(object): "hub", "_context_manager_state", "_containing_transaction", + "scope", + "origin", + "name", + "_flags", + "_flags_capacity", ) - def __new__(cls, **kwargs): - # type: (**Any) -> Any - """ - Backwards-compatible implementation of Span and Transaction - creation. - """ - - # TODO: consider removing this in a future release. - # This is for backwards compatibility with releases before Transaction - # existed, to allow for a smoother transition. - if "transaction" in kwargs: - return object.__new__(Transaction) - return object.__new__(cls) - def __init__( self, - trace_id=None, # type: Optional[str] - span_id=None, # type: Optional[str] - parent_span_id=None, # type: Optional[str] - same_process_as_parent=True, # type: bool - sampled=None, # type: Optional[bool] - op=None, # type: Optional[str] - description=None, # type: Optional[str] - hub=None, # type: Optional[sentry_sdk.Hub] - status=None, # type: Optional[str] - transaction=None, # type: Optional[str] # deprecated - containing_transaction=None, # type: Optional[Transaction] - start_timestamp=None, # type: Optional[datetime] - ): - # type: (...) -> None - self.trace_id = trace_id or uuid.uuid4().hex - self.span_id = span_id or uuid.uuid4().hex[16:] + trace_id: "Optional[str]" = None, + span_id: "Optional[str]" = None, + parent_span_id: "Optional[str]" = None, + same_process_as_parent: bool = True, + sampled: "Optional[bool]" = None, + op: "Optional[str]" = None, + description: "Optional[str]" = None, + hub: "Optional[sentry_sdk.Hub]" = None, # deprecated + status: "Optional[str]" = None, + containing_transaction: "Optional[Transaction]" = None, + start_timestamp: "Optional[Union[datetime, float]]" = None, + scope: "Optional[sentry_sdk.Scope]" = None, + origin: str = "manual", + name: "Optional[str]" = None, + ) -> None: + self._trace_id = trace_id + self._span_id = span_id self.parent_span_id = parent_span_id self.same_process_as_parent = same_process_as_parent self.sampled = sampled self.op = op - self.description = description + self.description = name or description self.status = status - self.hub = hub - self._tags = {} # type: Dict[str, str] - self._data = {} # type: Dict[str, Any] + self.hub = hub # backwards compatibility + self.scope = scope + self.origin = origin + self._measurements: "Dict[str, MeasurementValue]" = {} + self._tags: "MutableMapping[str, str]" = {} + self._data: "Dict[str, Any]" = {} self._containing_transaction = containing_transaction - self.start_timestamp = start_timestamp or datetime_utcnow() + self._flags: "Dict[str, bool]" = {} + self._flags_capacity = 10 + + if hub is not None: + warnings.warn( + "The `hub` parameter is deprecated. Please use `scope` instead.", + DeprecationWarning, + stacklevel=2, + ) + + self.scope = self.scope or hub.scope + + if start_timestamp is None: + start_timestamp = datetime.now(timezone.utc) + elif isinstance(start_timestamp, float): + start_timestamp = datetime.fromtimestamp(start_timestamp, timezone.utc) + self.start_timestamp = start_timestamp try: # profiling depends on this value and requires that # it is measured in nanoseconds @@ -155,21 +336,44 @@ def __init__( pass #: End timestamp of span - self.timestamp = None # type: Optional[datetime] + self.timestamp: "Optional[datetime]" = None + + self._span_recorder: "Optional[_SpanRecorder]" = None - self._span_recorder = None # type: Optional[_SpanRecorder] + self.update_active_thread() + self.set_profiler_id(get_profiler_id()) # TODO this should really live on the Transaction class rather than the Span # class - def init_span_recorder(self, maxlen): - # type: (int) -> None + def init_span_recorder(self, maxlen: int) -> None: if self._span_recorder is None: self._span_recorder = _SpanRecorder(maxlen) - def __repr__(self): - # type: () -> str + @property + def trace_id(self) -> str: + if not self._trace_id: + self._trace_id = uuid.uuid4().hex + + return self._trace_id + + @trace_id.setter + def trace_id(self, value: str) -> None: + self._trace_id = value + + @property + def span_id(self) -> str: + if not self._span_id: + self._span_id = uuid.uuid4().hex[16:] + + return self._span_id + + @span_id.setter + def span_id(self, value: str) -> None: + self._span_id = value + + def __repr__(self) -> str: return ( - "<%s(op=%r, description:%r, trace_id=%r, span_id=%r, parent_span_id=%r, sampled=%r)>" + "<%s(op=%r, description:%r, trace_id=%r, span_id=%r, parent_span_id=%r, sampled=%r, origin=%r)>" % ( self.__class__.__name__, self.op, @@ -178,33 +382,31 @@ def __repr__(self): self.span_id, self.parent_span_id, self.sampled, + self.origin, ) ) - def __enter__(self): - # type: () -> Span - hub = self.hub or sentry_sdk.Hub.current - - _, scope = hub._stack[-1] + def __enter__(self) -> "Span": + scope = self.scope or sentry_sdk.get_current_scope() old_span = scope.span scope.span = self - self._context_manager_state = (hub, scope, old_span) + self._context_manager_state = (scope, old_span) return self - def __exit__(self, ty, value, tb): - # type: (Optional[Any], Optional[Any], Optional[Any]) -> None - if value is not None: - self.set_status("internal_error") - - hub, scope, old_span = self._context_manager_state - del self._context_manager_state + def __exit__( + self, ty: "Optional[Any]", value: "Optional[Any]", tb: "Optional[Any]" + ) -> None: + if value is not None and should_be_treated_as_error(ty, value): + self.set_status(SPANSTATUS.INTERNAL_ERROR) - self.finish(hub) - scope.span = old_span + with capture_internal_exceptions(): + scope, old_span = self._context_manager_state + del self._context_manager_state + self.finish(scope) + scope.span = old_span @property - def containing_transaction(self): - # type: () -> Optional[Transaction] + def containing_transaction(self) -> "Optional[Transaction]": """The ``Transaction`` that this span belongs to. The ``Transaction`` is the root of the span tree, so one could also think of this ``Transaction`` as the "root span".""" @@ -214,18 +416,28 @@ def containing_transaction(self): # referencing themselves) return self._containing_transaction - def start_child(self, instrumenter=INSTRUMENTER.SENTRY, **kwargs): - # type: (str, **Any) -> Span + def start_child( + self, instrumenter: str = INSTRUMENTER.SENTRY, **kwargs: "Any" + ) -> "Span": """ Start a sub-span from the current span or transaction. Takes the same arguments as the initializer of :py:class:`Span`. The trace id, sampling decision, transaction pointer, and span recorder are inherited from the current span/transaction. + + The instrumenter parameter is deprecated for user code, and it will + be removed in the next major version. Going forward, it should only + be used by the SDK itself. """ - hub = self.hub or sentry_sdk.Hub.current - client = hub.client - configuration_instrumenter = client and client.options["instrumenter"] + if kwargs.get("description") is not None: + warnings.warn( + "The `description` parameter is deprecated. Please use `name` instead.", + DeprecationWarning, + stacklevel=2, + ) + + configuration_instrumenter = sentry_sdk.get_client().options["instrumenter"] if instrumenter != configuration_instrumenter: return NoOpSpan() @@ -236,7 +448,7 @@ def start_child(self, instrumenter=INSTRUMENTER.SENTRY, **kwargs): trace_id=self.trace_id, parent_span_id=self.span_id, containing_transaction=self.containing_transaction, - **kwargs + **kwargs, ) span_recorder = ( @@ -247,22 +459,15 @@ def start_child(self, instrumenter=INSTRUMENTER.SENTRY, **kwargs): return child - def new_span(self, **kwargs): - # type: (**Any) -> Span - """DEPRECATED: use :py:meth:`sentry_sdk.tracing.Span.start_child` instead.""" - logger.warning( - "Deprecated: use Span.start_child instead of Span.new_span. This will be removed in the future." - ) - return self.start_child(**kwargs) - @classmethod def continue_from_environ( cls, - environ, # type: typing.Mapping[str, str] - **kwargs # type: Any - ): - # type: (...) -> Transaction + environ: "Mapping[str, str]", + **kwargs: "Any", + ) -> "Transaction": """ + DEPRECATED: Use :py:meth:`sentry_sdk.continue_trace`. + Create a Transaction with the given params, then add in data pulled from the ``sentry-trace`` and ``baggage`` headers from the environ (if any) before returning the Transaction. @@ -274,36 +479,33 @@ def continue_from_environ( :param environ: The ASGI/WSGI environ to pull information from. """ - if cls is Span: - logger.warning( - "Deprecated: use Transaction.continue_from_environ " - "instead of Span.continue_from_environ." - ) return Transaction.continue_from_headers(EnvironHeaders(environ), **kwargs) @classmethod def continue_from_headers( cls, - headers, # type: typing.Mapping[str, str] - **kwargs # type: Any - ): - # type: (...) -> Transaction + headers: "Mapping[str, str]", + *, + _sample_rand: "Optional[str]" = None, + **kwargs: "Any", + ) -> "Transaction": """ + DEPRECATED: Use :py:meth:`sentry_sdk.continue_trace`. + Create a transaction with the given params (including any data pulled from the ``sentry-trace`` and ``baggage`` headers). :param headers: The dictionary with the HTTP headers to pull information from. + :param _sample_rand: If provided, we override the sample_rand value from the + incoming headers with this value. (internal use only) """ - # TODO move this to the Transaction class - if cls is Span: - logger.warning( - "Deprecated: use Transaction.continue_from_headers " - "instead of Span.continue_from_headers." - ) + logger.warning("Deprecated: use sentry_sdk.continue_trace instead.") # TODO-neel move away from this kwargs stuff, it's confusing and opaque # make more explicit - baggage = Baggage.from_incoming_header(headers.get(BAGGAGE_HEADER_NAME)) + baggage = Baggage.from_incoming_header( + headers.get(BAGGAGE_HEADER_NAME), _sample_rand=_sample_rand + ) kwargs.update({BAGGAGE_HEADER_NAME: baggage}) sentrytrace_kwargs = extract_sentrytrace_data( @@ -323,38 +525,37 @@ def continue_from_headers( return transaction - def iter_headers(self): - # type: () -> Iterator[Tuple[str, str]] + def iter_headers(self) -> "Iterator[Tuple[str, str]]": """ Creates a generator which returns the span's ``sentry-trace`` and ``baggage`` headers. If the span's containing transaction doesn't yet have a ``baggage`` value, this will cause one to be generated and stored. """ + if not self.containing_transaction: + # Do not propagate headers if there is no containing transaction. Otherwise, this + # span ends up being the root span of a new trace, and since it does not get sent + # to Sentry, the trace will be missing a root transaction. The dynamic sampling + # context will also be missing, breaking dynamic sampling & traces. + return + yield SENTRY_TRACE_HEADER_NAME, self.to_traceparent() - if self.containing_transaction: - baggage = self.containing_transaction.get_baggage().serialize() - if baggage: - yield BAGGAGE_HEADER_NAME, baggage + baggage = self.containing_transaction.get_baggage().serialize() + if baggage: + yield BAGGAGE_HEADER_NAME, baggage @classmethod def from_traceparent( cls, - traceparent, # type: Optional[str] - **kwargs # type: Any - ): - # type: (...) -> Optional[Transaction] + traceparent: "Optional[str]", + **kwargs: "Any", + ) -> "Optional[Transaction]": """ - DEPRECATED: Use :py:meth:`sentry_sdk.tracing.Span.continue_from_headers`. + DEPRECATED: Use :py:meth:`sentry_sdk.continue_trace`. Create a ``Transaction`` with the given params, then add in data pulled from the given ``sentry-trace`` header value before returning the ``Transaction``. """ - logger.warning( - "Deprecated: Use Transaction.continue_from_headers(headers, **kwargs) " - "instead of from_traceparent(traceparent, **kwargs)" - ) - if not traceparent: return None @@ -362,8 +563,7 @@ def from_traceparent( {SENTRY_TRACE_HEADER_NAME: traceparent}, **kwargs ) - def to_traceparent(self): - # type: () -> str + def to_traceparent(self) -> str: if self.sampled is True: sampled = "1" elif self.sampled is False: @@ -377,8 +577,7 @@ def to_traceparent(self): return traceparent - def to_baggage(self): - # type: () -> Optional[Baggage] + def to_baggage(self) -> "Optional[Baggage]": """Returns the :py:class:`~sentry_sdk.tracing_utils.Baggage` associated with this ``Span``, if any. (Taken from the root of the span tree.) """ @@ -386,83 +585,87 @@ def to_baggage(self): return self.containing_transaction.get_baggage() return None - def set_tag(self, key, value): - # type: (str, Any) -> None + def set_tag(self, key: str, value: "Any") -> None: self._tags[key] = value - def set_data(self, key, value): - # type: (str, Any) -> None + def set_data(self, key: str, value: "Any") -> None: self._data[key] = value - def set_status(self, value): - # type: (str) -> None + def update_data(self, data: "Dict[str, Any]") -> None: + self._data.update(data) + + def set_flag(self, flag: str, result: bool) -> None: + if len(self._flags) < self._flags_capacity: + self._flags[flag] = result + + def set_status(self, value: str) -> None: self.status = value - def set_http_status(self, http_status): - # type: (int) -> None + def set_measurement( + self, name: str, value: float, unit: "MeasurementUnit" = "" + ) -> None: + """ + .. deprecated:: 2.28.0 + This function is deprecated and will be removed in the next major release. + """ + + warnings.warn( + "`set_measurement()` is deprecated and will be removed in the next major version. Please use `set_data()` instead.", + DeprecationWarning, + stacklevel=2, + ) + self._measurements[name] = {"value": value, "unit": unit} + + def set_thread( + self, thread_id: "Optional[int]", thread_name: "Optional[str]" + ) -> None: + if thread_id is not None: + self.set_data(SPANDATA.THREAD_ID, str(thread_id)) + + if thread_name is not None: + self.set_data(SPANDATA.THREAD_NAME, thread_name) + + def set_profiler_id(self, profiler_id: "Optional[str]") -> None: + if profiler_id is not None: + self.set_data(SPANDATA.PROFILER_ID, profiler_id) + + def set_http_status(self, http_status: int) -> None: self.set_tag( "http.status_code", str(http_status) - ) # we keep this for backwards compatability + ) # TODO-neel remove in major, we keep this for backwards compatibility self.set_data(SPANDATA.HTTP_STATUS_CODE, http_status) + self.set_status(get_span_status_from_http_code(http_status)) - if http_status < 400: - self.set_status("ok") - elif 400 <= http_status < 500: - if http_status == 403: - self.set_status("permission_denied") - elif http_status == 404: - self.set_status("not_found") - elif http_status == 429: - self.set_status("resource_exhausted") - elif http_status == 413: - self.set_status("failed_precondition") - elif http_status == 401: - self.set_status("unauthenticated") - elif http_status == 409: - self.set_status("already_exists") - else: - self.set_status("invalid_argument") - elif 500 <= http_status < 600: - if http_status == 504: - self.set_status("deadline_exceeded") - elif http_status == 501: - self.set_status("unimplemented") - elif http_status == 503: - self.set_status("unavailable") - else: - self.set_status("internal_error") - else: - self.set_status("unknown_error") - - def is_success(self): - # type: () -> bool + def is_success(self) -> bool: return self.status == "ok" - def finish(self, hub=None, end_timestamp=None): - # type: (Optional[sentry_sdk.Hub], Optional[datetime]) -> Optional[str] - # Note: would be type: (Optional[sentry_sdk.Hub]) -> None, but that leads - # to incompatible return types for Span.finish and Transaction.finish. - """Sets the end timestamp of the span. + def finish( + self, + scope: "Optional[sentry_sdk.Scope]" = None, + end_timestamp: "Optional[Union[float, datetime]]" = None, + ) -> "Optional[str]": + """ + Sets the end timestamp of the span. + Additionally it also creates a breadcrumb from the span, if the span represents a database or HTTP request. - :param hub: The hub to use for this transaction. - If not provided, the current hub will be used. + :param scope: The scope to use for this transaction. + If not provided, the current scope will be used. :param end_timestamp: Optional timestamp that should be used as timestamp instead of the current time. :return: Always ``None``. The type is ``Optional[str]`` to match the return value of :py:meth:`sentry_sdk.tracing.Transaction.finish`. """ - if self.timestamp is not None: # This span is already finished, ignore. return None - hub = hub or self.hub or sentry_sdk.Hub.current - try: if end_timestamp: + if isinstance(end_timestamp, float): + end_timestamp = datetime.fromtimestamp(end_timestamp, timezone.utc) self.timestamp = end_timestamp else: elapsed = nanosecond_time() - self._start_timestamp_monotonic_ns @@ -470,16 +673,17 @@ def finish(self, hub=None, end_timestamp=None): microseconds=elapsed / 1000 ) except AttributeError: - self.timestamp = datetime_utcnow() + self.timestamp = datetime.now(timezone.utc) + + scope = scope or sentry_sdk.get_current_scope() + maybe_create_breadcrumbs_from_span(scope, self) - maybe_create_breadcrumbs_from_span(hub, self) return None - def to_json(self): - # type: () -> Dict[str, Any] + def to_json(self) -> "Dict[str, Any]": """Returns a JSON-compatible representation of the span.""" - rv = { + rv: "Dict[str, Any]" = { "trace_id": self.trace_id, "span_id": self.span_id, "parent_span_id": self.parent_span_id, @@ -488,44 +692,92 @@ def to_json(self): "description": self.description, "start_timestamp": self.start_timestamp, "timestamp": self.timestamp, - } # type: Dict[str, Any] + "origin": self.origin, + } if self.status: + rv["status"] = self.status + # TODO-neel remove redundant tag in major self._tags["status"] = self.status + if len(self._measurements) > 0: + rv["measurements"] = self._measurements + tags = self._tags if tags: rv["tags"] = tags - data = self._data + data = {} + data.update(self._flags) + data.update(self._data) if data: rv["data"] = data return rv - def get_trace_context(self): - # type: () -> Any - rv = { + def get_trace_context(self) -> "Any": + rv: "Dict[str, Any]" = { "trace_id": self.trace_id, "span_id": self.span_id, "parent_span_id": self.parent_span_id, "op": self.op, "description": self.description, - } # type: Dict[str, Any] + "origin": self.origin, + } if self.status: rv["status"] = self.status if self.containing_transaction: - rv[ - "dynamic_sampling_context" - ] = self.containing_transaction.get_baggage().dynamic_sampling_context() + rv["dynamic_sampling_context"] = ( + self.containing_transaction.get_baggage().dynamic_sampling_context() + ) + + data = {} + + thread_id = self._data.get(SPANDATA.THREAD_ID) + if thread_id is not None: + data["thread.id"] = thread_id + + thread_name = self._data.get(SPANDATA.THREAD_NAME) + if thread_name is not None: + data["thread.name"] = thread_name + + if data: + rv["data"] = data return rv + def get_profile_context(self) -> "Optional[ProfileContext]": + profiler_id = self._data.get(SPANDATA.PROFILER_ID) + if profiler_id is None: + return None + + return { + "profiler_id": profiler_id, + } + + def update_active_thread(self) -> None: + thread_id, thread_name = get_current_thread_meta() + self.set_thread(thread_id, thread_name) + class Transaction(Span): """The Transaction is the root element that holds all the spans - for Sentry performance instrumentation.""" + for Sentry performance instrumentation. + + :param name: Identifier of the transaction. + Will show up in the Sentry UI. + :param parent_sampled: Whether the parent transaction was sampled. + If True this transaction will be kept, if False it will be discarded. + :param baggage: The W3C baggage header value. + (see https://www.w3.org/TR/baggage/) + :param source: A string describing the source of the transaction name. + This will be used to determine the transaction's type. + See https://develop.sentry.dev/sdk/event-payloads/transaction/#transaction-annotations + for more information. Default "custom". + :param kwargs: Additional arguments to be passed to the Span constructor. + See :py:class:`sentry_sdk.tracing.Span` for available arguments. + """ __slots__ = ( "name", @@ -536,56 +788,42 @@ class Transaction(Span): "_measurements", "_contexts", "_profile", + "_continuous_profile", "_baggage", + "_sample_rand", ) - def __init__( + def __init__( # type: ignore[misc] self, - name="", # type: str - parent_sampled=None, # type: Optional[bool] - baggage=None, # type: Optional[Baggage] - source=TRANSACTION_SOURCE_CUSTOM, # type: str - **kwargs # type: Any - ): - # type: (...) -> None - """Constructs a new Transaction. - - :param name: Identifier of the transaction. - Will show up in the Sentry UI. - :param parent_sampled: Whether the parent transaction was sampled. - If True this transaction will be kept, if False it will be discarded. - :param baggage: The W3C baggage header value. - (see https://www.w3.org/TR/baggage/) - :param source: A string describing the source of the transaction name. - This will be used to determine the transaction's type. - See https://develop.sentry.dev/sdk/event-payloads/transaction/#transaction-annotations - for more information. Default "custom". - """ - # TODO: consider removing this in a future release. - # This is for backwards compatibility with releases before Transaction - # existed, to allow for a smoother transition. - if not name and "transaction" in kwargs: - logger.warning( - "Deprecated: use Transaction(name=...) to create transactions " - "instead of Span(transaction=...)." - ) - name = kwargs.pop("transaction") - - super(Transaction, self).__init__(**kwargs) + name: str = "", + parent_sampled: "Optional[bool]" = None, + baggage: "Optional[Baggage]" = None, + source: str = TransactionSource.CUSTOM, + **kwargs: "Unpack[SpanKwargs]", + ) -> None: + super().__init__(**kwargs) self.name = name self.source = source - self.sample_rate = None # type: Optional[float] + self.sample_rate: "Optional[float]" = None self.parent_sampled = parent_sampled - self._measurements = {} # type: Dict[str, Any] - self._contexts = {} # type: Dict[str, Any] - self._profile = None # type: Optional[sentry_sdk.profiler.Profile] + self._measurements: "Dict[str, MeasurementValue]" = {} + self._contexts: "Dict[str, Any]" = {} + self._profile: "Optional[Profile]" = None + self._continuous_profile: "Optional[ContinuousProfile]" = None self._baggage = baggage - def __repr__(self): - # type: () -> str + baggage_sample_rand = ( + None if self._baggage is None else self._baggage._sample_rand() + ) + if baggage_sample_rand is not None: + self._sample_rand = baggage_sample_rand + else: + self._sample_rand = _generate_sample_rand(self.trace_id) + + def __repr__(self) -> str: return ( - "<%s(name=%r, op=%r, trace_id=%r, span_id=%r, parent_span_id=%r, sampled=%r, source=%r)>" + "<%s(name=%r, op=%r, trace_id=%r, span_id=%r, parent_span_id=%r, sampled=%r, source=%r, origin=%r)>" % ( self.__class__.__name__, self.name, @@ -595,28 +833,49 @@ def __repr__(self): self.parent_span_id, self.sampled, self.source, + self.origin, ) ) - def __enter__(self): - # type: () -> Transaction - super(Transaction, self).__enter__() + def _possibly_started(self) -> bool: + """Returns whether the transaction might have been started. + + If this returns False, we know that the transaction was not started + with sentry_sdk.start_transaction, and therefore the transaction will + be discarded. + """ + + # We must explicitly check self.sampled is False since self.sampled can be None + return self._span_recorder is not None or self.sampled is False + + def __enter__(self) -> "Transaction": + if not self._possibly_started(): + logger.debug( + "Transaction was entered without being started with sentry_sdk.start_transaction." + "The transaction will not be sent to Sentry. To fix, start the transaction by" + "passing it to sentry_sdk.start_transaction." + ) + + super().__enter__() if self._profile is not None: self._profile.__enter__() return self - def __exit__(self, ty, value, tb): - # type: (Optional[Any], Optional[Any], Optional[Any]) -> None + def __exit__( + self, ty: "Optional[Any]", value: "Optional[Any]", tb: "Optional[Any]" + ) -> None: if self._profile is not None: self._profile.__exit__(ty, value, tb) - super(Transaction, self).__exit__(ty, value, tb) + if self._continuous_profile is not None: + self._continuous_profile.stop() + + super().__exit__(ty, value, tb) @property - def containing_transaction(self): - # type: () -> Transaction + def containing_transaction(self) -> "Transaction": """The root element of the span tree. In the case of a transaction it is the transaction itself. """ @@ -626,15 +885,60 @@ def containing_transaction(self): # reference. return self - def finish(self, hub=None, end_timestamp=None): - # type: (Optional[sentry_sdk.Hub], Optional[datetime]) -> Optional[str] + def _get_scope_from_finish_args( + self, + scope_arg: "Optional[Union[sentry_sdk.Scope, sentry_sdk.Hub]]", + hub_arg: "Optional[Union[sentry_sdk.Scope, sentry_sdk.Hub]]", + ) -> "Optional[sentry_sdk.Scope]": + """ + Logic to get the scope from the arguments passed to finish. This + function exists for backwards compatibility with the old finish. + + TODO: Remove this function in the next major version. + """ + scope_or_hub = scope_arg + if hub_arg is not None: + warnings.warn( + "The `hub` parameter is deprecated. Please use the `scope` parameter, instead.", + DeprecationWarning, + stacklevel=3, + ) + + scope_or_hub = hub_arg + + if isinstance(scope_or_hub, sentry_sdk.Hub): + warnings.warn( + "Passing a Hub to finish is deprecated. Please pass a Scope, instead.", + DeprecationWarning, + stacklevel=3, + ) + + return scope_or_hub.scope + + return scope_or_hub + + def _get_log_representation(self) -> str: + return "{op}transaction <{name}>".format( + op=("<" + self.op + "> " if self.op else ""), name=self.name + ) + + def finish( + self, + scope: "Optional[sentry_sdk.Scope]" = None, + end_timestamp: "Optional[Union[float, datetime]]" = None, + *, + hub: "Optional[sentry_sdk.Hub]" = None, + ) -> "Optional[str]": """Finishes the transaction and sends it to Sentry. All finished spans in the transaction will also be sent to Sentry. - :param hub: The hub to use for this transaction. - If not provided, the current hub will be used. + :param scope: The Scope to use for this transaction. + If not provided, the current Scope will be used. :param end_timestamp: Optional timestamp that should be used as timestamp instead of the current time. + :param hub: The hub to use for this transaction. + This argument is DEPRECATED. Please use the `scope` + parameter, instead. :return: The event ID if the transaction was sent to Sentry, otherwise None. @@ -643,16 +947,27 @@ def finish(self, hub=None, end_timestamp=None): # This transaction is already finished, ignore. return None - hub = hub or self.hub or sentry_sdk.Hub.current - client = hub.client + # For backwards compatibility, we must handle the case where `scope` + # or `hub` could both either be a `Scope` or a `Hub`. + scope: "Optional[sentry_sdk.Scope]" = self._get_scope_from_finish_args( + scope, hub + ) + + scope = scope or self.scope or sentry_sdk.get_current_scope() + client = sentry_sdk.get_client() - if client is None: - # We have no client and therefore nowhere to send this transaction. + if not client.is_active(): + # We have no active client and therefore nowhere to send this transaction. return None - # This is a de facto proxy for checking if sampled = False if self._span_recorder is None: - logger.debug("Discarding transaction because sampled = False") + # Explicit check against False needed because self.sampled might be None + if self.sampled is False: + logger.debug("Discarding transaction because sampled = False") + else: + logger.debug( + "Discarding transaction because it was not started with sentry_sdk.start_transaction" + ) # This is not entirely accurate because discards here are not # exclusively based on sample rate but also traces sampler, but @@ -665,6 +980,8 @@ def finish(self, hub=None, end_timestamp=None): client.transport.record_lost_event(reason, data_category="transaction") + # Only one span (the transaction itself) is discarded, since we did not record any spans here. + client.transport.record_lost_event(reason, data_category="span") return None if not self.name: @@ -673,7 +990,33 @@ def finish(self, hub=None, end_timestamp=None): ) self.name = "" - super(Transaction, self).finish(hub, end_timestamp) + super().finish(scope, end_timestamp) + + status_code = self._data.get(SPANDATA.HTTP_STATUS_CODE) + if ( + status_code is not None + and status_code in client.options["trace_ignore_status_codes"] + ): + logger.debug( + "[Tracing] Discarding {transaction_description} because the HTTP status code {status_code} is matched by trace_ignore_status_codes: {trace_ignore_status_codes}".format( + transaction_description=self._get_log_representation(), + status_code=self._data[SPANDATA.HTTP_STATUS_CODE], + trace_ignore_status_codes=client.options[ + "trace_ignore_status_codes" + ], + ) + ) + if client.transport: + client.transport.record_lost_event( + "event_processor", data_category="transaction" + ) + + num_spans = len(self._span_recorder.spans) + 1 + client.transport.record_lost_event( + "event_processor", data_category="span", quantity=num_spans + ) + + self.sampled = False if not self.sampled: # At this point a `sampled = None` should have already been resolved @@ -689,6 +1032,9 @@ def finish(self, hub=None, end_timestamp=None): if span.timestamp is not None ] + len_diff = len(self._span_recorder.spans) - len(finished_spans) + dropped_spans = len_diff + self._span_recorder.dropped_spans + # we do this to break the circular reference of transaction -> span # recorder -> span -> containing transaction (which is where we started) # before either the spans or the transaction goes out of scope and has @@ -698,8 +1044,11 @@ def finish(self, hub=None, end_timestamp=None): contexts = {} contexts.update(self._contexts) contexts.update({"trace": self.get_trace_context()}) + profile_context = self.get_profile_context() + if profile_context is not None: + contexts.update({"profile": profile_context}) - event = { + event: "Event" = { "type": "transaction", "transaction": self.name, "transaction_info": {"source": self.source}, @@ -708,7 +1057,10 @@ def finish(self, hub=None, end_timestamp=None): "timestamp": self.timestamp, "start_timestamp": self.start_timestamp, "spans": finished_spans, - } # type: Event + } + + if dropped_spans > 0: + event["_dropped_spans"] = dropped_spans if self._profile is not None and self._profile.valid(): event["profile"] = self._profile @@ -716,14 +1068,24 @@ def finish(self, hub=None, end_timestamp=None): event["measurements"] = self._measurements - return hub.capture_event(event) + return scope.capture_event(event) + + def set_measurement( + self, name: str, value: float, unit: "MeasurementUnit" = "" + ) -> None: + """ + .. deprecated:: 2.28.0 + This function is deprecated and will be removed in the next major release. + """ - def set_measurement(self, name, value, unit=""): - # type: (str, float, MeasurementUnit) -> None + warnings.warn( + "`set_measurement()` is deprecated and will be removed in the next major version. Please use `set_data()` instead.", + DeprecationWarning, + stacklevel=2, + ) self._measurements[name] = {"value": value, "unit": unit} - def set_context(self, key, value): - # type: (str, Any) -> None + def set_context(self, key: str, value: "dict[str, Any]") -> None: """Sets a context. Transactions can have multiple contexts and they should follow the format described in the "Contexts Interface" documentation. @@ -733,18 +1095,16 @@ def set_context(self, key, value): """ self._contexts[key] = value - def set_http_status(self, http_status): - # type: (int) -> None + def set_http_status(self, http_status: int) -> None: """Sets the status of the Transaction according to the given HTTP status. :param http_status: The HTTP status code.""" - super(Transaction, self).set_http_status(http_status) + super().set_http_status(http_status) self.set_context("response", {"status_code": http_status}) - def to_json(self): - # type: () -> Dict[str, Any] + def to_json(self) -> "Dict[str, Any]": """Returns a JSON-compatible representation of the transaction.""" - rv = super(Transaction, self).to_json() + rv = super().to_json() rv["name"] = self.name rv["source"] = self.source @@ -752,21 +1112,28 @@ def to_json(self): return rv - def get_baggage(self): - # type: () -> Baggage + def get_trace_context(self) -> "Any": + trace_context = super().get_trace_context() + + if self._data: + trace_context["data"] = self._data + + return trace_context + + def get_baggage(self) -> "Baggage": """Returns the :py:class:`~sentry_sdk.tracing_utils.Baggage` associated with the Transaction. The first time a new baggage with Sentry items is made, it will be frozen.""" - if not self._baggage or self._baggage.mutable: self._baggage = Baggage.populate_from_transaction(self) return self._baggage - def _set_initial_sampling_decision(self, sampling_context): - # type: (SamplingContext) -> None + def _set_initial_sampling_decision( + self, sampling_context: "SamplingContext" + ) -> None: """ Sets the transaction's sampling decision, according to the following precedence rules: @@ -786,16 +1153,12 @@ def _set_initial_sampling_decision(self, sampling_context): 4. If `traces_sampler` is not defined and there's no parent sampling decision, `traces_sample_rate` will be used. """ + client = sentry_sdk.get_client() - hub = self.hub or sentry_sdk.Hub.current - client = hub.client - options = (client and client.options) or {} - transaction_description = "{op}transaction <{name}>".format( - op=("<" + self.op + "> " if self.op else ""), name=self.name - ) + transaction_description = self._get_log_representation() - # nothing to do if there's no client or if tracing is disabled - if not client or not has_tracing_enabled(options): + # nothing to do if tracing is disabled + if not has_tracing_enabled(client.options): self.sampled = False return @@ -809,13 +1172,13 @@ def _set_initial_sampling_decision(self, sampling_context): # `traces_sample_rate` were defined, so one of these should work; prefer # the hook if so sample_rate = ( - options["traces_sampler"](sampling_context) - if callable(options.get("traces_sampler")) + client.options["traces_sampler"](sampling_context) + if callable(client.options.get("traces_sampler")) + # default inheritance behavior else ( - # default inheritance behavior sampling_context["parent_sampled"] if sampling_context["parent_sampled"] is not None - else options["traces_sample_rate"] + else client.options["traces_sample_rate"] ) ) @@ -844,7 +1207,7 @@ def _set_initial_sampling_decision(self, sampling_context): transaction_description=transaction_description, reason=( "traces_sampler returned 0 or False" - if callable(options.get("traces_sampler")) + if callable(client.options.get("traces_sampler")) else "traces_sample_rate is set to 0" ), ) @@ -852,10 +1215,8 @@ def _set_initial_sampling_decision(self, sampling_context): self.sampled = False return - # Now we roll the dice. random.random is inclusive of 0, but not of 1, - # so strict < is safe here. In case sample_rate is a boolean, cast it - # to a float (True becomes 1.0 and False becomes 0.0) - self.sampled = random.random() < self.sample_rate + # Now we roll the dice. + self.sampled = self._sample_rand < self.sample_rate if self.sampled: logger.debug( @@ -873,118 +1234,193 @@ def _set_initial_sampling_decision(self, sampling_context): class NoOpSpan(Span): - def __repr__(self): - # type: () -> str - return self.__class__.__name__ + def __repr__(self) -> str: + return "<%s>" % self.__class__.__name__ @property - def containing_transaction(self): - # type: () -> Optional[Transaction] + def containing_transaction(self) -> "Optional[Transaction]": return None - def start_child(self, instrumenter=INSTRUMENTER.SENTRY, **kwargs): - # type: (str, **Any) -> NoOpSpan + def start_child( + self, instrumenter: str = INSTRUMENTER.SENTRY, **kwargs: "Any" + ) -> "NoOpSpan": return NoOpSpan() - def new_span(self, **kwargs): - # type: (**Any) -> NoOpSpan - return self.start_child(**kwargs) - - def to_traceparent(self): - # type: () -> str + def to_traceparent(self) -> str: return "" - def to_baggage(self): - # type: () -> Optional[Baggage] + def to_baggage(self) -> "Optional[Baggage]": return None - def get_baggage(self): - # type: () -> Optional[Baggage] + def get_baggage(self) -> "Optional[Baggage]": return None - def iter_headers(self): - # type: () -> Iterator[Tuple[str, str]] + def iter_headers(self) -> "Iterator[Tuple[str, str]]": return iter(()) - def set_tag(self, key, value): - # type: (str, Any) -> None + def set_tag(self, key: str, value: "Any") -> None: + pass + + def set_data(self, key: str, value: "Any") -> None: pass - def set_data(self, key, value): - # type: (str, Any) -> None + def update_data(self, data: "Dict[str, Any]") -> None: pass - def set_status(self, value): - # type: (str) -> None + def set_status(self, value: str) -> None: pass - def set_http_status(self, http_status): - # type: (int) -> None + def set_http_status(self, http_status: int) -> None: pass - def is_success(self): - # type: () -> bool + def is_success(self) -> bool: return True - def to_json(self): - # type: () -> Dict[str, Any] + def to_json(self) -> "Dict[str, Any]": return {} - def get_trace_context(self): - # type: () -> Any + def get_trace_context(self) -> "Any": return {} - def finish(self, hub=None, end_timestamp=None): - # type: (Optional[sentry_sdk.Hub], Optional[datetime]) -> Optional[str] + def get_profile_context(self) -> "Any": + return {} + + def finish( + self, + scope: "Optional[sentry_sdk.Scope]" = None, + end_timestamp: "Optional[Union[float, datetime]]" = None, + *, + hub: "Optional[sentry_sdk.Hub]" = None, + ) -> "Optional[str]": + """ + The `hub` parameter is deprecated. Please use the `scope` parameter, instead. + """ pass - def set_measurement(self, name, value, unit=""): - # type: (str, float, MeasurementUnit) -> None + def set_measurement( + self, name: str, value: float, unit: "MeasurementUnit" = "" + ) -> None: pass - def set_context(self, key, value): - # type: (str, Any) -> None + def set_context(self, key: str, value: "dict[str, Any]") -> None: pass - def init_span_recorder(self, maxlen): - # type: (int) -> None + def init_span_recorder(self, maxlen: int) -> None: pass - def _set_initial_sampling_decision(self, sampling_context): - # type: (SamplingContext) -> None + def _set_initial_sampling_decision( + self, sampling_context: "SamplingContext" + ) -> None: pass -def trace(func=None): - # type: (Any) -> Any - """ - Decorator to start a child span under the existing current transaction. - If there is no current transaction, then nothing will be traced. +if TYPE_CHECKING: - .. code-block:: - :caption: Usage + @overload + def trace( + func: None = None, + *, + op: "Optional[str]" = None, + name: "Optional[str]" = None, + attributes: "Optional[dict[str, Any]]" = None, + template: "SPANTEMPLATE" = SPANTEMPLATE.DEFAULT, + ) -> "Callable[[Callable[P, R]], Callable[P, R]]": + # Handles: @trace() and @trace(op="custom") + pass + + @overload + def trace(func: "Callable[P, R]") -> "Callable[P, R]": + # Handles: @trace + pass + + +def trace( + func: "Optional[Callable[P, R]]" = None, + *, + op: "Optional[str]" = None, + name: "Optional[str]" = None, + attributes: "Optional[dict[str, Any]]" = None, + template: "SPANTEMPLATE" = SPANTEMPLATE.DEFAULT, +) -> "Union[Callable[P, R], Callable[[Callable[P, R]], Callable[P, R]]]": + """ + Decorator to start a child span around a function call. + + This decorator automatically creates a new span when the decorated function + is called, and finishes the span when the function returns or raises an exception. + + :param func: The function to trace. When used as a decorator without parentheses, + this is the function being decorated. When used with parameters (e.g., + ``@trace(op="custom")``, this should be None. + :type func: Callable or None + + :param op: The operation name for the span. This is a high-level description + of what the span represents (e.g., "http.client", "db.query"). + You can use predefined constants from :py:class:`sentry_sdk.consts.OP` + or provide your own string. If not provided, a default operation will + be assigned based on the template. + :type op: str or None + + :param name: The human-readable name/description for the span. If not provided, + defaults to the function name. This provides more specific details about + what the span represents (e.g., "GET /api/users", "process_user_data"). + :type name: str or None + + :param attributes: A dictionary of key-value pairs to add as attributes to the span. + Attribute values must be strings, integers, floats, or booleans. These + attributes provide additional context about the span's execution. + :type attributes: dict[str, Any] or None + + :param template: The type of span to create. This determines what kind of + span instrumentation and data collection will be applied. Use predefined + constants from :py:class:`sentry_sdk.consts.SPANTEMPLATE`. + The default is `SPANTEMPLATE.DEFAULT` which is the right choice for most + use cases. + :type template: :py:class:`sentry_sdk.consts.SPANTEMPLATE` + + :returns: When used as ``@trace``, returns the decorated function. When used as + ``@trace(...)`` with parameters, returns a decorator function. + :rtype: Callable or decorator function + + Example:: import sentry_sdk + from sentry_sdk.consts import OP, SPANTEMPLATE + # Simple usage with default values @sentry_sdk.trace - def my_function(): - ... + def process_data(): + # Function implementation + pass - @sentry_sdk.trace - async def my_async_function(): - ... + # With custom parameters + @sentry_sdk.trace( + op=OP.DB_QUERY, + name="Get user data", + attributes={"postgres": True} + ) + def make_db_query(sql): + # Function implementation + pass + + # With a custom template + @sentry_sdk.trace(template=SPANTEMPLATE.AI_TOOL) + def calculate_interest_rate(amount, rate, years): + # Function implementation + pass """ - if PY2: - from sentry_sdk.tracing_utils_py2 import start_child_span_decorator - else: - from sentry_sdk.tracing_utils_py3 import start_child_span_decorator + from sentry_sdk.tracing_utils import create_span_decorator + + decorator = create_span_decorator( + op=op, + name=name, + attributes=attributes, + template=template, + ) - # This patterns allows usage of both @sentry_traced and @sentry_traced(...) - # See https://stackoverflow.com/questions/52126071/decorator-with-arguments-avoid-parenthesis-when-no-arguments/52126278 if func: - return start_child_span_decorator(func) + return decorator(func) else: - return start_child_span_decorator + return decorator # Circular imports @@ -992,6 +1428,7 @@ async def my_async_function(): from sentry_sdk.tracing_utils import ( Baggage, EnvironHeaders, + _generate_sample_rand, extract_sentrytrace_data, has_tracing_enabled, maybe_create_breadcrumbs_from_span, diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index 2a89145663..f45b849499 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -1,33 +1,44 @@ -import re import contextlib +import functools +import inspect +import os +import re +import sys +from collections.abc import Mapping +from datetime import timedelta +from random import Random +from urllib.parse import quote, unquote +import uuid import sentry_sdk -from sentry_sdk.consts import OP +from sentry_sdk.consts import OP, SPANDATA, SPANSTATUS, SPANTEMPLATE from sentry_sdk.utils import ( capture_internal_exceptions, - Dsn, + filename_for_module, + logger, match_regex_list, + qualname_from_function, + safe_repr, to_string, + try_convert, is_sentry_url, + _is_external_source, + _is_in_project_root, + _module_in_list, ) -from sentry_sdk._compat import PY2, iteritems -from sentry_sdk._types import TYPE_CHECKING -if PY2: - from collections import Mapping - from urllib import quote, unquote -else: - from collections.abc import Mapping - from urllib.parse import quote, unquote +from typing import TYPE_CHECKING if TYPE_CHECKING: - import typing - from typing import Any from typing import Dict from typing import Generator from typing import Optional from typing import Union + from typing import Iterator + from typing import Tuple + + from types import FrameType SENTRY_TRACE_REGEX = re.compile( @@ -38,6 +49,7 @@ "[ \t]*$" # whitespace ) + # This is a normal base64 regex, modified to reflect that fact that we strip the # trailing = or == off base64_stripped = ( @@ -53,23 +65,19 @@ class EnvironHeaders(Mapping): # type: ignore def __init__( self, - environ, # type: typing.Mapping[str, str] - prefix="HTTP_", # type: str - ): - # type: (...) -> None + environ: "Mapping[str, str]", + prefix: str = "HTTP_", + ) -> None: self.environ = environ self.prefix = prefix - def __getitem__(self, key): - # type: (str) -> Optional[Any] + def __getitem__(self, key: str) -> "Optional[Any]": return self.environ[self.prefix + key.replace("-", "_").upper()] - def __len__(self): - # type: () -> int + def __len__(self) -> int: return sum(1 for _ in iter(self)) - def __iter__(self): - # type: () -> Generator[str, None, None] + def __iter__(self) -> "Generator[str, None, None]": for k in self.environ: if not isinstance(k, str): continue @@ -81,8 +89,7 @@ def __iter__(self): yield k[len(self.prefix) :] -def has_tracing_enabled(options): - # type: (Optional[Dict[str, Any]]) -> bool +def has_tracing_enabled(options: "Optional[Dict[str, Any]]") -> bool: """ Returns True if either traces_sample_rate or traces_sampler is defined and enable_tracing is set and not false. @@ -101,20 +108,16 @@ def has_tracing_enabled(options): @contextlib.contextmanager def record_sql_queries( - hub, # type: sentry_sdk.Hub - cursor, # type: Any - query, # type: Any - params_list, # type: Any - paramstyle, # type: Optional[str] - executemany, # type: bool - record_cursor_repr=False, # type: bool -): - # type: (...) -> Generator[sentry_sdk.tracing.Span, None, None] - + cursor: "Any", + query: "Any", + params_list: "Any", + paramstyle: "Optional[str]", + executemany: bool, + record_cursor_repr: bool = False, + span_origin: str = "manual", +) -> "Generator[sentry_sdk.tracing.Span, None, None]": # TODO: Bring back capturing of params by default - if hub.client and hub.client.options["_experiments"].get( - "record_sql_params", False - ): + if sentry_sdk.get_client().options["_experiments"].get("record_sql_params", False): if not params_list or params_list == [None]: params_list = None @@ -137,24 +140,44 @@ def record_sql_queries( data["db.cursor"] = cursor with capture_internal_exceptions(): - hub.add_breadcrumb(message=query, category="query", data=data) + sentry_sdk.add_breadcrumb(message=query, category="query", data=data) - with hub.start_span(op=OP.DB, description=query) as span: + with sentry_sdk.start_span( + op=OP.DB, + name=query, + origin=span_origin, + ) as span: for k, v in data.items(): span.set_data(k, v) yield span -def maybe_create_breadcrumbs_from_span(hub, span): - # type: (sentry_sdk.Hub, sentry_sdk.tracing.Span) -> None +def maybe_create_breadcrumbs_from_span( + scope: "sentry_sdk.Scope", span: "sentry_sdk.tracing.Span" +) -> None: if span.op == OP.DB_REDIS: - hub.add_breadcrumb( + scope.add_breadcrumb( message=span.description, type="redis", category="redis", data=span._tags ) + elif span.op == OP.HTTP_CLIENT: - hub.add_breadcrumb(type="http", category="httplib", data=span._data) + level = None + status_code = span._data.get(SPANDATA.HTTP_STATUS_CODE) + if status_code: + if 500 <= status_code <= 599: + level = "error" + elif 400 <= status_code <= 499: + level = "warning" + + if level: + scope.add_breadcrumb( + type="http", category="httplib", data=span._data, level=level + ) + else: + scope.add_breadcrumb(type="http", category="httplib", data=span._data) + elif span.op == "subprocess": - hub.add_breadcrumb( + scope.add_breadcrumb( type="subprocess", category="subprocess", message=span.description, @@ -162,8 +185,168 @@ def maybe_create_breadcrumbs_from_span(hub, span): ) -def extract_sentrytrace_data(header): - # type: (Optional[str]) -> Optional[Dict[str, Union[str, bool, None]]] +def _get_frame_module_abs_path(frame: "FrameType") -> "Optional[str]": + try: + return frame.f_code.co_filename + except Exception: + return None + + +def _should_be_included( + is_sentry_sdk_frame: bool, + namespace: "Optional[str]", + in_app_include: "Optional[list[str]]", + in_app_exclude: "Optional[list[str]]", + abs_path: "Optional[str]", + project_root: "Optional[str]", +) -> bool: + # in_app_include takes precedence over in_app_exclude + should_be_included = _module_in_list(namespace, in_app_include) + should_be_excluded = _is_external_source(abs_path) or _module_in_list( + namespace, in_app_exclude + ) + return not is_sentry_sdk_frame and ( + should_be_included + or (_is_in_project_root(abs_path, project_root) and not should_be_excluded) + ) + + +def add_source( + span: "sentry_sdk.tracing.Span", + project_root: "Optional[str]", + in_app_include: "Optional[list[str]]", + in_app_exclude: "Optional[list[str]]", +) -> None: + """ + Adds OTel compatible source code information to the span + """ + # Find the correct frame + frame: "Union[FrameType, None]" = sys._getframe() + while frame is not None: + abs_path = _get_frame_module_abs_path(frame) + + try: + namespace: "Optional[str]" = frame.f_globals.get("__name__") + except Exception: + namespace = None + + is_sentry_sdk_frame = namespace is not None and namespace.startswith( + "sentry_sdk." + ) + + should_be_included = _should_be_included( + is_sentry_sdk_frame=is_sentry_sdk_frame, + namespace=namespace, + in_app_include=in_app_include, + in_app_exclude=in_app_exclude, + abs_path=abs_path, + project_root=project_root, + ) + if should_be_included: + break + + frame = frame.f_back + else: + frame = None + + # Set the data + if frame is not None: + try: + lineno = frame.f_lineno + except Exception: + lineno = None + if lineno is not None: + span.set_data(SPANDATA.CODE_LINENO, frame.f_lineno) + + try: + namespace = frame.f_globals.get("__name__") + except Exception: + namespace = None + if namespace is not None: + span.set_data(SPANDATA.CODE_NAMESPACE, namespace) + + filepath = _get_frame_module_abs_path(frame) + if filepath is not None: + if namespace is not None: + in_app_path = filename_for_module(namespace, filepath) + elif project_root is not None and filepath.startswith(project_root): + in_app_path = filepath.replace(project_root, "").lstrip(os.sep) + else: + in_app_path = filepath + span.set_data(SPANDATA.CODE_FILEPATH, in_app_path) + + try: + code_function = frame.f_code.co_name + except Exception: + code_function = None + + if code_function is not None: + span.set_data(SPANDATA.CODE_FUNCTION, frame.f_code.co_name) + + +def add_query_source(span: "sentry_sdk.tracing.Span") -> None: + """ + Adds OTel compatible source code information to a database query span + """ + client = sentry_sdk.get_client() + if not client.is_active(): + return + + if span.timestamp is None or span.start_timestamp is None: + return + + should_add_query_source = client.options.get("enable_db_query_source", True) + if not should_add_query_source: + return + + duration = span.timestamp - span.start_timestamp + threshold = client.options.get("db_query_source_threshold_ms", 0) + slow_query = duration / timedelta(milliseconds=1) > threshold + + if not slow_query: + return + + add_source( + span=span, + project_root=client.options["project_root"], + in_app_include=client.options.get("in_app_include"), + in_app_exclude=client.options.get("in_app_exclude"), + ) + + +def add_http_request_source(span: "sentry_sdk.tracing.Span") -> None: + """ + Adds OTel compatible source code information to a span for an outgoing HTTP request + """ + client = sentry_sdk.get_client() + if not client.is_active(): + return + + if span.timestamp is None or span.start_timestamp is None: + return + + should_add_request_source = client.options.get("enable_http_request_source", True) + if not should_add_request_source: + return + + duration = span.timestamp - span.start_timestamp + threshold = client.options.get("http_request_source_threshold_ms", 0) + slow_query = duration / timedelta(milliseconds=1) > threshold + + if not slow_query: + return + + add_source( + span=span, + project_root=client.options["project_root"], + in_app_include=client.options.get("in_app_include"), + in_app_exclude=client.options.get("in_app_exclude"), + ) + + +def extract_sentrytrace_data( + header: "Optional[str]", +) -> "Optional[Dict[str, Union[str, bool, None]]]": """ Given a `sentry-trace` header string, return a dictionary of data. """ @@ -194,9 +377,7 @@ def extract_sentrytrace_data(header): } -def _format_sql(cursor, sql): - # type: (Any, str) -> Optional[str] - +def _format_sql(cursor: "Any", sql: str) -> "Optional[str]": real_sql = None # If we're using psycopg2, it could be that we're @@ -214,9 +395,208 @@ def _format_sql(cursor, sql): return real_sql or to_string(sql) -class Baggage(object): +class PropagationContext: + """ + The PropagationContext represents the data of a trace in Sentry. + """ + + __slots__ = ( + "_trace_id", + "_span_id", + "parent_span_id", + "parent_sampled", + "baggage", + ) + + def __init__( + self, + trace_id: "Optional[str]" = None, + span_id: "Optional[str]" = None, + parent_span_id: "Optional[str]" = None, + parent_sampled: "Optional[bool]" = None, + dynamic_sampling_context: "Optional[Dict[str, str]]" = None, + baggage: "Optional[Baggage]" = None, + ) -> None: + self._trace_id = trace_id + """The trace id of the Sentry trace.""" + + self._span_id = span_id + """The span id of the currently executing span.""" + + self.parent_span_id = parent_span_id + """The id of the parent span that started this span. + The parent span could also be a span in an upstream service.""" + + self.parent_sampled = parent_sampled + """Boolean indicator if the parent span was sampled. + Important when the parent span originated in an upstream service, + because we want to sample the whole trace, or nothing from the trace.""" + + self.baggage = baggage + """Parsed baggage header that is used for dynamic sampling decisions.""" + + """DEPRECATED this only exists for backwards compat of constructor.""" + if baggage is None and dynamic_sampling_context is not None: + self.baggage = Baggage(dynamic_sampling_context) + + @classmethod + def from_incoming_data( + cls, incoming_data: "Dict[str, Any]" + ) -> "PropagationContext": + propagation_context = PropagationContext() + normalized_data = normalize_incoming_data(incoming_data) + + sentry_trace_header = normalized_data.get(SENTRY_TRACE_HEADER_NAME) + sentrytrace_data = extract_sentrytrace_data(sentry_trace_header) + + # nothing to propagate if no sentry-trace + if sentrytrace_data is None: + return propagation_context + + baggage_header = normalized_data.get(BAGGAGE_HEADER_NAME) + baggage = ( + Baggage.from_incoming_header(baggage_header) if baggage_header else None + ) + + if not _should_continue_trace(baggage): + return propagation_context + + propagation_context.update(sentrytrace_data) + if baggage: + propagation_context.baggage = baggage + + propagation_context._fill_sample_rand() + + return propagation_context + + @property + def trace_id(self) -> str: + """The trace id of the Sentry trace.""" + if not self._trace_id: + # New trace, don't fill in sample_rand + self._trace_id = uuid.uuid4().hex + + return self._trace_id + + @trace_id.setter + def trace_id(self, value: str) -> None: + self._trace_id = value + + @property + def span_id(self) -> str: + """The span id of the currently executed span.""" + if not self._span_id: + self._span_id = uuid.uuid4().hex[16:] + + return self._span_id + + @span_id.setter + def span_id(self, value: str) -> None: + self._span_id = value + + @property + def dynamic_sampling_context(self) -> "Optional[Dict[str, Any]]": + return self.get_baggage().dynamic_sampling_context() + + def to_traceparent(self) -> str: + return f"{self.trace_id}-{self.span_id}" + + def get_baggage(self) -> "Baggage": + if self.baggage is None: + self.baggage = Baggage.populate_from_propagation_context(self) + return self.baggage + + def iter_headers(self) -> "Iterator[Tuple[str, str]]": + """ + Creates a generator which returns the propagation_context's ``sentry-trace`` and ``baggage`` headers. + """ + yield SENTRY_TRACE_HEADER_NAME, self.to_traceparent() + + baggage = self.get_baggage().serialize() + if baggage: + yield BAGGAGE_HEADER_NAME, baggage + + def update(self, other_dict: "Dict[str, Any]") -> None: + """ + Updates the PropagationContext with data from the given dictionary. + """ + for key, value in other_dict.items(): + try: + setattr(self, key, value) + except AttributeError: + pass + + def __repr__(self) -> str: + return "".format( + self._trace_id, + self._span_id, + self.parent_span_id, + self.parent_sampled, + self.baggage, + ) + + def _fill_sample_rand(self) -> None: + """ + Ensure that there is a valid sample_rand value in the baggage. + + If there is a valid sample_rand value in the baggage, we keep it. + Otherwise, we generate a sample_rand value according to the following: + + - If we have a parent_sampled value and a sample_rate in the DSC, we compute + a sample_rand value randomly in the range: + - [0, sample_rate) if parent_sampled is True, + - or, in the range [sample_rate, 1) if parent_sampled is False. + + - If either parent_sampled or sample_rate is missing, we generate a random + value in the range [0, 1). + + The sample_rand is deterministically generated from the trace_id, if present. + + This function does nothing if there is no baggage. + """ + if self.baggage is None: + return + + sample_rand = try_convert(float, self.baggage.sentry_items.get("sample_rand")) + if sample_rand is not None and 0 <= sample_rand < 1: + # sample_rand is present and valid, so don't overwrite it + return + + # Get the sample rate and compute the transformation that will map the random value + # to the desired range: [0, 1), [0, sample_rate), or [sample_rate, 1). + sample_rate = try_convert(float, self.baggage.sentry_items.get("sample_rate")) + lower, upper = _sample_rand_range(self.parent_sampled, sample_rate) + + try: + sample_rand = _generate_sample_rand(self.trace_id, interval=(lower, upper)) + except ValueError: + # ValueError is raised if the interval is invalid, i.e. lower >= upper. + # lower >= upper might happen if the incoming trace's sampled flag + # and sample_rate are inconsistent, e.g. sample_rate=0.0 but sampled=True. + # We cannot generate a sensible sample_rand value in this case. + logger.debug( + f"Could not backfill sample_rand, since parent_sampled={self.parent_sampled} " + f"and sample_rate={sample_rate}." + ) + return + + self.baggage.sentry_items["sample_rand"] = f"{sample_rand:.6f}" # noqa: E231 + + def _sample_rand(self) -> "Optional[str]": + """Convenience method to get the sample_rand value from the baggage.""" + if self.baggage is None: + return None + + return self.baggage.sentry_items.get("sample_rand") + + +class Baggage: """ The W3C Baggage header information (see https://www.w3.org/TR/baggage/). + + Before mutating a `Baggage` object, calling code must check that `mutable` is `True`. + Mutating a `Baggage` object that has `mutable` set to `False` is not allowed, but + it is the caller's responsibility to enforce this restriction. """ __slots__ = ("sentry_items", "third_party_items", "mutable") @@ -226,17 +606,21 @@ class Baggage(object): def __init__( self, - sentry_items, # type: Dict[str, str] - third_party_items="", # type: str - mutable=True, # type: bool + sentry_items: "Dict[str, str]", + third_party_items: str = "", + mutable: bool = True, ): self.sentry_items = sentry_items self.third_party_items = third_party_items self.mutable = mutable @classmethod - def from_incoming_header(cls, header): - # type: (Optional[str]) -> Baggage + def from_incoming_header( + cls, + header: "Optional[str]", + *, + _sample_rand: "Optional[str]" = None, + ) -> "Baggage": """ freeze if incoming header already has sentry baggage """ @@ -259,26 +643,38 @@ def from_incoming_header(cls, header): else: third_party_items += ("," if third_party_items else "") + item + if _sample_rand is not None: + sentry_items["sample_rand"] = str(_sample_rand) + mutable = False + return Baggage(sentry_items, third_party_items, mutable) @classmethod - def from_options(cls, scope): - # type: (sentry_sdk.scope.Scope) -> Optional[Baggage] + def from_options(cls, scope: "sentry_sdk.scope.Scope") -> "Optional[Baggage]": + """ + Deprecated: use populate_from_propagation_context + """ + if scope._propagation_context is None: + return Baggage({}) - sentry_items = {} # type: Dict[str, str] + return Baggage.populate_from_propagation_context(scope._propagation_context) + + @classmethod + def populate_from_propagation_context( + cls, propagation_context: "PropagationContext" + ) -> "Baggage": + sentry_items: "Dict[str, str]" = {} third_party_items = "" mutable = False - client = sentry_sdk.Hub.current.client + client = sentry_sdk.get_client() - if client is None or scope._propagation_context is None: + if not client.is_active(): return Baggage(sentry_items) options = client.options - propagation_context = scope._propagation_context - if propagation_context is not None and "trace_id" in propagation_context: - sentry_items["trace_id"] = propagation_context["trace_id"] + sentry_items["trace_id"] = propagation_context.trace_id if options.get("environment"): sentry_items["environment"] = options["environment"] @@ -286,36 +682,34 @@ def from_options(cls, scope): if options.get("release"): sentry_items["release"] = options["release"] - if options.get("dsn"): - sentry_items["public_key"] = Dsn(options["dsn"]).public_key + if client.parsed_dsn: + sentry_items["public_key"] = client.parsed_dsn.public_key + if client.parsed_dsn.org_id: + sentry_items["org_id"] = client.parsed_dsn.org_id if options.get("traces_sample_rate"): - sentry_items["sample_rate"] = options["traces_sample_rate"] - - user = (scope and scope._user) or {} - if user.get("segment"): - sentry_items["user_segment"] = user["segment"] + sentry_items["sample_rate"] = str(options["traces_sample_rate"]) return Baggage(sentry_items, third_party_items, mutable) @classmethod - def populate_from_transaction(cls, transaction): - # type: (sentry_sdk.tracing.Transaction) -> Baggage + def populate_from_transaction( + cls, transaction: "sentry_sdk.tracing.Transaction" + ) -> "Baggage": """ Populate fresh baggage entry with sentry_items and make it immutable if this is the head SDK which originates traces. """ - hub = transaction.hub or sentry_sdk.Hub.current - client = hub.client - sentry_items = {} # type: Dict[str, str] + client = sentry_sdk.get_client() + sentry_items: "Dict[str, str]" = {} - if not client: + if not client.is_active(): return Baggage(sentry_items) options = client.options or {} - user = (hub.scope and hub.scope._user) or {} sentry_items["trace_id"] = transaction.trace_id + sentry_items["sample_rand"] = f"{transaction._sample_rand:.6f}" # noqa: E231 if options.get("environment"): sentry_items["environment"] = options["environment"] @@ -323,8 +717,10 @@ def populate_from_transaction(cls, transaction): if options.get("release"): sentry_items["release"] = options["release"] - if options.get("dsn"): - sentry_items["public_key"] = Dsn(options["dsn"]).public_key + if client.parsed_dsn: + sentry_items["public_key"] = client.parsed_dsn.public_key + if client.parsed_dsn.org_id: + sentry_items["org_id"] = client.parsed_dsn.org_id if ( transaction.name @@ -332,9 +728,6 @@ def populate_from_transaction(cls, transaction): ): sentry_items["transaction"] = transaction.name - if user.get("segment"): - sentry_items["user_segment"] = user["segment"] - if transaction.sample_rate is not None: sentry_items["sample_rate"] = str(transaction.sample_rate) @@ -349,24 +742,21 @@ def populate_from_transaction(cls, transaction): return Baggage(sentry_items, mutable=False) - def freeze(self): - # type: () -> None + def freeze(self) -> None: self.mutable = False - def dynamic_sampling_context(self): - # type: () -> Dict[str, str] + def dynamic_sampling_context(self) -> "Dict[str, str]": header = {} - for key, item in iteritems(self.sentry_items): + for key, item in self.sentry_items.items(): header[key] = item return header - def serialize(self, include_third_party=False): - # type: (bool) -> str + def serialize(self, include_third_party: bool = False) -> str: items = [] - for key, val in iteritems(self.sentry_items): + for key, val in self.sentry_items.items(): with capture_internal_exceptions(): item = Baggage.SENTRY_PREFIX + quote(key) + "=" + quote(str(val)) items.append(item) @@ -376,23 +766,50 @@ def serialize(self, include_third_party=False): return ",".join(items) + @staticmethod + def strip_sentry_baggage(header: str) -> str: + """Remove Sentry baggage from the given header. + + Given a Baggage header, return a new Baggage header with all Sentry baggage items removed. + """ + return ",".join( + ( + item + for item in header.split(",") + if not Baggage.SENTRY_PREFIX_REGEX.match(item.strip()) + ) + ) + + def _sample_rand(self) -> "Optional[float]": + """Convenience method to get the sample_rand value from the sentry_items. + + We validate the value and parse it as a float before returning it. The value is considered + valid if it is a float in the range [0, 1). + """ + sample_rand = try_convert(float, self.sentry_items.get("sample_rand")) + + if sample_rand is not None and 0.0 <= sample_rand < 1.0: + return sample_rand -def should_propagate_trace(hub, url): - # type: (sentry_sdk.Hub, str) -> bool + return None + + def __repr__(self) -> str: + return f'' + + +def should_propagate_trace(client: "sentry_sdk.client.BaseClient", url: str) -> bool: """ - Returns True if url matches trace_propagation_targets configured in the given hub. Otherwise, returns False. + Returns True if url matches trace_propagation_targets configured in the given client. Otherwise, returns False. """ - client = hub.client # type: Any trace_propagation_targets = client.options["trace_propagation_targets"] - if is_sentry_url(hub, url): + if is_sentry_url(client, url): return False return match_regex_list(url, trace_propagation_targets, substring_matching=True) -def normalize_incoming_data(incoming_data): - # type: (Dict[str, Any]) -> Dict[str, Any] +def normalize_incoming_data(incoming_data: "Dict[str, Any]") -> "Dict[str, Any]": """ Normalizes incoming data so the keys are all lowercase with dashes instead of underscores and stripped from known prefixes. """ @@ -407,5 +824,473 @@ def normalize_incoming_data(incoming_data): return data +def create_span_decorator( + op: "Optional[Union[str, OP]]" = None, + name: "Optional[str]" = None, + attributes: "Optional[dict[str, Any]]" = None, + template: "SPANTEMPLATE" = SPANTEMPLATE.DEFAULT, +) -> "Any": + """ + Create a span decorator that can wrap both sync and async functions. + + :param op: The operation type for the span. + :type op: str or :py:class:`sentry_sdk.consts.OP` or None + :param name: The name of the span. + :type name: str or None + :param attributes: Additional attributes to set on the span. + :type attributes: dict or None + :param template: The type of span to create. This determines what kind of + span instrumentation and data collection will be applied. Use predefined + constants from :py:class:`sentry_sdk.consts.SPANTEMPLATE`. + The default is `SPANTEMPLATE.DEFAULT` which is the right choice for most + use cases. + :type template: :py:class:`sentry_sdk.consts.SPANTEMPLATE` + """ + from sentry_sdk.scope import should_send_default_pii + + def span_decorator(f: "Any") -> "Any": + """ + Decorator to create a span for the given function. + """ + + @functools.wraps(f) + async def async_wrapper(*args: "Any", **kwargs: "Any") -> "Any": + current_span = get_current_span() + + if current_span is None: + logger.debug( + "Cannot create a child span for %s. " + "Please start a Sentry transaction before calling this function.", + qualname_from_function(f), + ) + return await f(*args, **kwargs) + + span_op = op or _get_span_op(template) + function_name = name or qualname_from_function(f) or "" + span_name = _get_span_name(template, function_name, kwargs) + send_pii = should_send_default_pii() + + with current_span.start_child( + op=span_op, + name=span_name, + ) as span: + span.update_data(attributes or {}) + _set_input_attributes( + span, template, send_pii, function_name, f, args, kwargs + ) + + result = await f(*args, **kwargs) + + _set_output_attributes(span, template, send_pii, result) + + return result + + try: + async_wrapper.__signature__ = inspect.signature(f) # type: ignore[attr-defined] + except Exception: + pass + + @functools.wraps(f) + def sync_wrapper(*args: "Any", **kwargs: "Any") -> "Any": + current_span = get_current_span() + + if current_span is None: + logger.debug( + "Cannot create a child span for %s. " + "Please start a Sentry transaction before calling this function.", + qualname_from_function(f), + ) + return f(*args, **kwargs) + + span_op = op or _get_span_op(template) + function_name = name or qualname_from_function(f) or "" + span_name = _get_span_name(template, function_name, kwargs) + send_pii = should_send_default_pii() + + with current_span.start_child( + op=span_op, + name=span_name, + ) as span: + span.update_data(attributes or {}) + _set_input_attributes( + span, template, send_pii, function_name, f, args, kwargs + ) + + result = f(*args, **kwargs) + + _set_output_attributes(span, template, send_pii, result) + + return result + + try: + sync_wrapper.__signature__ = inspect.signature(f) # type: ignore[attr-defined] + except Exception: + pass + + if inspect.iscoroutinefunction(f): + return async_wrapper + else: + return sync_wrapper + + return span_decorator + + +def get_current_span(scope: "Optional[sentry_sdk.Scope]" = None) -> "Optional[Span]": + """ + Returns the currently active span if there is one running, otherwise `None` + """ + scope = scope or sentry_sdk.get_current_scope() + current_span = scope.span + return current_span + + +def set_span_errored(span: "Optional[Span]" = None) -> None: + """ + Set the status of the current or given span to INTERNAL_ERROR. + Also sets the status of the transaction (root span) to INTERNAL_ERROR. + """ + span = span or get_current_span() + if span is not None: + span.set_status(SPANSTATUS.INTERNAL_ERROR) + if span.containing_transaction is not None: + span.containing_transaction.set_status(SPANSTATUS.INTERNAL_ERROR) + + +def _generate_sample_rand( + trace_id: "Optional[str]", + *, + interval: "tuple[float, float]" = (0.0, 1.0), +) -> float: + """Generate a sample_rand value from a trace ID. + + The generated value will be pseudorandomly chosen from the provided + interval. Specifically, given (lower, upper) = interval, the generated + value will be in the range [lower, upper). The value has 6-digit precision, + so when printing with .6f, the value will never be rounded up. + + The pseudorandom number generator is seeded with the trace ID. + """ + lower, upper = interval + if not lower < upper: # using `if lower >= upper` would handle NaNs incorrectly + raise ValueError("Invalid interval: lower must be less than upper") + + rng = Random(trace_id) + lower_scaled = int(lower * 1_000_000) + upper_scaled = int(upper * 1_000_000) + try: + sample_rand_scaled = rng.randrange(lower_scaled, upper_scaled) + except ValueError: + # In some corner cases it might happen that the range is too small + # In that case, just take the lower bound + sample_rand_scaled = lower_scaled + + return sample_rand_scaled / 1_000_000 + + +def _sample_rand_range( + parent_sampled: "Optional[bool]", sample_rate: "Optional[float]" +) -> "tuple[float, float]": + """ + Compute the lower (inclusive) and upper (exclusive) bounds of the range of values + that a generated sample_rand value must fall into, given the parent_sampled and + sample_rate values. + """ + if parent_sampled is None or sample_rate is None: + return 0.0, 1.0 + elif parent_sampled is True: + return 0.0, sample_rate + else: # parent_sampled is False + return sample_rate, 1.0 + + +def _get_value(source: "Any", key: str) -> "Optional[Any]": + """ + Gets a value from a source object. The source can be a dict or an object. + It is checked for dictionary keys and object attributes. + """ + value = None + if isinstance(source, dict): + value = source.get(key) + else: + if hasattr(source, key): + try: + value = getattr(source, key) + except Exception: + value = None + return value + + +def _get_span_name( + template: "Union[str, SPANTEMPLATE]", + name: str, + kwargs: "Optional[dict[str, Any]]" = None, +) -> str: + """ + Get the name of the span based on the template and the name. + """ + span_name = name + + if template == SPANTEMPLATE.AI_CHAT: + model = None + if kwargs: + for key in ("model", "model_name"): + if kwargs.get(key) and isinstance(kwargs[key], str): + model = kwargs[key] + break + + span_name = f"chat {model}" if model else "chat" + + elif template == SPANTEMPLATE.AI_AGENT: + span_name = f"invoke_agent {name}" + + elif template == SPANTEMPLATE.AI_TOOL: + span_name = f"execute_tool {name}" + + return span_name + + +def _get_span_op(template: "Union[str, SPANTEMPLATE]") -> str: + """ + Get the operation of the span based on the template. + """ + mapping: "dict[Union[str, SPANTEMPLATE], Union[str, OP]]" = { + SPANTEMPLATE.AI_CHAT: OP.GEN_AI_CHAT, + SPANTEMPLATE.AI_AGENT: OP.GEN_AI_INVOKE_AGENT, + SPANTEMPLATE.AI_TOOL: OP.GEN_AI_EXECUTE_TOOL, + } + op = mapping.get(template, OP.FUNCTION) + + return str(op) + + +def _get_input_attributes( + template: "Union[str, SPANTEMPLATE]", + send_pii: bool, + args: "tuple[Any, ...]", + kwargs: "dict[str, Any]", +) -> "dict[str, Any]": + """ + Get input attributes for the given span template. + """ + attributes: "dict[str, Any]" = {} + + if template in [SPANTEMPLATE.AI_AGENT, SPANTEMPLATE.AI_TOOL, SPANTEMPLATE.AI_CHAT]: + mapping = { + "model": (SPANDATA.GEN_AI_REQUEST_MODEL, str), + "model_name": (SPANDATA.GEN_AI_REQUEST_MODEL, str), + "agent": (SPANDATA.GEN_AI_AGENT_NAME, str), + "agent_name": (SPANDATA.GEN_AI_AGENT_NAME, str), + "max_tokens": (SPANDATA.GEN_AI_REQUEST_MAX_TOKENS, int), + "frequency_penalty": (SPANDATA.GEN_AI_REQUEST_FREQUENCY_PENALTY, float), + "presence_penalty": (SPANDATA.GEN_AI_REQUEST_PRESENCE_PENALTY, float), + "temperature": (SPANDATA.GEN_AI_REQUEST_TEMPERATURE, float), + "top_p": (SPANDATA.GEN_AI_REQUEST_TOP_P, float), + "top_k": (SPANDATA.GEN_AI_REQUEST_TOP_K, int), + } + + def _set_from_key(key: str, value: "Any") -> None: + if key in mapping: + (attribute, data_type) = mapping[key] + if value is not None and isinstance(value, data_type): + attributes[attribute] = value + + for key, value in list(kwargs.items()): + if key == "prompt" and isinstance(value, str): + attributes.setdefault(SPANDATA.GEN_AI_REQUEST_MESSAGES, []).append( + {"role": "user", "content": value} + ) + continue + + if key == "system_prompt" and isinstance(value, str): + attributes.setdefault(SPANDATA.GEN_AI_REQUEST_MESSAGES, []).append( + {"role": "system", "content": value} + ) + continue + + _set_from_key(key, value) + + if template == SPANTEMPLATE.AI_TOOL and send_pii: + attributes[SPANDATA.GEN_AI_TOOL_INPUT] = safe_repr( + {"args": args, "kwargs": kwargs} + ) + + # Coerce to string + if SPANDATA.GEN_AI_REQUEST_MESSAGES in attributes: + attributes[SPANDATA.GEN_AI_REQUEST_MESSAGES] = safe_repr( + attributes[SPANDATA.GEN_AI_REQUEST_MESSAGES] + ) + + return attributes + + +def _get_usage_attributes(usage: "Any") -> "dict[str, Any]": + """ + Get usage attributes. + """ + attributes = {} + + def _set_from_keys(attribute: str, keys: "tuple[str, ...]") -> None: + for key in keys: + value = _get_value(usage, key) + if value is not None and isinstance(value, int): + attributes[attribute] = value + + _set_from_keys( + SPANDATA.GEN_AI_USAGE_INPUT_TOKENS, + ("prompt_tokens", "input_tokens"), + ) + _set_from_keys( + SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS, + ("completion_tokens", "output_tokens"), + ) + _set_from_keys( + SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS, + ("total_tokens",), + ) + + return attributes + + +def _get_output_attributes( + template: "Union[str, SPANTEMPLATE]", send_pii: bool, result: "Any" +) -> "dict[str, Any]": + """ + Get output attributes for the given span template. + """ + attributes: "dict[str, Any]" = {} + + if template in [SPANTEMPLATE.AI_AGENT, SPANTEMPLATE.AI_TOOL, SPANTEMPLATE.AI_CHAT]: + with capture_internal_exceptions(): + # Usage from result, result.usage, and result.metadata.usage + usage_candidates = [result] + + usage = _get_value(result, "usage") + usage_candidates.append(usage) + + meta = _get_value(result, "metadata") + usage = _get_value(meta, "usage") + usage_candidates.append(usage) + + for usage_candidate in usage_candidates: + if usage_candidate is not None: + attributes.update(_get_usage_attributes(usage_candidate)) + + # Response model + model_name = _get_value(result, "model") + if model_name is not None and isinstance(model_name, str): + attributes[SPANDATA.GEN_AI_RESPONSE_MODEL] = model_name + + model_name = _get_value(result, "model_name") + if model_name is not None and isinstance(model_name, str): + attributes[SPANDATA.GEN_AI_RESPONSE_MODEL] = model_name + + # Tool output + if template == SPANTEMPLATE.AI_TOOL and send_pii: + attributes[SPANDATA.GEN_AI_TOOL_OUTPUT] = safe_repr(result) + + return attributes + + +def _set_input_attributes( + span: "Span", + template: "Union[str, SPANTEMPLATE]", + send_pii: bool, + name: str, + f: "Any", + args: "tuple[Any, ...]", + kwargs: "dict[str, Any]", +) -> None: + """ + Set span input attributes based on the given span template. + + :param span: The span to set attributes on. + :param template: The template to use to set attributes on the span. + :param send_pii: Whether to send PII data. + :param f: The wrapped function. + :param args: The arguments to the wrapped function. + :param kwargs: The keyword arguments to the wrapped function. + """ + attributes: "dict[str, Any]" = {} + + if template == SPANTEMPLATE.AI_AGENT: + attributes = { + SPANDATA.GEN_AI_OPERATION_NAME: "invoke_agent", + SPANDATA.GEN_AI_AGENT_NAME: name, + } + elif template == SPANTEMPLATE.AI_CHAT: + attributes = { + SPANDATA.GEN_AI_OPERATION_NAME: "chat", + } + elif template == SPANTEMPLATE.AI_TOOL: + attributes = { + SPANDATA.GEN_AI_OPERATION_NAME: "execute_tool", + SPANDATA.GEN_AI_TOOL_NAME: name, + } + + docstring = f.__doc__ + if docstring is not None: + attributes[SPANDATA.GEN_AI_TOOL_DESCRIPTION] = docstring + + attributes.update(_get_input_attributes(template, send_pii, args, kwargs)) + span.update_data(attributes or {}) + + +def _set_output_attributes( + span: "Span", template: "Union[str, SPANTEMPLATE]", send_pii: bool, result: "Any" +) -> None: + """ + Set span output attributes based on the given span template. + + :param span: The span to set attributes on. + :param template: The template to use to set attributes on the span. + :param send_pii: Whether to send PII data. + :param result: The result of the wrapped function. + """ + span.update_data(_get_output_attributes(template, send_pii, result) or {}) + + +def _should_continue_trace(baggage: "Optional[Baggage]") -> bool: + """ + Check if we should continue the incoming trace according to the strict_trace_continuation spec. + https://develop.sentry.dev/sdk/telemetry/traces/#stricttracecontinuation + """ + + client = sentry_sdk.get_client() + parsed_dsn = client.parsed_dsn + client_org_id = parsed_dsn.org_id if parsed_dsn else None + baggage_org_id = baggage.sentry_items.get("org_id") if baggage else None + + if ( + client_org_id is not None + and baggage_org_id is not None + and client_org_id != baggage_org_id + ): + logger.debug( + f"Starting a new trace because org IDs don't match (incoming baggage org_id: {baggage_org_id}, SDK org_id: {client_org_id})" + ) + return False + + strict_trace_continuation: bool = client.options.get( + "strict_trace_continuation", False + ) + if strict_trace_continuation: + if (baggage_org_id is not None and client_org_id is None) or ( + baggage_org_id is None and client_org_id is not None + ): + logger.debug( + f"Starting a new trace because strict trace continuation is enabled and one org ID is missing (incoming baggage org_id: {baggage_org_id}, SDK org_id: {client_org_id})" + ) + return False + + return True + + # Circular imports -from sentry_sdk.tracing import LOW_QUALITY_TRANSACTION_SOURCES +from sentry_sdk.tracing import ( + BAGGAGE_HEADER_NAME, + LOW_QUALITY_TRANSACTION_SOURCES, + SENTRY_TRACE_HEADER_NAME, +) + +if TYPE_CHECKING: + from sentry_sdk.tracing import Span diff --git a/sentry_sdk/tracing_utils_py2.py b/sentry_sdk/tracing_utils_py2.py deleted file mode 100644 index a251ab41be..0000000000 --- a/sentry_sdk/tracing_utils_py2.py +++ /dev/null @@ -1,45 +0,0 @@ -from functools import wraps - -import sentry_sdk -from sentry_sdk import get_current_span -from sentry_sdk._types import TYPE_CHECKING -from sentry_sdk.consts import OP -from sentry_sdk.utils import logger, qualname_from_function - - -if TYPE_CHECKING: - from typing import Any - - -def start_child_span_decorator(func): - # type: (Any) -> Any - """ - Decorator to add child spans for functions. - - This is the Python 2 compatible version of the decorator. - Duplicated code from ``sentry_sdk.tracing_utils_python3.start_child_span_decorator``. - - See also ``sentry_sdk.tracing.trace()``. - """ - - @wraps(func) - def func_with_tracing(*args, **kwargs): - # type: (*Any, **Any) -> Any - - span = get_current_span(sentry_sdk.Hub.current) - - if span is None: - logger.warning( - "Can not create a child span for %s. " - "Please start a Sentry transaction before calling this function.", - qualname_from_function(func), - ) - return func(*args, **kwargs) - - with span.start_child( - op=OP.FUNCTION, - description=qualname_from_function(func), - ): - return func(*args, **kwargs) - - return func_with_tracing diff --git a/sentry_sdk/tracing_utils_py3.py b/sentry_sdk/tracing_utils_py3.py deleted file mode 100644 index d58d5f7cb4..0000000000 --- a/sentry_sdk/tracing_utils_py3.py +++ /dev/null @@ -1,72 +0,0 @@ -import inspect -from functools import wraps - -import sentry_sdk -from sentry_sdk import get_current_span -from sentry_sdk._types import TYPE_CHECKING -from sentry_sdk.consts import OP -from sentry_sdk.utils import logger, qualname_from_function - - -if TYPE_CHECKING: - from typing import Any - - -def start_child_span_decorator(func): - # type: (Any) -> Any - """ - Decorator to add child spans for functions. - - This is the Python 3 compatible version of the decorator. - For Python 2 there is duplicated code here: ``sentry_sdk.tracing_utils_python2.start_child_span_decorator()``. - - See also ``sentry_sdk.tracing.trace()``. - """ - - # Asynchronous case - if inspect.iscoroutinefunction(func): - - @wraps(func) - async def func_with_tracing(*args, **kwargs): - # type: (*Any, **Any) -> Any - - span = get_current_span(sentry_sdk.Hub.current) - - if span is None: - logger.warning( - "Can not create a child span for %s. " - "Please start a Sentry transaction before calling this function.", - qualname_from_function(func), - ) - return await func(*args, **kwargs) - - with span.start_child( - op=OP.FUNCTION, - description=qualname_from_function(func), - ): - return await func(*args, **kwargs) - - # Synchronous case - else: - - @wraps(func) - def func_with_tracing(*args, **kwargs): - # type: (*Any, **Any) -> Any - - span = get_current_span(sentry_sdk.Hub.current) - - if span is None: - logger.warning( - "Can not create a child span for %s. " - "Please start a Sentry transaction before calling this function.", - qualname_from_function(func), - ) - return func(*args, **kwargs) - - with span.start_child( - op=OP.FUNCTION, - description=qualname_from_function(func), - ): - return func(*args, **kwargs) - - return func_with_tracing diff --git a/sentry_sdk/transport.py b/sentry_sdk/transport.py index 12343fed0b..cee4fa882b 100644 --- a/sentry_sdk/transport.py +++ b/sentry_sdk/transport.py @@ -1,207 +1,291 @@ -from __future__ import print_function - +from abc import ABC, abstractmethod import io -import urllib3 -import certifi +import os import gzip +import socket +import ssl import time - -from datetime import timedelta +import warnings +from datetime import datetime, timedelta, timezone from collections import defaultdict +from urllib.request import getproxies + +try: + import brotli # type: ignore +except ImportError: + brotli = None + +import urllib3 +import certifi -from sentry_sdk.utils import Dsn, logger, capture_internal_exceptions, json_dumps +import sentry_sdk +from sentry_sdk.consts import EndpointType +from sentry_sdk.utils import Dsn, logger, capture_internal_exceptions from sentry_sdk.worker import BackgroundWorker from sentry_sdk.envelope import Envelope, Item, PayloadRef -from sentry_sdk._compat import datetime_utcnow -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING, cast, List, Dict if TYPE_CHECKING: - from datetime import datetime from typing import Any from typing import Callable - from typing import Dict + from typing import DefaultDict from typing import Iterable + from typing import Mapping from typing import Optional + from typing import Self 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 - from sentry_sdk._types import Event, EndpointType - - DataCategory = Optional[str] - -try: - from urllib.request import getproxies -except ImportError: - from urllib import getproxies # type: ignore + from sentry_sdk._types import Event, EventDataCategory + +KEEP_ALIVE_SOCKET_OPTIONS = [] +for option in [ + (socket.SOL_SOCKET, lambda: getattr(socket, "SO_KEEPALIVE"), 1), # noqa: B009 + (socket.SOL_TCP, lambda: getattr(socket, "TCP_KEEPIDLE"), 45), # noqa: B009 + (socket.SOL_TCP, lambda: getattr(socket, "TCP_KEEPINTVL"), 10), # noqa: B009 + (socket.SOL_TCP, lambda: getattr(socket, "TCP_KEEPCNT"), 6), # noqa: B009 +]: + try: + KEEP_ALIVE_SOCKET_OPTIONS.append((option[0], option[1](), option[2])) + except AttributeError: + # a specific option might not be available on specific systems, + # e.g. TCP_KEEPIDLE doesn't exist on macOS + pass -class Transport(object): +class Transport(ABC): """Baseclass for all transports. A transport is used to send an event to sentry. """ - parsed_dsn = None # type: Optional[Dsn] + parsed_dsn: "Optional[Dsn]" = None - def __init__( - self, options=None # type: Optional[Dict[str, Any]] - ): - # type: (...) -> None + def __init__(self: "Self", options: "Optional[Dict[str, Any]]" = None) -> None: self.options = options if options and options["dsn"] is not None and options["dsn"]: - self.parsed_dsn = Dsn(options["dsn"]) + self.parsed_dsn = Dsn(options["dsn"], options.get("org_id")) else: self.parsed_dsn = None - def capture_event( - self, event # type: Event - ): - # type: (...) -> None + def capture_event(self: "Self", event: "Event") -> None: """ + DEPRECATED: Please use capture_envelope instead. + This gets invoked with the event dictionary when an event should be sent to sentry. """ - raise NotImplementedError() - def capture_envelope( - self, envelope # type: Envelope - ): - # type: (...) -> None + warnings.warn( + "capture_event is deprecated, please use capture_envelope instead!", + DeprecationWarning, + stacklevel=2, + ) + + envelope = Envelope() + envelope.add_event(event) + self.capture_envelope(envelope) + + @abstractmethod + def capture_envelope(self: "Self", envelope: "Envelope") -> None: """ Send an envelope to Sentry. Envelopes are a data container format that can hold any type of data - submitted to Sentry. We use it for transactions and sessions, but - regular "error" events should go through `capture_event` for backwards - compat. + submitted to Sentry. We use it to send all event data (including errors, + transactions, crons check-ins, etc.) to Sentry. """ - raise NotImplementedError() + pass def flush( - self, - timeout, # type: float - callback=None, # type: Optional[Any] - ): - # type: (...) -> None - """Wait `timeout` seconds for the current events to be sent out.""" - pass + self: "Self", + timeout: float, + callback: "Optional[Any]" = None, + ) -> None: + """ + Wait `timeout` seconds for the current events to be sent out. - def kill(self): - # type: () -> None - """Forcefully kills the transport.""" - pass + The default implementation is a no-op, since this method may only be relevant to some transports. + Subclasses should override this method if necessary. + """ + return None + + def kill(self: "Self") -> None: + """ + Forcefully kills the transport. + + The default implementation is a no-op, since this method may only be relevant to some transports. + Subclasses should override this method if necessary. + """ + return None def record_lost_event( self, - reason, # type: str - data_category=None, # type: Optional[str] - item=None, # type: Optional[Item] - ): - # type: (...) -> None + reason: str, + data_category: "Optional[EventDataCategory]" = None, + item: "Optional[Item]" = None, + *, + quantity: int = 1, + ) -> None: """This increments a counter for event loss by reason and - data category. + data category by the given positive-int quantity (default 1). + + If an item is provided, the data category and quantity are + extracted from the item, and the values passed for + data_category and quantity are ignored. + + When recording a lost transaction via data_category="transaction", + the calling code should also record the lost spans via this method. + When recording lost spans, `quantity` should be set to the number + of contained spans, plus one for the transaction itself. When + passing an Item containing a transaction via the `item` parameter, + this method automatically records the lost spans. """ return None - def is_healthy(self): - # type: () -> bool + def is_healthy(self: "Self") -> bool: return True - def __del__(self): - # type: () -> None - try: - self.kill() - except Exception: - pass - -def _parse_rate_limits(header, now=None): - # type: (Any, Optional[datetime]) -> Iterable[Tuple[DataCategory, datetime]] +def _parse_rate_limits( + header: str, now: "Optional[datetime]" = None +) -> "Iterable[Tuple[Optional[EventDataCategory], datetime]]": if now is None: - now = datetime_utcnow() + now = datetime.now(timezone.utc) for limit in header.split(","): try: - retry_after, categories, _ = limit.strip().split(":", 2) - retry_after = now + timedelta(seconds=int(retry_after)) + parameters = limit.strip().split(":") + retry_after_val, categories = parameters[:2] + + retry_after = now + timedelta(seconds=int(retry_after_val)) for category in categories and categories.split(";") or (None,): - yield category, retry_after + yield category, retry_after # type: ignore except (LookupError, ValueError): continue -class HttpTransport(Transport): - """The default HTTP transport.""" +class BaseHttpTransport(Transport): + """The base HTTP transport.""" - def __init__( - self, options # type: Dict[str, Any] - ): - # type: (...) -> None + TIMEOUT = 30 # seconds + + def __init__(self: "Self", options: "Dict[str, Any]") -> None: from sentry_sdk.consts import VERSION Transport.__init__(self, options) assert self.parsed_dsn is not None - self.options = options # type: Dict[str, Any] + self.options: "Dict[str, Any]" = options 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[DataCategory, datetime] + self._disabled_until: "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[str, str], int] + self._discarded_events: "DefaultDict[Tuple[EventDataCategory, str], int]" = ( + defaultdict(int) + ) self._last_client_report_sent = time.time() - self._pool = self._make_pool( - self.parsed_dsn, - http_proxy=options["http_proxy"], - https_proxy=options["https_proxy"], - ca_certs=options["ca_certs"], - proxy_headers=options["proxy_headers"], - ) + self._pool = self._make_pool() + + # Backwards compatibility for deprecated `self.hub_class` attribute + self._hub_cls = sentry_sdk.Hub - compresslevel = options.get("_experiments", {}).get( - "transport_zlib_compression_level" + experiments = options.get("_experiments", {}) + compression_level = experiments.get( + "transport_compression_level", + experiments.get("transport_zlib_compression_level"), + ) + compression_algo = experiments.get( + "transport_compression_algo", + ( + "gzip" + # if only compression level is set, assume gzip for backwards compatibility + # if we don't have brotli available, fallback to gzip + if compression_level is not None or brotli is None + else "br" + ), ) - self._compresslevel = 9 if compresslevel is None else int(compresslevel) - from sentry_sdk import Hub + if compression_algo == "br" and brotli is None: + logger.warning( + "You asked for brotli compression without the Brotli module, falling back to gzip -9" + ) + compression_algo = "gzip" + compression_level = None + + if compression_algo not in ("br", "gzip"): + logger.warning( + "Unknown compression algo %s, disabling compression", compression_algo + ) + self._compression_level = 0 + self._compression_algo = None + else: + self._compression_algo = compression_algo - self.hub_cls = Hub + if compression_level is not None: + self._compression_level = compression_level + elif self._compression_algo == "gzip": + self._compression_level = 9 + elif self._compression_algo == "br": + self._compression_level = 4 def record_lost_event( self, - reason, # type: str - data_category=None, # type: Optional[str] - item=None, # type: Optional[Item] - ): - # type: (...) -> None + reason: str, + data_category: "Optional[EventDataCategory]" = None, + item: "Optional[Item]" = None, + *, + quantity: int = 1, + ) -> None: if not self.options["send_client_reports"]: return - quantity = 1 if item is not None: data_category = item.data_category - if data_category == "attachment": + quantity = 1 # If an item is provided, we always count it as 1 (except for attachments, handled below). + + if data_category == "transaction": + # Also record the lost spans + event = item.get_transaction_event() or {} + + # +1 for the transaction itself + span_count = ( + len(cast(List[Dict[str, object]], event.get("spans") or [])) + 1 + ) + self.record_lost_event(reason, "span", quantity=span_count) + + elif data_category == "log_item" and item: + # Also record size of lost logs in bytes + bytes_size = len(item.get_bytes()) + self.record_lost_event(reason, "log_byte", quantity=bytes_size) + + elif data_category == "attachment": # quantity of 0 is actually 1 as we do not want to count # empty attachments as actually empty. quantity = len(item.get_bytes()) or 1 + elif data_category is None: raise TypeError("data category not provided") self._discarded_events[data_category, reason] += quantity - def _update_rate_limits(self, response): - # type: (urllib3.BaseHTTPResponse) -> None + def _get_header_value( + self: "Self", response: "Any", header: str + ) -> "Optional[str]": + return response.headers.get(header) + def _update_rate_limits( + self: "Self", response: "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)) @@ -211,21 +295,24 @@ 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") - self._disabled_until[None] = datetime_utcnow() + timedelta( - seconds=self._retry.get_retry_after(response) or 60 + 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=retry_after ) def _send_request( - self, - body, # type: bytes - headers, # type: Dict[str, str] - endpoint_type="store", # type: EndpointType - envelope=None, # type: Optional[Envelope] - ): - # type: (...) -> None - - def record_loss(reason): - # type: (str) -> None + self: "Self", + body: bytes, + headers: "Dict[str, str]", + endpoint_type: "EndpointType" = EndpointType.ENVELOPE, + envelope: "Optional[Envelope]" = None, + ) -> None: + def record_loss(reason: str) -> None: if envelope is None: self.record_lost_event(reason, data_category="error") else: @@ -239,11 +326,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") @@ -265,19 +352,19 @@ 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") finally: response.close() - def on_dropped_event(self, reason): - # type: (str) -> None + def on_dropped_event(self: "Self", _reason: str) -> None: return None - def _fetch_pending_client_report(self, force=False, interval=60): - # type: (bool, int) -> Optional[Item] + def _fetch_pending_client_report( + self: "Self", force: bool = False, interval: int = 60 + ) -> "Optional[Item]": if not self.options["send_client_reports"]: return None @@ -307,83 +394,35 @@ def _fetch_pending_client_report(self, force=False, interval=60): type="client_report", ) - def _flush_client_reports(self, force=False): - # type: (bool) -> None + def _flush_client_reports(self: "Self", force: bool = False) -> None: client_report = self._fetch_pending_client_report(force=force, interval=60) if client_report is not None: self.capture_envelope(Envelope(items=[client_report])) - def _check_disabled(self, category): - # type: (str) -> bool - def _disabled(bucket): - # type: (Any) -> bool + def _check_disabled(self, category: str) -> bool: + def _disabled(bucket: "Any") -> bool: ts = self._disabled_until.get(bucket) - return ts is not None and ts > datetime_utcnow() + return ts is not None and ts > datetime.now(timezone.utc) return _disabled(category) or _disabled(None) - def _is_rate_limited(self): - # type: () -> bool - return any(ts > datetime_utcnow() for ts in self._disabled_until.values()) + def _is_rate_limited(self: "Self") -> bool: + return any( + ts > datetime.now(timezone.utc) for ts in self._disabled_until.values() + ) - def _is_worker_full(self): - # type: () -> bool + def _is_worker_full(self: "Self") -> bool: return self._worker.full() - def is_healthy(self): - # type: () -> bool + def is_healthy(self: "Self") -> bool: return not (self._is_worker_full() or self._is_rate_limited()) - def _send_event( - self, event # type: Event - ): - # type: (...) -> None - - if self._check_disabled("error"): - self.on_dropped_event("self_rate_limits") - self.record_lost_event("ratelimit_backoff", data_category="error") - return None - - body = io.BytesIO() - if self._compresslevel == 0: - body.write(json_dumps(event)) - else: - with gzip.GzipFile( - fileobj=body, mode="w", compresslevel=self._compresslevel - ) as f: - f.write(json_dumps(event)) - - assert self.parsed_dsn is not None - logger.debug( - "Sending event, type:%s level:%s event_id:%s project:%s host:%s" - % ( - event.get("type") or "null", - event.get("level") or "null", - event.get("event_id") or "null", - self.parsed_dsn.project_id, - self.parsed_dsn.host, - ) - ) - - headers = { - "Content-Type": "application/json", - } - if self._compresslevel > 0: - headers["Content-Encoding"] = "gzip" - - self._send_request(body.getvalue(), headers=headers) - return None - - def _send_envelope( - self, envelope # type: Envelope - ): - # type: (...) -> None - + def _send_envelope(self: "Self", envelope: "Envelope") -> None: # remove all items from the envelope which are over quota new_items = [] for item in envelope.items: if self._check_disabled(item.data_category): - if item.data_category in ("transaction", "error", "default"): + if item.data_category in ("transaction", "error", "default", "statsd"): self.on_dropped_event("self_rate_limits") self.record_lost_event("ratelimit_backoff", item=item) else: @@ -405,14 +444,7 @@ def _send_envelope( if client_report_item is not None: envelope.items.append(client_report_item) - body = io.BytesIO() - if self._compresslevel == 0: - envelope.serialize_into(body) - else: - with gzip.GzipFile( - fileobj=body, mode="w", compresslevel=self._compresslevel - ) as f: - envelope.serialize_into(f) + content_encoding, body = self._serialize_envelope(envelope) assert self.parsed_dsn is not None logger.debug( @@ -425,27 +457,44 @@ def _send_envelope( headers = { "Content-Type": "application/x-sentry-envelope", } - if self._compresslevel > 0: - headers["Content-Encoding"] = "gzip" + if content_encoding: + headers["Content-Encoding"] = content_encoding self._send_request( body.getvalue(), headers=headers, - endpoint_type="envelope", + endpoint_type=EndpointType.ENVELOPE, envelope=envelope, ) return None - def _get_pool_options(self, ca_certs): - # type: (Optional[Any]) -> Dict[str, Any] - return { - "num_pools": 2, - "cert_reqs": "CERT_REQUIRED", - "ca_certs": ca_certs or certifi.where(), - } + def _serialize_envelope( + self: "Self", envelope: "Envelope" + ) -> "tuple[Optional[str], io.BytesIO]": + content_encoding = None + body = io.BytesIO() + if self._compression_level == 0 or self._compression_algo is None: + envelope.serialize_into(body) + else: + content_encoding = self._compression_algo + if self._compression_algo == "br" and brotli is not None: + body.write( + brotli.compress( + envelope.serialize(), quality=self._compression_level + ) + ) + else: # assume gzip as we sanitize the algo value in init + with gzip.GzipFile( + fileobj=body, mode="w", compresslevel=self._compression_level + ) as f: + envelope.serialize_into(f) + + return content_encoding, body - def _in_no_proxy(self, parsed_dsn): - # type: (Dsn) -> bool + def _get_pool_options(self: "Self") -> "Dict[str, Any]": + raise NotImplementedError() + + def _in_no_proxy(self: "Self", parsed_dsn: "Dsn") -> bool: no_proxy = getproxies().get("no") if not no_proxy: return False @@ -456,35 +505,143 @@ def _in_no_proxy(self, parsed_dsn): return False def _make_pool( + self: "Self", + ) -> "Union[PoolManager, ProxyManager, httpcore.SOCKSProxy, httpcore.HTTPProxy, httpcore.ConnectionPool]": + raise NotImplementedError() + + def _request( + self: "Self", + method: str, + endpoint_type: "EndpointType", + body: "Any", + headers: "Mapping[str, str]", + ) -> "Union[urllib3.BaseHTTPResponse, httpcore.Response]": + raise NotImplementedError() + + def capture_envelope( self, - parsed_dsn, # type: Dsn - http_proxy, # type: Optional[str] - https_proxy, # type: Optional[str] - ca_certs, # type: Optional[Any] - proxy_headers, # type: Optional[Dict[str, str]] - ): - # type: (...) -> Union[PoolManager, ProxyManager] + envelope: "Envelope", + ) -> None: + def send_envelope_wrapper() -> 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: "Self", + timeout: float, + callback: "Optional[Callable[[int, float], None]]" = None, + ) -> 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: "Self") -> None: + logger.debug("Killing HTTP transport") + self._worker.kill() + + @staticmethod + def _warn_hub_cls() -> 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: "Self") -> "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: "Self", value: "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: "Self") -> "Dict[str, Any]": + num_pools = self.options.get("_experiments", {}).get("transport_num_pools") + options = { + "num_pools": 2 if num_pools is None else int(num_pools), + "cert_reqs": "CERT_REQUIRED", + "timeout": urllib3.Timeout(total=self.TIMEOUT), + } + + socket_options: "Optional[List[Tuple[int, int, int | bytes]]]" = None + + if self.options["socket_options"] is not None: + socket_options = self.options["socket_options"] + + if self.options["keep_alive"]: + if socket_options is None: + socket_options = [] + + 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) + + if socket_options is not None: + options["socket_options"] = socket_options + + options["ca_certs"] = ( + self.options["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() + ) + + options["cert_file"] = self.options["cert_file"] or os.environ.get( + "CLIENT_CERT_FILE" + ) + options["key_file"] = self.options["key_file"] or os.environ.get( + "CLIENT_KEY_FILE" + ) + + return options + + def _make_pool(self: "Self") -> "Union[PoolManager, ProxyManager]": + if self.parsed_dsn is None: + raise ValueError("Cannot create HTTP-based transport without valid DSN") + proxy = None - no_proxy = self._in_no_proxy(parsed_dsn) + no_proxy = self._in_no_proxy(self.parsed_dsn) # try HTTPS first - if parsed_dsn.scheme == "https" and (https_proxy != ""): + https_proxy = self.options["https_proxy"] + if self.parsed_dsn.scheme == "https" and (https_proxy != ""): proxy = https_proxy or (not no_proxy and getproxies().get("https")) # maybe fallback to HTTP proxy + http_proxy = self.options["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) + opts = self._get_pool_options() if proxy: + proxy_headers = self.options["proxy_headers"] if proxy_headers: opts["proxy_headers"] = proxy_headers 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 @@ -502,88 +659,212 @@ def _make_pool( else: return urllib3.PoolManager(**opts) - def capture_event( - self, event # type: Event - ): - # type: (...) -> None - hub = self.hub_cls.current - - def send_event_wrapper(): - # type: () -> None - with hub: - with capture_internal_exceptions(): - self._send_event(event) - self._flush_client_reports() - - if not self._worker.submit(send_event_wrapper): - self.on_dropped_event("full_queue") - self.record_lost_event("queue_overflow", data_category="error") + def _request( + self: "Self", + method: str, + endpoint_type: "EndpointType", + body: "Any", + headers: "Mapping[str, str]", + ) -> "urllib3.BaseHTTPResponse": + return self._pool.request( + method, + self._auth.get_api_url(endpoint_type), + body=body, + headers=headers, + ) - def capture_envelope( - self, envelope # type: Envelope - ): - # type: (...) -> None - hub = self.hub_cls.current - - def send_envelope_wrapper(): - # type: () -> None - with hub: - 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) +try: + import httpcore + import h2 # noqa: F401 +except ImportError: + # Sorry, no Http2Transport for you + class Http2Transport(HttpTransport): + def __init__(self: "Self", options: "Dict[str, Any]") -> None: + super().__init__(options) + logger.warning( + "You tried to use HTTP2Transport but don't have httpcore[http2] installed. Falling back to HTTPTransport." + ) - def flush( - self, - timeout, # type: float - callback=None, # type: Optional[Any] - ): - # type: (...) -> None - logger.debug("Flushing HTTP transport") +else: - if timeout > 0: - self._worker.submit(lambda: self._flush_client_reports(force=True)) - self._worker.flush(timeout, callback) + class Http2Transport(BaseHttpTransport): # type: ignore + """The HTTP2 transport based on httpcore.""" - def kill(self): - # type: () -> None - logger.debug("Killing HTTP transport") - self._worker.kill() + TIMEOUT = 15 + + if TYPE_CHECKING: + _pool: """Union[ + httpcore.SOCKSProxy, httpcore.HTTPProxy, httpcore.ConnectionPool + ]""" + + def _get_header_value( + self: "Self", response: "httpcore.Response", header: str + ) -> "Optional[str]": + return next( + ( + val.decode("ascii") + for key, val in response.headers + if key.decode("ascii").lower() == header + ), + None, + ) + + def _request( + self: "Self", + method: str, + endpoint_type: "EndpointType", + body: "Any", + headers: "Mapping[str, str]", + ) -> "httpcore.Response": + response = self._pool.request( + method, + self._auth.get_api_url(endpoint_type), + content=body, + headers=headers, # type: ignore + extensions={ + "timeout": { + "pool": self.TIMEOUT, + "connect": self.TIMEOUT, + "write": self.TIMEOUT, + "read": self.TIMEOUT, + } + }, + ) + return response + + def _get_pool_options(self: "Self") -> "Dict[str, Any]": + options: "Dict[str, Any]" = { + "http2": self.parsed_dsn is not None + and self.parsed_dsn.scheme == "https", + "retries": 3, + } + + socket_options = ( + self.options["socket_options"] + if self.options["socket_options"] is not None + else [] + ) + + 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) + + options["socket_options"] = socket_options + + ssl_context = ssl.create_default_context() + ssl_context.load_verify_locations( + self.options["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 = self.options["cert_file"] or os.environ.get("CLIENT_CERT_FILE") + key_file = self.options["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: "Self", + ) -> "Union[httpcore.SOCKSProxy, httpcore.HTTPProxy, httpcore.ConnectionPool]": + if self.parsed_dsn is None: + raise ValueError("Cannot create HTTP-based transport without valid DSN") + proxy = None + no_proxy = self._in_no_proxy(self.parsed_dsn) + + # try HTTPS first + https_proxy = self.options["https_proxy"] + if self.parsed_dsn.scheme == "https" and (https_proxy != ""): + proxy = https_proxy or (not no_proxy and getproxies().get("https")) + + # maybe fallback to HTTP proxy + http_proxy = self.options["http_proxy"] + if not proxy and (http_proxy != ""): + proxy = http_proxy or (not no_proxy and getproxies().get("http")) + + opts = self._get_pool_options() + + if proxy: + proxy_headers = self.options["proxy_headers"] + 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): + """ + DEPRECATED: Users wishing to provide a custom transport should subclass + the Transport class, rather than providing a function. + """ + def __init__( - self, func # type: Callable[[Event], None] - ): - # type: (...) -> None + self, + func: "Callable[[Event], None]", + ) -> None: Transport.__init__(self) self._func = func def capture_event( - self, event # type: Event - ): - # type: (...) -> None + self, + event: "Event", + ) -> None: self._func(event) return None + def capture_envelope(self, envelope: "Envelope") -> None: + # Since function transports expect to be called with an event, we need + # to iterate over the envelope and call the function for each event, via + # the deprecated capture_event method. + event = envelope.get_event() + if event is not None: + self.capture_event(event) + -def make_transport(options): - # type: (Dict[str, Any]) -> Optional[Transport] +def make_transport(options: "Dict[str, Any]") -> "Optional[Transport]": ref_transport = options["transport"] - # If no transport is given, we use the http transport class - if ref_transport is None: - transport_cls = HttpTransport # type: Type[Transport] - elif isinstance(ref_transport, Transport): + use_http2_transport = options.get("_experiments", {}).get("transport_http2", False) + + # By default, we use the http transport class + transport_cls: "Type[Transport]" = ( + Http2Transport if use_http2_transport else HttpTransport + ) + + if isinstance(ref_transport, Transport): return ref_transport elif isinstance(ref_transport, type) and issubclass(ref_transport, Transport): transport_cls = ref_transport elif callable(ref_transport): - return _FunctionTransport(ref_transport) # type: ignore + warnings.warn( + "Function transports are deprecated and will be removed in a future release." + "Please provide a Transport instance or subclass, instead.", + DeprecationWarning, + stacklevel=2, + ) + return _FunctionTransport(ref_transport) # if a transport class is given only instantiate it if the dsn is not # empty or None diff --git a/sentry_sdk/types.py b/sentry_sdk/types.py new file mode 100644 index 0000000000..8b28166462 --- /dev/null +++ b/sentry_sdk/types.py @@ -0,0 +1,52 @@ +""" +This module contains type definitions for the Sentry SDK's public API. +The types are re-exported from the internal module `sentry_sdk._types`. + +Disclaimer: Since types are a form of documentation, type definitions +may change in minor releases. Removing a type would be considered a +breaking change, and so we will only remove type definitions in major +releases. +""" + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + # Re-export types to make them available in the public API + from sentry_sdk._types import ( + Breadcrumb, + BreadcrumbHint, + Event, + EventDataCategory, + Hint, + Log, + MonitorConfig, + SamplingContext, + Metric, + ) +else: + from typing import Any + + # The lines below allow the types to be imported from outside `if TYPE_CHECKING` + # guards. The types in this module are only intended to be used for type hints. + Breadcrumb = Any + BreadcrumbHint = Any + Event = Any + EventDataCategory = Any + Hint = Any + Log = Any + MonitorConfig = Any + SamplingContext = Any + Metric = Any + + +__all__ = ( + "Breadcrumb", + "BreadcrumbHint", + "Event", + "EventDataCategory", + "Hint", + "Log", + "MonitorConfig", + "SamplingContext", + "Metric", +) diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py index c811d2d2fe..c99b81a2f5 100644 --- a/sentry_sdk/utils.py +++ b/sentry_sdk/utils.py @@ -4,31 +4,18 @@ import logging import math import os +import random import re import subprocess import sys import threading import time from collections import namedtuple -from copy import copy +from datetime import datetime, timezone from decimal import Decimal +from functools import partial, partialmethod, wraps from numbers import Real - -try: - # Python 3 - from urllib.parse import parse_qs - from urllib.parse import unquote - from urllib.parse import urlencode - from urllib.parse import urlsplit - from urllib.parse import urlunsplit - -except ImportError: - # Python 2 - from cgi import parse_qs # type: ignore - from urllib import unquote # type: ignore - from urllib import urlencode # type: ignore - from urlparse import urlsplit # type: ignore - from urlparse import urlunsplit # type: ignore +from urllib.parse import parse_qs, unquote, urlencode, urlsplit, urlunsplit try: # Python 3.11 @@ -37,20 +24,17 @@ # Python 3.10 and below BaseExceptionGroup = None # type: ignore -from datetime import datetime -from functools import partial - -try: - from functools import partialmethod - - _PARTIALMETHOD_AVAILABLE = True -except ImportError: - _PARTIALMETHOD_AVAILABLE = False +from typing import TYPE_CHECKING import sentry_sdk -from sentry_sdk._compat import PY2, PY33, PY37, implements_str, text_type, urlparse -from sentry_sdk._types import TYPE_CHECKING -from sentry_sdk.consts import DEFAULT_MAX_VALUE_LENGTH +from sentry_sdk._compat import PY37 +from sentry_sdk._types import SENSITIVE_DATA_SUBSTITUTE, Annotated, AnnotatedValue +from sentry_sdk.consts import ( + DEFAULT_ADD_FULL_STACK, + DEFAULT_MAX_STACK_FRAMES, + DEFAULT_MAX_VALUE_LENGTH, + EndpointType, +) if TYPE_CHECKING: from types import FrameType, TracebackType @@ -61,14 +45,33 @@ Dict, Iterator, List, + Literal, + NoReturn, Optional, + ParamSpec, Set, Tuple, Type, + TypeVar, Union, + cast, + overload, + ) + + from gevent.hub import Hub + + from sentry_sdk._types import ( + AttributeValue, + SerializedAttributeValue, + Event, + ExcInfo, + Hint, + Log, + Metric, ) - from sentry_sdk._types import EndpointType, ExcInfo + P = ParamSpec("P") + R = TypeVar("R") epoch = datetime(1970, 1, 1) @@ -76,36 +79,56 @@ # The logger is created here but initialized in the debug support module logger = logging.getLogger("sentry_sdk.errors") +_installed_modules = None BASE64_ALPHABET = re.compile(r"^[a-zA-Z0-9/+=]*$") -SENSITIVE_DATA_SUBSTITUTE = "[Filtered]" +FALSY_ENV_VALUES = frozenset(("false", "f", "n", "no", "off", "0")) +TRUTHY_ENV_VALUES = frozenset(("true", "t", "y", "yes", "on", "1")) +MAX_STACK_FRAMES = 2000 +"""Maximum number of stack frames to send to Sentry. -def json_dumps(data): - # type: (Any) -> bytes - """Serialize data into a compact JSON representation encoded as UTF-8.""" - return json.dumps(data, allow_nan=False, separators=(",", ":")).encode("utf-8") +If we have more than this number of stack frames, we will stop processing +the stacktrace to avoid getting stuck in a long-lasting loop. This value +exceeds the default sys.getrecursionlimit() of 1000, so users will only +be affected by this limit if they have a custom recursion limit. +""" -def _get_debug_hub(): - # type: () -> Optional[sentry_sdk.Hub] - # This function is replaced by debug.py - pass +def env_to_bool(value: "Any", *, strict: "Optional[bool]" = False) -> "bool | None": + """Casts an ENV variable value to boolean using the constants defined above. + In strict mode, it may return None if the value doesn't match any of the predefined values. + """ + normalized = str(value).lower() if value is not None else None + if normalized in FALSY_ENV_VALUES: + return False -def get_default_release(): - # type: () -> Optional[str] - """Try to guess a default release.""" - release = os.environ.get("SENTRY_RELEASE") - if release: - return release + if normalized in TRUTHY_ENV_VALUES: + return True - with open(os.path.devnull, "w+") as null: - try: - release = ( + return None if strict else bool(value) + + +def json_dumps(data: "Any") -> bytes: + """Serialize data into a compact JSON representation encoded as UTF-8.""" + return json.dumps(data, allow_nan=False, separators=(",", ":")).encode("utf-8") + + +def get_git_revision() -> "Optional[str]": + try: + with open(os.path.devnull, "w+") as null: + # prevent command prompt windows from popping up on windows + startupinfo = None + if sys.platform == "win32" or sys.platform == "cygwin": + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + + revision = ( subprocess.Popen( ["git", "rev-parse", "HEAD"], + startupinfo=startupinfo, stdout=subprocess.PIPE, stderr=null, stdin=null, @@ -114,11 +137,21 @@ def get_default_release(): .strip() .decode("utf-8") ) - except (OSError, IOError): - pass + except (OSError, IOError, FileNotFoundError): + return None - if release: - return release + return revision + + +def get_default_release() -> "Optional[str]": + """Try to guess a default release.""" + release = os.environ.get("SENTRY_RELEASE") + if release: + return release + + release = get_git_revision() + if release: + return release for var in ( "HEROKU_SLUG_COMMIT", @@ -126,6 +159,7 @@ def get_default_release(): "CODEBUILD_RESOLVED_SOURCE_VERSION", "CIRCLE_SHA1", "GAE_DEPLOYMENT_ID", + "K_REVISION", ): release = os.environ.get(var) if release: @@ -133,8 +167,7 @@ def get_default_release(): return None -def get_sdk_name(installed_integrations): - # type: (List[str]) -> str +def get_sdk_name(installed_integrations: "List[str]") -> str: """Return the SDK name including the name of the used web framework.""" # Note: I can not use for example sentry_sdk.integrations.django.DjangoIntegration.identifier @@ -148,6 +181,8 @@ def get_sdk_name(installed_integrations): "quart", "sanic", "starlette", + "litestar", + "starlite", "chalice", "serverless", "pyramid", @@ -167,15 +202,18 @@ def get_sdk_name(installed_integrations): return "sentry.python" -class CaptureInternalException(object): +class CaptureInternalException: __slots__ = () - def __enter__(self): - # type: () -> ContextManager[Any] + def __enter__(self) -> "ContextManager[Any]": return self - def __exit__(self, ty, value, tb): - # type: (Optional[Type[BaseException]], Optional[BaseException], Optional[TracebackType]) -> bool + def __exit__( + self, + ty: "Optional[Type[BaseException]]", + value: "Optional[BaseException]", + tb: "Optional[TracebackType]", + ) -> bool: if ty is not None and value is not None: capture_internal_exception((ty, value, tb)) @@ -185,30 +223,64 @@ def __exit__(self, ty, value, tb): _CAPTURE_INTERNAL_EXCEPTION = CaptureInternalException() -def capture_internal_exceptions(): - # type: () -> ContextManager[Any] +def capture_internal_exceptions() -> "ContextManager[Any]": return _CAPTURE_INTERNAL_EXCEPTION -def capture_internal_exception(exc_info): - # type: (ExcInfo) -> None - hub = _get_debug_hub() - if hub is not None: - hub._capture_internal_exception(exc_info) +def capture_internal_exception(exc_info: "ExcInfo") -> None: + """ + Capture an exception that is likely caused by a bug in the SDK + itself. + + These exceptions do not end up in Sentry and are just logged instead. + """ + if sentry_sdk.get_client().is_active(): + logger.error("Internal error in sentry_sdk", exc_info=exc_info) -def to_timestamp(value): - # type: (datetime) -> float +def to_timestamp(value: "datetime") -> float: return (value - epoch).total_seconds() -def format_timestamp(value): - # type: (datetime) -> str - return value.strftime("%Y-%m-%dT%H:%M:%S.%fZ") +def format_timestamp(value: "datetime") -> str: + """Formats a timestamp in RFC 3339 format. + + Any datetime objects with a non-UTC timezone are converted to UTC, so that all timestamps are formatted in UTC. + """ + utctime = value.astimezone(timezone.utc) + + # We use this custom formatting rather than isoformat for backwards compatibility (we have used this format for + # several years now), and isoformat is slightly different. + return utctime.strftime("%Y-%m-%dT%H:%M:%S.%fZ") + + +ISO_TZ_SEPARATORS = frozenset(("+", "-")) + + +def datetime_from_isoformat(value: str) -> "datetime": + try: + result = datetime.fromisoformat(value) + except (AttributeError, ValueError): + # py 3.6 + timestamp_format = ( + "%Y-%m-%dT%H:%M:%S.%f" if "." in value else "%Y-%m-%dT%H:%M:%S" + ) + if value.endswith("Z"): + value = value[:-1] + "+0000" + + if value[-6] in ISO_TZ_SEPARATORS: + timestamp_format += "%z" + value = value[:-3] + value[-2:] + elif value[-5] in ISO_TZ_SEPARATORS: + timestamp_format += "%z" + result = datetime.strptime(value, timestamp_format) + return result.astimezone(timezone.utc) -def event_hint_with_exc_info(exc_info=None): - # type: (Optional[ExcInfo]) -> Dict[str, Optional[ExcInfo]] + +def event_hint_with_exc_info( + exc_info: "Optional[ExcInfo]" = None, +) -> "Dict[str, Optional[ExcInfo]]": """Creates a hint with the exc info filled in.""" if exc_info is None: exc_info = sys.exc_info() @@ -223,16 +295,18 @@ class BadDsn(ValueError): """Raised on invalid DSNs.""" -@implements_str -class Dsn(object): +class Dsn: """Represents a DSN.""" - def __init__(self, value): - # type: (Union[Dsn, str]) -> None + ORG_ID_REGEX = re.compile(r"^o(\d+)\.") + + def __init__( + self, value: "Union[Dsn, str]", org_id: "Optional[str]" = None + ) -> None: if isinstance(value, Dsn): self.__dict__ = dict(value.__dict__) return - parts = urlparse.urlsplit(text_type(value)) + parts = urlsplit(str(value)) if parts.scheme not in ("http", "https"): raise BadDsn("Unsupported scheme %r" % parts.scheme) @@ -243,8 +317,14 @@ def __init__(self, value): self.host = parts.hostname + if org_id is not None: + self.org_id: "Optional[str]" = org_id + else: + org_id_match = Dsn.ORG_ID_REGEX.match(self.host) + self.org_id = org_id_match.group(1) if org_id_match else None + if parts.port is None: - self.port = self.scheme == "https" and 443 or 80 # type: int + self.port: int = self.scheme == "https" and 443 or 80 else: self.port = parts.port @@ -257,23 +337,21 @@ def __init__(self, value): path = parts.path.rsplit("/", 1) try: - self.project_id = text_type(int(path.pop())) + self.project_id = str(int(path.pop())) except (ValueError, TypeError): raise BadDsn("Invalid project in DSN (%r)" % (parts.path or "")[1:]) self.path = "/".join(path) + "/" @property - def netloc(self): - # type: () -> str + def netloc(self) -> str: """The netloc part of a DSN.""" rv = self.host if (self.scheme, self.port) not in (("http", 80), ("https", 443)): rv = "%s:%s" % (rv, self.port) return rv - def to_auth(self, client=None): - # type: (Optional[Any]) -> Auth + def to_auth(self, client: "Optional[Any]" = None) -> "Auth": """Returns the auth info object for this dsn.""" return Auth( scheme=self.scheme, @@ -285,8 +363,7 @@ def to_auth(self, client=None): client=client, ) - def __str__(self): - # type: () -> str + def __str__(self) -> str: return "%s://%s%s@%s%s%s" % ( self.scheme, self.public_key, @@ -297,21 +374,20 @@ def __str__(self): ) -class Auth(object): +class Auth: """Helper object that represents the auth info.""" def __init__( self, - scheme, - host, - project_id, - public_key, - secret_key=None, - version=7, - client=None, - path="/", - ): - # type: (str, str, str, str, Optional[str], int, Optional[Any], str) -> None + scheme: str, + host: str, + project_id: str, + public_key: str, + secret_key: "Optional[str]" = None, + version: int = 7, + client: "Optional[Any]" = None, + path: str = "/", + ) -> None: self.scheme = scheme self.host = host self.path = path @@ -321,30 +397,20 @@ def __init__( self.version = version self.client = client - @property - def store_api_url(self): - # type: () -> str - """Returns the API url for storing events. - - Deprecated: use get_api_url instead. - """ - return self.get_api_url(type="store") - def get_api_url( - self, type="store" # type: EndpointType - ): - # type: (...) -> str + self, + type: "EndpointType" = EndpointType.ENVELOPE, + ) -> str: """Returns the API url for storing events.""" return "%s://%s%sapi/%s/%s/" % ( self.scheme, self.host, self.path, self.project_id, - type, + type.value, ) - def to_header(self): - # type: () -> str + def to_header(self) -> str: """Returns the auth header a string.""" rv = [("sentry_key", self.public_key), ("sentry_version", self.version)] if self.client is not None: @@ -354,92 +420,18 @@ def to_header(self): return "Sentry " + ", ".join("%s=%s" % (key, value) for key, value in rv) -class AnnotatedValue(object): - """ - Meta information for a data field in the event payload. - This is to tell Relay that we have tampered with the fields value. - See: - https://github.com/getsentry/relay/blob/be12cd49a0f06ea932ed9b9f93a655de5d6ad6d1/relay-general/src/types/meta.rs#L407-L423 - """ - - __slots__ = ("value", "metadata") - - def __init__(self, value, metadata): - # type: (Optional[Any], Dict[str, Any]) -> None - self.value = value - self.metadata = metadata - - @classmethod - def removed_because_raw_data(cls): - # type: () -> AnnotatedValue - """The value was removed because it could not be parsed. This is done for request body values that are not json nor a form.""" - return AnnotatedValue( - value="", - metadata={ - "rem": [ # Remark - [ - "!raw", # Unparsable raw data - "x", # The fields original value was removed - ] - ] - }, - ) - - @classmethod - def removed_because_over_size_limit(cls): - # type: () -> AnnotatedValue - """The actual value was removed because the size of the field exceeded the configured maximum size (specified with the max_request_body_size sdk option)""" - return AnnotatedValue( - value="", - metadata={ - "rem": [ # Remark - [ - "!config", # Because of configured maximum size - "x", # The fields original value was removed - ] - ] - }, - ) - - @classmethod - def substituted_because_contains_sensitive_data(cls): - # type: () -> AnnotatedValue - """The actual value was removed because it contained sensitive information.""" - return AnnotatedValue( - value=SENSITIVE_DATA_SUBSTITUTE, - metadata={ - "rem": [ # Remark - [ - "!config", # Because of SDK configuration (in this case the config is the hard coded removal of certain django cookies) - "s", # The fields original value was substituted - ] - ] - }, - ) - - -if TYPE_CHECKING: - from typing import TypeVar - - T = TypeVar("T") - Annotated = Union[AnnotatedValue, T] - - -def get_type_name(cls): - # type: (Optional[type]) -> Optional[str] +def get_type_name(cls: "Optional[type]") -> "Optional[str]": return getattr(cls, "__qualname__", None) or getattr(cls, "__name__", None) -def get_type_module(cls): - # type: (Optional[type]) -> Optional[str] +def get_type_module(cls: "Optional[type]") -> "Optional[str]": mod = getattr(cls, "__module__", None) if mod not in (None, "builtins", "__builtins__"): return mod return None -def should_hide_frame(frame): - # type: (FrameType) -> bool +def should_hide_frame(frame: "FrameType") -> bool: try: mod = frame.f_globals["__name__"] if mod.startswith("sentry_sdk."): @@ -457,9 +449,8 @@ def should_hide_frame(frame): return False -def iter_stacks(tb): - # type: (Optional[TracebackType]) -> Iterator[TracebackType] - tb_ = tb # type: Optional[TracebackType] +def iter_stacks(tb: "Optional[TracebackType]") -> "Iterator[TracebackType]": + tb_: "Optional[TracebackType]" = tb while tb_ is not None: if not should_hide_frame(tb_.tb_frame): yield tb_ @@ -467,18 +458,17 @@ def iter_stacks(tb): def get_lines_from_file( - filename, # type: str - lineno, # type: int - max_length=None, # type: Optional[int] - loader=None, # type: Optional[Any] - module=None, # type: Optional[str] -): - # type: (...) -> Tuple[List[Annotated[str]], Optional[Annotated[str]], List[Annotated[str]]] + filename: str, + lineno: int, + max_length: "Optional[int]" = None, + loader: "Optional[Any]" = None, + module: "Optional[str]" = None, +) -> "Tuple[List[Annotated[str]], Optional[Annotated[str]], List[Annotated[str]]]": context_lines = 5 source = None if loader is not None and hasattr(loader, "get_source"): try: - source_str = loader.get_source(module) # type: Optional[str] + source_str: "Optional[str]" = loader.get_source(module) except (ImportError, IOError): source_str = None if source_str is not None: @@ -513,13 +503,12 @@ def get_lines_from_file( def get_source_context( - frame, # type: FrameType - tb_lineno, # type: int - max_value_length=None, # type: Optional[int] -): - # type: (...) -> Tuple[List[Annotated[str]], Optional[Annotated[str]], List[Annotated[str]]] + frame: "FrameType", + tb_lineno: "Optional[int]", + max_value_length: "Optional[int]" = None, +) -> "Tuple[List[Annotated[str]], Optional[Annotated[str]], List[Annotated[str]]]": try: - abs_path = frame.f_code.co_filename # type: Optional[str] + abs_path: "Optional[str]" = frame.f_code.co_filename except Exception: abs_path = None try: @@ -530,61 +519,33 @@ def get_source_context( loader = frame.f_globals["__loader__"] except Exception: loader = None - lineno = tb_lineno - 1 - if lineno is not None and abs_path: + + if tb_lineno is not None and abs_path: + lineno = tb_lineno - 1 return get_lines_from_file( abs_path, lineno, max_value_length, loader=loader, module=module ) + return [], None, [] -def safe_str(value): - # type: (Any) -> str +def safe_str(value: "Any") -> str: try: - return text_type(value) + return str(value) except Exception: return safe_repr(value) -if PY2: - - def safe_repr(value): - # type: (Any) -> str - try: - rv = repr(value).decode("utf-8", "replace") - - # At this point `rv` contains a bunch of literal escape codes, like - # this (exaggerated example): - # - # u"\\x2f" - # - # But we want to show this string as: - # - # u"/" - try: - # unicode-escape does this job, but can only decode latin1. So we - # attempt to encode in latin1. - return rv.encode("latin1").decode("unicode-escape") - except Exception: - # Since usually strings aren't latin1 this can break. In those - # cases we just give up. - return rv - except Exception: - # If e.g. the call to `repr` already fails - return "" - -else: - - def safe_repr(value): - # type: (Any) -> str - try: - return repr(value) - except Exception: - return "" +def safe_repr(value: "Any") -> str: + try: + return repr(value) + except Exception: + return "" -def filename_for_module(module, abs_path): - # type: (Optional[str], Optional[str]) -> Optional[str] +def filename_for_module( + module: "Optional[str]", abs_path: "Optional[str]" +) -> "Optional[str]": if not abs_path or not module: return abs_path @@ -608,13 +569,13 @@ def filename_for_module(module, abs_path): def serialize_frame( - frame, - tb_lineno=None, - include_local_variables=True, - include_source_context=True, - max_value_length=None, -): - # type: (FrameType, Optional[int], bool, bool, Optional[int]) -> Dict[str, Any] + frame: "FrameType", + tb_lineno: "Optional[int]" = None, + include_local_variables: bool = True, + include_source_context: bool = True, + max_value_length: "Optional[int]" = None, + custom_repr: "Optional[Callable[..., Optional[str]]]" = None, +) -> "Dict[str, Any]": f_code = getattr(frame, "f_code", None) if not f_code: abs_path = None @@ -630,13 +591,18 @@ def serialize_frame( if tb_lineno is None: tb_lineno = frame.f_lineno - rv = { + try: + os_abs_path = os.path.abspath(abs_path) if abs_path else None + except Exception: + os_abs_path = None + + rv: "Dict[str, Any]" = { "filename": filename_for_module(module, abs_path) or None, - "abs_path": os.path.abspath(abs_path) if abs_path else None, + "abs_path": os_abs_path, "function": function or "", "module": module, "lineno": tb_lineno, - } # type: Dict[str, Any] + } if include_source_context: rv["pre_context"], rv["context_line"], rv["post_context"] = get_source_context( @@ -644,21 +610,24 @@ def serialize_frame( ) if include_local_variables: - rv["vars"] = copy(frame.f_locals) + from sentry_sdk.serializer import serialize + + rv["vars"] = serialize( + dict(frame.f_locals), is_vars=True, custom_repr=custom_repr + ) return rv def current_stacktrace( - include_local_variables=True, # type: bool - include_source_context=True, # type: bool - max_value_length=None, # type: Optional[int] -): - # type: (...) -> Dict[str, Any] + include_local_variables: bool = True, + include_source_context: bool = True, + max_value_length: "Optional[int]" = None, +) -> "Dict[str, Any]": __tracebackhide__ = True frames = [] - f = sys._getframe() # type: Optional[FrameType] + f: "Optional[FrameType]" = sys._getframe() while f is not None: if not should_hide_frame(f): frames.append( @@ -676,38 +645,46 @@ def current_stacktrace( return {"frames": frames} -def get_errno(exc_value): - # type: (BaseException) -> Optional[Any] +def get_errno(exc_value: BaseException) -> "Optional[Any]": return getattr(exc_value, "errno", None) -def get_error_message(exc_value): - # type: (Optional[BaseException]) -> str - return ( +def get_error_message(exc_value: "Optional[BaseException]") -> str: + message: str = safe_str( getattr(exc_value, "message", "") or getattr(exc_value, "detail", "") or safe_str(exc_value) ) + # __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: object = getattr(exc_value, "__notes__", None) + 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( - exc_type, # type: Optional[type] - exc_value, # type: Optional[BaseException] - tb, # type: Optional[TracebackType] - client_options=None, # type: Optional[Dict[str, Any]] - mechanism=None, # type: Optional[Dict[str, Any]] - exception_id=None, # type: Optional[int] - parent_id=None, # type: Optional[int] - source=None, # type: Optional[str] -): - # type: (...) -> Dict[str, Any] + exc_type: "Optional[type]", + exc_value: "Optional[BaseException]", + tb: "Optional[TracebackType]", + client_options: "Optional[Dict[str, Any]]" = None, + mechanism: "Optional[Dict[str, Any]]" = None, + exception_id: "Optional[int]" = None, + parent_id: "Optional[int]" = None, + source: "Optional[str]" = None, + full_stack: "Optional[list[dict[str, Any]]]" = None, +) -> "Dict[str, Any]": """ Creates a dict that goes into the events `exception.values` list and is ingestible by Sentry. See the Exception Interface documentation for more details: https://develop.sentry.dev/sdk/event-payloads/exception/ """ - exception_value = {} # type: Dict[str, Any] + exception_value: "Dict[str, Any]" = {} exception_value["mechanism"] = ( mechanism.copy() if mechanism else {"type": "generic", "handled": True} ) @@ -749,24 +726,45 @@ def single_exception_from_error_tuple( include_local_variables = True include_source_context = True max_value_length = DEFAULT_MAX_VALUE_LENGTH # fallback + custom_repr = None else: include_local_variables = client_options["include_local_variables"] include_source_context = client_options["include_source_context"] max_value_length = client_options["max_value_length"] + custom_repr = client_options.get("custom_repr") - frames = [ + frames: "List[Dict[str, Any]]" = [ serialize_frame( tb.tb_frame, tb_lineno=tb.tb_lineno, include_local_variables=include_local_variables, include_source_context=include_source_context, max_value_length=max_value_length, + custom_repr=custom_repr, ) - for tb in iter_stacks(tb) + # Process at most MAX_STACK_FRAMES + 1 frames, to avoid hanging on + # processing a super-long stacktrace. + for tb, _ in zip(iter_stacks(tb), range(MAX_STACK_FRAMES + 1)) ] - if frames: - exception_value["stacktrace"] = {"frames": frames} + if len(frames) > MAX_STACK_FRAMES: + # If we have more frames than the limit, we remove the stacktrace completely. + # We don't trim the stacktrace here because we have not processed the whole + # thing (see above, we stop at MAX_STACK_FRAMES + 1). Normally, Relay would + # intelligently trim by removing frames in the middle of the stacktrace, but + # since we don't have the whole stacktrace, we can't do that. Instead, we + # drop the entire stacktrace. + exception_value["stacktrace"] = AnnotatedValue.removed_because_over_size_limit( + value=None + ) + + elif frames: + if not full_stack: + new_frames = frames + else: + new_frames = merge_stack_frames(frames, full_stack, client_options) + + exception_value["stacktrace"] = {"frames": new_frames} return exception_value @@ -775,12 +773,11 @@ def single_exception_from_error_tuple( if HAS_CHAINED_EXCEPTIONS: - def walk_exception_chain(exc_info): - # type: (ExcInfo) -> Iterator[ExcInfo] + def walk_exception_chain(exc_info: "ExcInfo") -> "Iterator[ExcInfo]": exc_type, exc_value, tb = exc_info seen_exceptions = [] - seen_exception_ids = set() # type: Set[int] + seen_exception_ids: "Set[int]" = set() while ( exc_type is not None @@ -807,22 +804,21 @@ def walk_exception_chain(exc_info): else: - def walk_exception_chain(exc_info): - # type: (ExcInfo) -> Iterator[ExcInfo] + def walk_exception_chain(exc_info: "ExcInfo") -> "Iterator[ExcInfo]": yield exc_info def exceptions_from_error( - exc_type, # type: Optional[type] - exc_value, # type: Optional[BaseException] - tb, # type: Optional[TracebackType] - client_options=None, # type: Optional[Dict[str, Any]] - mechanism=None, # type: Optional[Dict[str, Any]] - exception_id=0, # type: int - parent_id=0, # type: int - source=None, # type: Optional[str] -): - # type: (...) -> Tuple[int, List[Dict[str, Any]]] + exc_type: "Optional[type]", + exc_value: "Optional[BaseException]", + tb: "Optional[TracebackType]", + client_options: "Optional[Dict[str, Any]]" = None, + mechanism: "Optional[Dict[str, Any]]" = None, + exception_id: int = 0, + parent_id: int = 0, + source: "Optional[str]" = None, + full_stack: "Optional[list[dict[str, Any]]]" = None, +) -> "Tuple[int, List[Dict[str, Any]]]": """ Creates the list of exceptions. This can include chained exceptions and exceptions from an ExceptionGroup. @@ -840,13 +836,16 @@ def exceptions_from_error( exception_id=exception_id, parent_id=parent_id, source=source, + full_stack=full_stack, ) exceptions = [parent] parent_id = exception_id exception_id += 1 - should_supress_context = hasattr(exc_value, "__suppress_context__") and exc_value.__suppress_context__ # type: ignore + should_supress_context = ( + hasattr(exc_value, "__suppress_context__") and exc_value.__suppress_context__ # type: ignore + ) if should_supress_context: # Add direct cause. # The field `__cause__` is set when raised with the exception (using the `from` keyword). @@ -865,6 +864,7 @@ def exceptions_from_error( mechanism=mechanism, exception_id=exception_id, source="__cause__", + full_stack=full_stack, ) exceptions.extend(child_exceptions) @@ -886,6 +886,7 @@ def exceptions_from_error( mechanism=mechanism, exception_id=exception_id, source="__context__", + full_stack=full_stack, ) exceptions.extend(child_exceptions) @@ -902,6 +903,7 @@ def exceptions_from_error( exception_id=exception_id, parent_id=parent_id, source="exceptions[%s]" % idx, + full_stack=full_stack, ) exceptions.extend(child_exceptions) @@ -909,11 +911,11 @@ def exceptions_from_error( def exceptions_from_error_tuple( - exc_info, # type: ExcInfo - client_options=None, # type: Optional[Dict[str, Any]] - mechanism=None, # type: Optional[Dict[str, Any]] -): - # type: (...) -> List[Dict[str, Any]] + exc_info: "ExcInfo", + client_options: "Optional[Dict[str, Any]]" = None, + mechanism: "Optional[Dict[str, Any]]" = None, + full_stack: "Optional[list[dict[str, Any]]]" = None, +) -> "List[Dict[str, Any]]": exc_type, exc_value, tb = exc_info is_exception_group = BaseExceptionGroup is not None and isinstance( @@ -929,6 +931,7 @@ def exceptions_from_error_tuple( mechanism=mechanism, exception_id=0, parent_id=0, + full_stack=full_stack, ) else: @@ -936,7 +939,12 @@ def exceptions_from_error_tuple( for exc_type, exc_value, tb in walk_exception_chain(exc_info): exceptions.append( single_exception_from_error_tuple( - exc_type, exc_value, tb, client_options, mechanism + exc_type=exc_type, + exc_value=exc_value, + tb=tb, + client_options=client_options, + mechanism=mechanism, + full_stack=full_stack, ) ) @@ -945,16 +953,14 @@ def exceptions_from_error_tuple( return exceptions -def to_string(value): - # type: (str) -> str +def to_string(value: str) -> str: try: - return text_type(value) + return str(value) except UnicodeDecodeError: return repr(value)[1:-1] -def iter_event_stacktraces(event): - # type: (Dict[str, Any]) -> Iterator[Dict[str, Any]] +def iter_event_stacktraces(event: "Event") -> "Iterator[Annotated[Dict[str, Any]]]": if "stacktrace" in event: yield event["stacktrace"] if "threads" in event: @@ -963,20 +969,29 @@ def iter_event_stacktraces(event): yield thread["stacktrace"] if "exception" in event: for exception in event["exception"].get("values") or (): - if "stacktrace" in exception: + if isinstance(exception, dict) and "stacktrace" in exception: yield exception["stacktrace"] -def iter_event_frames(event): - # type: (Dict[str, Any]) -> Iterator[Dict[str, Any]] +def iter_event_frames(event: "Event") -> "Iterator[Dict[str, Any]]": for stacktrace in iter_event_stacktraces(event): + if isinstance(stacktrace, AnnotatedValue): + stacktrace = stacktrace.value or {} + for frame in stacktrace.get("frames") or (): yield frame -def handle_in_app(event, in_app_exclude=None, in_app_include=None, project_root=None): - # type: (Dict[str, Any], Optional[List[str]], Optional[List[str]], Optional[str]) -> Dict[str, Any] +def handle_in_app( + event: "Event", + in_app_exclude: "Optional[List[str]]" = None, + in_app_include: "Optional[List[str]]" = None, + project_root: "Optional[str]" = None, +) -> "Event": for stacktrace in iter_event_stacktraces(event): + if isinstance(stacktrace, AnnotatedValue): + stacktrace = stacktrace.value or {} + set_in_app_in_frames( stacktrace.get("frames"), in_app_exclude=in_app_exclude, @@ -987,8 +1002,12 @@ def handle_in_app(event, in_app_exclude=None, in_app_include=None, project_root= return event -def set_in_app_in_frames(frames, in_app_exclude, in_app_include, project_root=None): - # type: (Any, Optional[List[str]], Optional[List[str]], Optional[str]) -> Optional[Any] +def set_in_app_in_frames( + frames: "Any", + in_app_exclude: "Optional[List[str]]", + in_app_include: "Optional[List[str]]", + project_root: "Optional[str]" = None, +) -> "Optional[Any]": if not frames: return None @@ -1026,8 +1045,7 @@ def set_in_app_in_frames(frames, in_app_exclude, in_app_include, project_root=No return frames -def exc_info_from_error(error): - # type: (Union[BaseException, ExcInfo]) -> ExcInfo +def exc_info_from_error(error: "Union[BaseException, ExcInfo]") -> "ExcInfo": if isinstance(error, tuple) and len(error) == 3: exc_type, exc_value, tb = error elif isinstance(error, BaseException): @@ -1045,23 +1063,81 @@ def exc_info_from_error(error): else: raise ValueError("Expected Exception object to report, got %s!" % type(error)) - return exc_type, exc_value, tb + exc_info = (exc_type, exc_value, tb) + + if TYPE_CHECKING: + # This cast is safe because exc_type and exc_value are either both + # None or both not None. + exc_info = cast(ExcInfo, exc_info) + + return exc_info + + +def merge_stack_frames( + frames: "List[Dict[str, Any]]", + full_stack: "List[Dict[str, Any]]", + client_options: "Optional[Dict[str, Any]]", +) -> "List[Dict[str, Any]]": + """ + Add the missing frames from full_stack to frames and return the merged list. + """ + frame_ids = { + ( + frame["abs_path"], + frame["context_line"], + frame["lineno"], + frame["function"], + ) + for frame in frames + } + + new_frames = [ + stackframe + for stackframe in full_stack + if ( + stackframe["abs_path"], + stackframe["context_line"], + stackframe["lineno"], + stackframe["function"], + ) + not in frame_ids + ] + new_frames.extend(frames) + + # Limit the number of frames + max_stack_frames = ( + client_options.get("max_stack_frames", DEFAULT_MAX_STACK_FRAMES) + if client_options + else None + ) + if max_stack_frames is not None: + new_frames = new_frames[len(new_frames) - max_stack_frames :] + + return new_frames def event_from_exception( - exc_info, # type: Union[BaseException, ExcInfo] - client_options=None, # type: Optional[Dict[str, Any]] - mechanism=None, # type: Optional[Dict[str, Any]] -): - # type: (...) -> Tuple[Dict[str, Any], Dict[str, Any]] + exc_info: "Union[BaseException, ExcInfo]", + client_options: "Optional[Dict[str, Any]]" = None, + mechanism: "Optional[Dict[str, Any]]" = None, +) -> "Tuple[Event, Dict[str, Any]]": exc_info = exc_info_from_error(exc_info) hint = event_hint_with_exc_info(exc_info) + + if client_options and client_options.get("add_full_stack", DEFAULT_ADD_FULL_STACK): + full_stack = current_stacktrace( + include_local_variables=client_options["include_local_variables"], + max_value_length=client_options["max_value_length"], + )["frames"] + else: + full_stack = None + return ( { "level": "error", "exception": { "values": exceptions_from_error_tuple( - exc_info, client_options, mechanism + exc_info, client_options, mechanism, full_stack ) }, }, @@ -1069,8 +1145,7 @@ def event_from_exception( ) -def _module_in_list(name, items): - # type: (str, Optional[List[str]]) -> bool +def _module_in_list(name: "Optional[str]", items: "Optional[List[str]]") -> bool: if name is None: return False @@ -1084,18 +1159,21 @@ def _module_in_list(name, items): return False -def _is_external_source(abs_path): - # type: (str) -> bool +def _is_external_source(abs_path: "Optional[str]") -> bool: # check if frame is in 'site-packages' or 'dist-packages' + if abs_path is None: + return False + external_source = ( re.search(r"[\\/](?:dist|site)-packages[\\/]", abs_path) is not None ) return external_source -def _is_in_project_root(abs_path, project_root): - # type: (str, Optional[str]) -> bool - if project_root is None: +def _is_in_project_root( + abs_path: "Optional[str]", project_root: "Optional[str]" +) -> bool: + if abs_path is None or project_root is None: return False # check if path is in the project root @@ -1105,35 +1183,110 @@ def _is_in_project_root(abs_path, project_root): return False -def strip_string(value, max_length=None): - # type: (str, Optional[int]) -> Union[AnnotatedValue, str] +def _truncate_by_bytes(string: str, max_bytes: int) -> str: + """ + Truncate a UTF-8-encodable string to the last full codepoint so that it fits in max_bytes. + """ + truncated = string.encode("utf-8")[: max_bytes - 3].decode("utf-8", errors="ignore") + + return truncated + "..." + + +def _get_size_in_bytes(value: str) -> "Optional[int]": + try: + return len(value.encode("utf-8")) + except (UnicodeEncodeError, UnicodeDecodeError): + return None + + +def strip_string( + value: str, max_length: "Optional[int]" = None +) -> "Union[AnnotatedValue, str]": if not value: return value if max_length is None: max_length = DEFAULT_MAX_VALUE_LENGTH - length = len(value.encode("utf-8")) + byte_size = _get_size_in_bytes(value) + text_size = len(value) - if length > max_length: - return AnnotatedValue( - value=value[: max_length - 3] + "...", - metadata={ - "len": length, - "rem": [["!limit", "x", max_length - 3, max_length]], - }, + if byte_size is not None and byte_size > max_length: + # truncate to max_length bytes, preserving code points + truncated_value = _truncate_by_bytes(value, max_length) + elif text_size is not None and text_size > max_length: + # fallback to truncating by string length + truncated_value = value[: max_length - 3] + "..." + else: + return value + + return AnnotatedValue( + value=truncated_value, + metadata={ + "len": byte_size or text_size, + "rem": [["!limit", "x", max_length - 3, max_length]], + }, + ) + + +def parse_version(version: str) -> "Optional[Tuple[int, ...]]": + """ + Parses a version string into a tuple of integers. + This uses the parsing loging from PEP 440: + https://peps.python.org/pep-0440/#appendix-b-parsing-version-strings-with-regular-expressions + """ + VERSION_PATTERN = r""" # noqa: N806 + v? + (?: + (?:(?P[0-9]+)!)? # epoch + (?P[0-9]+(?:\.[0-9]+)*) # release segment + (?P
                                          # pre-release
+                [-_\.]?
+                (?P(a|b|c|rc|alpha|beta|pre|preview))
+                [-_\.]?
+                (?P[0-9]+)?
+            )?
+            (?P                                         # post release
+                (?:-(?P[0-9]+))
+                |
+                (?:
+                    [-_\.]?
+                    (?Ppost|rev|r)
+                    [-_\.]?
+                    (?P[0-9]+)?
+                )
+            )?
+            (?P                                          # dev release
+                [-_\.]?
+                (?Pdev)
+                [-_\.]?
+                (?P[0-9]+)?
+            )?
         )
-    return value
+        (?:\+(?P[a-z0-9]+(?:[-_\.][a-z0-9]+)*))?       # local version
+    """
+
+    pattern = re.compile(
+        r"^\s*" + VERSION_PATTERN + r"\s*$",
+        re.VERBOSE | re.IGNORECASE,
+    )
+
+    try:
+        release = pattern.match(version).groupdict()["release"]  # type: ignore
+        release_tuple: "Tuple[int, ...]" = tuple(map(int, release.split(".")[:3]))
+    except (TypeError, ValueError, AttributeError):
+        return None
+
+    return release_tuple
 
 
-def _is_contextvars_broken():
-    # type: () -> bool
+def _is_contextvars_broken() -> bool:
     """
     Returns whether gevent/eventlet have patched the stdlib in a way where thread locals are now more "correct" than contextvars.
     """
     try:
-        import gevent  # type: ignore
-        from gevent.monkey import is_object_patched  # type: ignore
+        import gevent
+        from gevent.monkey import is_object_patched
 
         # Get the MAJOR and MINOR version numbers of Gevent
         version_tuple = tuple(
@@ -1159,9 +1312,18 @@ def _is_contextvars_broken():
         pass
 
     try:
+        import greenlet
         from eventlet.patcher import is_monkey_patched  # type: ignore
 
-        if is_monkey_patched("thread"):
+        greenlet_version = parse_version(greenlet.__version__)
+
+        if greenlet_version is None:
+            logger.error(
+                "Internal error in Sentry SDK: Could not parse Greenlet version from greenlet.__version__."
+            )
+            return False
+
+        if is_monkey_patched("thread") and greenlet_version < (0, 5):
             return True
     except ImportError:
         pass
@@ -1169,29 +1331,35 @@ def _is_contextvars_broken():
     return False
 
 
-def _make_threadlocal_contextvars(local):
-    # type: (type) -> type
-    class ContextVar(object):
+def _make_threadlocal_contextvars(local: type) -> type:
+    class ContextVar:
         # Super-limited impl of ContextVar
 
-        def __init__(self, name):
-            # type: (str) -> None
+        def __init__(self, name: str, default: "Any" = None) -> None:
             self._name = name
+            self._default = default
             self._local = local()
+            self._original_local = local()
 
-        def get(self, default):
-            # type: (Any) -> Any
-            return getattr(self._local, "value", default)
+        def get(self, default: "Any" = None) -> "Any":
+            return getattr(self._local, "value", default or self._default)
 
-        def set(self, value):
-            # type: (Any) -> None
+        def set(self, value: "Any") -> "Any":
+            token = str(random.getrandbits(64))
+            original_value = self.get()
+            setattr(self._original_local, token, original_value)
             self._local.value = value
+            return token
+
+        def reset(self, token: "Any") -> None:
+            self._local.value = getattr(self._original_local, token)
+            # delete the original value (this way it works in Python 3.6+)
+            del self._original_local.__dict__[token]
 
     return ContextVar
 
 
-def _get_contextvars():
-    # type: () -> Tuple[bool, type]
+def _get_contextvars() -> "Tuple[bool, type]":
     """
     Figure out the "right" contextvars installation to use. Returns a
     `contextvars.ContextVar`-like class with a limited API.
@@ -1240,10 +1408,9 @@ def _get_contextvars():
 """
 
 
-def qualname_from_function(func):
-    # type: (Callable[..., Any]) -> Optional[str]
+def qualname_from_function(func: "Callable[..., Any]") -> "Optional[str]":
     """Return the qualified name of func. Works with regular function, lambda, partial and partialmethod."""
-    func_qualname = None  # type: Optional[str]
+    func_qualname: "Optional[str]" = None
 
     # Python 2
     try:
@@ -1257,16 +1424,18 @@ def qualname_from_function(func):
 
     prefix, suffix = "", ""
 
-    if (
-        _PARTIALMETHOD_AVAILABLE
-        and hasattr(func, "_partialmethod")
-        and isinstance(func._partialmethod, partialmethod)
-    ):
-        prefix, suffix = "partialmethod()"
-        func = func._partialmethod.func
-    elif isinstance(func, partial) and hasattr(func.func, "__name__"):
+    if isinstance(func, partial) and hasattr(func.func, "__name__"):
         prefix, suffix = "partial()"
         func = func.func
+    else:
+        # The _partialmethod attribute of methods wrapped with partialmethod() was renamed to __partialmethod__ in CPython 3.13:
+        # https://github.com/python/cpython/pull/16600
+        partial_method = getattr(func, "_partialmethod", None) or getattr(
+            func, "__partialmethod__", None
+        )
+        if isinstance(partial_method, partialmethod):
+            prefix, suffix = "partialmethod()"
+            func = partial_method.func
 
     if hasattr(func, "__qualname__"):
         func_qualname = func.__qualname__
@@ -1275,15 +1444,14 @@ def qualname_from_function(func):
 
     # Python 3: methods, functions, classes
     if func_qualname is not None:
-        if hasattr(func, "__module__"):
+        if hasattr(func, "__module__") and isinstance(func.__module__, str):
             func_qualname = func.__module__ + "." + func_qualname
         func_qualname = prefix + func_qualname + suffix
 
     return func_qualname
 
 
-def transaction_from_function(func):
-    # type: (Callable[..., Any]) -> Optional[str]
+def transaction_from_function(func: "Callable[..., Any]") -> "Optional[str]":
     return qualname_from_function(func)
 
 
@@ -1301,20 +1469,39 @@ class TimeoutThread(threading.Thread):
     waiting_time and raises a custom ServerlessTimeout exception.
     """
 
-    def __init__(self, waiting_time, configured_timeout):
-        # type: (float, int) -> None
+    def __init__(
+        self,
+        waiting_time: float,
+        configured_timeout: int,
+        isolation_scope: "Optional[sentry_sdk.Scope]" = None,
+        current_scope: "Optional[sentry_sdk.Scope]" = None,
+    ) -> None:
         threading.Thread.__init__(self)
         self.waiting_time = waiting_time
         self.configured_timeout = configured_timeout
+
+        self.isolation_scope = isolation_scope
+        self.current_scope = current_scope
+
         self._stop_event = threading.Event()
 
-    def stop(self):
-        # type: () -> None
+    def stop(self) -> None:
         self._stop_event.set()
 
-    def run(self):
-        # type: () -> None
+    def _capture_exception(self) -> "ExcInfo":
+        exc_info = sys.exc_info()
 
+        client = sentry_sdk.get_client()
+        event, hint = event_from_exception(
+            exc_info,
+            client_options=client.options,
+            mechanism={"type": "threading", "handled": False},
+        )
+        sentry_sdk.capture_event(event, hint=hint)
+
+        return exc_info
+
+    def run(self) -> None:
         self._stop_event.wait(self.waiting_time)
 
         if self._stop_event.is_set():
@@ -1327,6 +1514,18 @@ def run(self):
             integer_configured_timeout = integer_configured_timeout + 1
 
         # Raising Exception after timeout duration is reached
+        if self.isolation_scope is not None and self.current_scope is not None:
+            with sentry_sdk.scope.use_isolation_scope(self.isolation_scope):
+                with sentry_sdk.scope.use_scope(self.current_scope):
+                    try:
+                        raise ServerlessTimeoutWarning(
+                            "WARNING : Function is expected to get timed out. Configured timeout duration = {} seconds.".format(
+                                integer_configured_timeout
+                            )
+                        )
+                    except Exception:
+                        reraise(*self._capture_exception())
+
         raise ServerlessTimeoutWarning(
             "WARNING : Function is expected to get timed out. Configured timeout duration = {} seconds.".format(
                 integer_configured_timeout
@@ -1334,8 +1533,7 @@ def run(self):
         )
 
 
-def to_base64(original):
-    # type: (str) -> Optional[str]
+def to_base64(original: str) -> "Optional[str]":
     """
     Convert a string to base64, via UTF-8. Returns None on invalid input.
     """
@@ -1351,8 +1549,7 @@ def to_base64(original):
     return base64_string
 
 
-def from_base64(base64_string):
-    # type: (str) -> Optional[str]
+def from_base64(base64_string: str) -> "Optional[str]":
     """
     Convert a string from base64, via UTF-8. Returns None on invalid input.
     """
@@ -1376,8 +1573,12 @@ def from_base64(base64_string):
 Components = namedtuple("Components", ["scheme", "netloc", "path", "query", "fragment"])
 
 
-def sanitize_url(url, remove_authority=True, remove_query_values=True, split=False):
-    # type: (str, bool, bool, bool) -> Union[str, Components]
+def sanitize_url(
+    url: str,
+    remove_authority: bool = True,
+    remove_query_values: bool = True,
+    split: bool = False,
+) -> "Union[str, Components]":
     """
     Removes the authority and query parameter values from a given URL.
     """
@@ -1423,8 +1624,7 @@ def sanitize_url(url, remove_authority=True, remove_query_values=True, split=Fal
 ParsedUrl = namedtuple("ParsedUrl", ["url", "query", "fragment"])
 
 
-def parse_url(url, sanitize=True):
-    # type: (str, bool) -> ParsedUrl
+def parse_url(url: str, sanitize: bool = True) -> "ParsedUrl":
     """
     Splits a URL into a url (including path), query and fragment. If sanitize is True, the query
     parameters will be sanitized to remove sensitive data. The autority (username and password)
@@ -1451,8 +1651,7 @@ def parse_url(url, sanitize=True):
     )
 
 
-def is_valid_sample_rate(rate, source):
-    # type: (Any, str) -> bool
+def is_valid_sample_rate(rate: "Any", source: str) -> bool:
     """
     Checks the given sample rate to make sure it is valid type and value (a
     boolean or a number between 0 and 1, inclusive).
@@ -1482,8 +1681,11 @@ def is_valid_sample_rate(rate, source):
     return True
 
 
-def match_regex_list(item, regex_list=None, substring_matching=False):
-    # type: (str, Optional[List[str]], bool) -> bool
+def match_regex_list(
+    item: str,
+    regex_list: "Optional[List[str]]" = None,
+    substring_matching: bool = False,
+) -> bool:
     if regex_list is None:
         return False
 
@@ -1498,98 +1700,381 @@ def match_regex_list(item, regex_list=None, substring_matching=False):
     return False
 
 
-def is_sentry_url(hub, url):
-    # type: (sentry_sdk.Hub, str) -> bool
+def is_sentry_url(client: "sentry_sdk.client.BaseClient", url: str) -> bool:
     """
     Determines whether the given URL matches the Sentry DSN.
     """
     return (
-        hub.client is not None
-        and hub.client.transport is not None
-        and hub.client.transport.parsed_dsn is not None
-        and hub.client.transport.parsed_dsn.netloc in url
+        client is not None
+        and client.transport is not None
+        and client.transport.parsed_dsn is not None
+        and client.transport.parsed_dsn.netloc in url
     )
 
 
-def parse_version(version):
-    # type: (str) -> Optional[Tuple[int, ...]]
-    """
-    Parses a version string into a tuple of integers.
-    This uses the parsing loging from PEP 440:
-    https://peps.python.org/pep-0440/#appendix-b-parsing-version-strings-with-regular-expressions
-    """
-    VERSION_PATTERN = r"""  # noqa: N806
-        v?
-        (?:
-            (?:(?P[0-9]+)!)?                           # epoch
-            (?P[0-9]+(?:\.[0-9]+)*)                  # release segment
-            (?P
                                          # pre-release
-                [-_\.]?
-                (?P(a|b|c|rc|alpha|beta|pre|preview))
-                [-_\.]?
-                (?P[0-9]+)?
-            )?
-            (?P                                         # post release
-                (?:-(?P[0-9]+))
-                |
-                (?:
-                    [-_\.]?
-                    (?Ppost|rev|r)
-                    [-_\.]?
-                    (?P[0-9]+)?
-                )
-            )?
-            (?P                                          # dev release
-                [-_\.]?
-                (?Pdev)
-                [-_\.]?
-                (?P[0-9]+)?
-            )?
-        )
-        (?:\+(?P[a-z0-9]+(?:[-_\.][a-z0-9]+)*))?       # local version
-    """
+def _generate_installed_modules() -> "Iterator[Tuple[str, str]]":
+    try:
+        from importlib import metadata
+
+        yielded = set()
+        for dist in metadata.distributions():
+            name = dist.metadata.get("Name", None)  # type: ignore[attr-defined]
+            # `metadata` values may be `None`, see:
+            # https://github.com/python/cpython/issues/91216
+            # and
+            # https://github.com/python/importlib_metadata/issues/371
+            if name is not None:
+                normalized_name = _normalize_module_name(name)
+                if dist.version is not None and normalized_name not in yielded:
+                    yield normalized_name, dist.version
+                    yielded.add(normalized_name)
 
-    pattern = re.compile(
-        r"^\s*" + VERSION_PATTERN + r"\s*$",
-        re.VERBOSE | re.IGNORECASE,
+    except ImportError:
+        # < py3.8
+        try:
+            import pkg_resources
+        except ImportError:
+            return
+
+        for info in pkg_resources.working_set:
+            yield _normalize_module_name(info.key), info.version
+
+
+def _normalize_module_name(name: str) -> str:
+    return name.lower()
+
+
+def _replace_hyphens_dots_and_underscores_with_dashes(name: str) -> str:
+    # https://peps.python.org/pep-0503/#normalized-names
+    return re.sub(r"[-_.]+", "-", name)
+
+
+def _get_installed_modules() -> "Dict[str, str]":
+    global _installed_modules
+    if _installed_modules is None:
+        _installed_modules = dict(_generate_installed_modules())
+    return _installed_modules
+
+
+def package_version(package: str) -> "Optional[Tuple[int, ...]]":
+    normalized_package = _normalize_module_name(
+        _replace_hyphens_dots_and_underscores_with_dashes(package)
     )
 
-    try:
-        release = pattern.match(version).groupdict()["release"]  # type: ignore
-        release_tuple = tuple(map(int, release.split(".")[:3]))  # type: Tuple[int, ...]
-    except (TypeError, ValueError, AttributeError):
+    installed_packages = {
+        _replace_hyphens_dots_and_underscores_with_dashes(module): v
+        for module, v in _get_installed_modules().items()
+    }
+    version = installed_packages.get(normalized_package)
+    if version is None:
         return None
 
-    return release_tuple
+    return parse_version(version)
+
+
+def reraise(
+    tp: "Optional[Type[BaseException]]",
+    value: "Optional[BaseException]",
+    tb: "Optional[Any]" = None,
+) -> "NoReturn":
+    assert value is not None
+    if value.__traceback__ is not tb:
+        raise value.with_traceback(tb)
+    raise value
+
+
+def _no_op(*_a: "Any", **_k: "Any") -> None:
+    """No-op function for ensure_integration_enabled."""
+    pass
+
+
+if TYPE_CHECKING:
+
+    @overload
+    def ensure_integration_enabled(
+        integration: "type[sentry_sdk.integrations.Integration]",
+        original_function: "Callable[P, R]",
+    ) -> "Callable[[Callable[P, R]], Callable[P, R]]": ...
+
+    @overload
+    def ensure_integration_enabled(
+        integration: "type[sentry_sdk.integrations.Integration]",
+    ) -> "Callable[[Callable[P, None]], Callable[P, None]]": ...
+
+
+def ensure_integration_enabled(
+    integration: "type[sentry_sdk.integrations.Integration]",
+    original_function: "Union[Callable[P, R], Callable[P, None]]" = _no_op,
+) -> "Callable[[Callable[P, R]], Callable[P, R]]":
+    """
+    Ensures a given integration is enabled prior to calling a Sentry-patched function.
+
+    The function takes as its parameters the integration that must be enabled and the original
+    function that the SDK is patching. The function returns a function that takes the
+    decorated (Sentry-patched) function as its parameter, and returns a function that, when
+    called, checks whether the given integration is enabled. If the integration is enabled, the
+    function calls the decorated, Sentry-patched function. If the integration is not enabled,
+    the original function is called.
+
+    The function also takes care of preserving the original function's signature and docstring.
+
+    Example usage:
+
+    ```python
+    @ensure_integration_enabled(MyIntegration, my_function)
+    def patch_my_function():
+        with sentry_sdk.start_transaction(...):
+            return my_function()
+    ```
+    """
+    if TYPE_CHECKING:
+        # Type hint to ensure the default function has the right typing. The overloads
+        # ensure the default _no_op function is only used when R is None.
+        original_function = cast(Callable[P, R], original_function)
+
+    def patcher(sentry_patched_function: "Callable[P, R]") -> "Callable[P, R]":
+        def runner(*args: "P.args", **kwargs: "P.kwargs") -> "R":
+            if sentry_sdk.get_client().get_integration(integration) is None:
+                return original_function(*args, **kwargs)
+
+            return sentry_patched_function(*args, **kwargs)
+
+        if original_function is _no_op:
+            return wraps(sentry_patched_function)(runner)
+
+        return wraps(original_function)(runner)
+
+    return patcher
 
 
 if PY37:
 
-    def nanosecond_time():
-        # type: () -> int
+    def nanosecond_time() -> int:
         return time.perf_counter_ns()
 
-elif PY33:
+else:
 
-    def nanosecond_time():
-        # type: () -> int
+    def nanosecond_time() -> int:
         return int(time.perf_counter() * 1e9)
 
-else:
 
-    def nanosecond_time():
-        # type: () -> int
-        return int(time.time() * 1e9)
+def now() -> float:
+    return time.perf_counter()
 
 
-if PY2:
+try:
+    from gevent import get_hub as get_gevent_hub
+    from gevent.monkey import is_module_patched
+except ImportError:
+    # it's not great that the signatures are different, get_hub can't return None
+    # consider adding an if TYPE_CHECKING to change the signature to Optional[Hub]
+    def get_gevent_hub() -> "Optional[Hub]":  # type: ignore[misc]
+        return None
 
-    def now():
-        # type: () -> float
-        return time.time()
+    def is_module_patched(mod_name: str) -> bool:
+        # unable to import from gevent means no modules have been patched
+        return False
+
+
+def is_gevent() -> bool:
+    return is_module_patched("threading") or is_module_patched("_thread")
+
+
+def get_current_thread_meta(
+    thread: "Optional[threading.Thread]" = None,
+) -> "Tuple[Optional[int], Optional[str]]":
+    """
+    Try to get the id of the current thread, with various fall backs.
+    """
+
+    # if a thread is specified, that takes priority
+    if thread is not None:
+        try:
+            thread_id = thread.ident
+            thread_name = thread.name
+            if thread_id is not None:
+                return thread_id, thread_name
+        except AttributeError:
+            pass
+
+    # if the app is using gevent, we should look at the gevent hub first
+    # as the id there differs from what the threading module reports
+    if is_gevent():
+        gevent_hub = get_gevent_hub()
+        if gevent_hub is not None:
+            try:
+                # this is undocumented, so wrap it in try except to be safe
+                return gevent_hub.thread_ident, None
+            except AttributeError:
+                pass
+
+    # use the current thread's id if possible
+    try:
+        thread = threading.current_thread()
+        thread_id = thread.ident
+        thread_name = thread.name
+        if thread_id is not None:
+            return thread_id, thread_name
+    except AttributeError:
+        pass
+
+    # if we can't get the current thread id, fall back to the main thread id
+    try:
+        thread = threading.main_thread()
+        thread_id = thread.ident
+        thread_name = thread.name
+        if thread_id is not None:
+            return thread_id, thread_name
+    except AttributeError:
+        pass
+
+    # we've tried everything, time to give up
+    return None, None
+
+
+def should_be_treated_as_error(ty: "Any", value: "Any") -> bool:
+    if ty == SystemExit and hasattr(value, "code") and value.code in (0, None):
+        # https://docs.python.org/3/library/exceptions.html#SystemExit
+        return False
+
+    return True
+
+
+if TYPE_CHECKING:
+    T = TypeVar("T")
+
+
+def try_convert(convert_func: "Callable[[Any], T]", value: "Any") -> "Optional[T]":
+    """
+    Attempt to convert from an unknown type to a specific type, using the
+    given function. Return None if the conversion fails, i.e. if the function
+    raises an exception.
+    """
+    try:
+        if isinstance(value, convert_func):  # type: ignore
+            return value
+    except TypeError:
+        pass
+
+    try:
+        return convert_func(value)
+    except Exception:
+        return None
+
+
+def safe_serialize(data: "Any") -> str:
+    """Safely serialize to a readable string."""
+
+    def serialize_item(
+        item: "Any",
+    ) -> "Union[str, dict[Any, Any], list[Any], tuple[Any, ...]]":
+        if callable(item):
+            try:
+                module = getattr(item, "__module__", None)
+                qualname = getattr(item, "__qualname__", None)
+                name = getattr(item, "__name__", "anonymous")
+
+                if module and qualname:
+                    full_path = f"{module}.{qualname}"
+                elif module and name:
+                    full_path = f"{module}.{name}"
+                else:
+                    full_path = name
+
+                return f""
+            except Exception:
+                return f""
+        elif isinstance(item, dict):
+            return {k: serialize_item(v) for k, v in item.items()}
+        elif isinstance(item, (list, tuple)):
+            return [serialize_item(x) for x in item]
+        elif hasattr(item, "__dict__"):
+            try:
+                attrs = {
+                    k: serialize_item(v)
+                    for k, v in vars(item).items()
+                    if not k.startswith("_")
+                }
+                return f"<{type(item).__name__} {attrs}>"
+            except Exception:
+                return repr(item)
+        else:
+            return item
+
+    try:
+        serialized = serialize_item(data)
+        return json.dumps(serialized, default=str)
+    except Exception:
+        return str(data)
 
-else:
 
-    def now():
-        # type: () -> float
-        return time.perf_counter()
+def has_logs_enabled(options: "Optional[dict[str, Any]]") -> bool:
+    if options is None:
+        return False
+
+    return bool(
+        options.get("enable_logs", False)
+        or options["_experiments"].get("enable_logs", False)
+    )
+
+
+def get_before_send_log(
+    options: "Optional[dict[str, Any]]",
+) -> "Optional[Callable[[Log, Hint], Optional[Log]]]":
+    if options is None:
+        return None
+
+    return options.get("before_send_log") or options["_experiments"].get(
+        "before_send_log"
+    )
+
+
+def has_metrics_enabled(options: "Optional[dict[str, Any]]") -> bool:
+    if options is None:
+        return False
+
+    return bool(options.get("enable_metrics", True))
+
+
+def get_before_send_metric(
+    options: "Optional[dict[str, Any]]",
+) -> "Optional[Callable[[Metric, Hint], Optional[Metric]]]":
+    if options is None:
+        return None
+
+    return options.get("before_send_metric") or options["_experiments"].get(
+        "before_send_metric"
+    )
+
+
+def format_attribute(val: "Any") -> "AttributeValue":
+    """
+    Turn unsupported attribute value types into an AttributeValue.
+
+    We do this as soon as a user-provided attribute is set, to prevent spans,
+    logs, metrics and similar from having live references to various objects.
+
+    Note: This is not the final attribute value format. Before they're sent,
+    they're serialized further into the actual format the protocol expects:
+    https://develop.sentry.dev/sdk/telemetry/attributes/
+    """
+    if isinstance(val, (bool, int, float, str)):
+        return val
+
+    return safe_repr(val)
+
+
+def serialize_attribute(val: "AttributeValue") -> "SerializedAttributeValue":
+    """Serialize attribute value to the transport format."""
+    if isinstance(val, bool):
+        return {"value": val, "type": "boolean"}
+    if isinstance(val, int):
+        return {"value": val, "type": "integer"}
+    if isinstance(val, float):
+        return {"value": val, "type": "double"}
+    if isinstance(val, str):
+        return {"value": val, "type": "string"}
+
+    # Coerce to string if we don't know what to do with the value. This should
+    # never happen as we pre-format early in format_attribute, but let's be safe.
+    return {"value": safe_repr(val), "type": "string"}
diff --git a/sentry_sdk/worker.py b/sentry_sdk/worker.py
index 2fe81a8d70..3d85a653d6 100644
--- a/sentry_sdk/worker.py
+++ b/sentry_sdk/worker.py
@@ -2,12 +2,11 @@
 import threading
 
 from time import sleep, time
-from sentry_sdk._compat import check_thread_support
 from sentry_sdk._queue import Queue, FullError
 from sentry_sdk.utils import logger
 from sentry_sdk.consts import DEFAULT_QUEUE_SIZE
 
-from sentry_sdk._types import TYPE_CHECKING
+from typing import TYPE_CHECKING
 
 if TYPE_CHECKING:
     from typing import Any
@@ -18,31 +17,26 @@
 _TERMINATOR = object()
 
 
-class BackgroundWorker(object):
-    def __init__(self, queue_size=DEFAULT_QUEUE_SIZE):
-        # type: (int) -> None
-        check_thread_support()
-        self._queue = Queue(queue_size)  # type: Queue
+class BackgroundWorker:
+    def __init__(self, queue_size: int = DEFAULT_QUEUE_SIZE) -> None:
+        self._queue: "Queue" = Queue(queue_size)
         self._lock = threading.Lock()
-        self._thread = None  # type: Optional[threading.Thread]
-        self._thread_for_pid = None  # type: Optional[int]
+        self._thread: "Optional[threading.Thread]" = None
+        self._thread_for_pid: "Optional[int]" = None
 
     @property
-    def is_alive(self):
-        # type: () -> bool
+    def is_alive(self) -> bool:
         if self._thread_for_pid != os.getpid():
             return False
         if not self._thread:
             return False
         return self._thread.is_alive()
 
-    def _ensure_thread(self):
-        # type: () -> None
+    def _ensure_thread(self) -> None:
         if not self.is_alive:
             self.start()
 
-    def _timed_queue_join(self, timeout):
-        # type: (float) -> bool
+    def _timed_queue_join(self, timeout: float) -> bool:
         deadline = time() + timeout
         queue = self._queue
 
@@ -59,19 +53,23 @@ def _timed_queue_join(self, timeout):
         finally:
             queue.all_tasks_done.release()
 
-    def start(self):
-        # type: () -> None
+    def start(self) -> None:
         with self._lock:
             if not self.is_alive:
                 self._thread = threading.Thread(
-                    target=self._target, name="raven-sentry.BackgroundWorker"
+                    target=self._target, name="sentry-sdk.BackgroundWorker"
                 )
                 self._thread.daemon = True
-                self._thread.start()
-                self._thread_for_pid = os.getpid()
-
-    def kill(self):
-        # type: () -> None
+                try:
+                    self._thread.start()
+                    self._thread_for_pid = os.getpid()
+                except RuntimeError:
+                    # At this point we can no longer start because the interpreter
+                    # is already shutting down.  Sadly at this point we can no longer
+                    # send out events.
+                    self._thread = None
+
+    def kill(self) -> None:
         """
         Kill worker thread. Returns immediately. Not useful for
         waiting on shutdown for events, use `flush` for that.
@@ -87,20 +85,17 @@ def kill(self):
                 self._thread = None
                 self._thread_for_pid = None
 
-    def flush(self, timeout, callback=None):
-        # type: (float, Optional[Any]) -> None
+    def flush(self, timeout: float, callback: "Optional[Any]" = None) -> None:
         logger.debug("background worker got flush request")
         with self._lock:
             if self.is_alive and timeout > 0.0:
                 self._wait_flush(timeout, callback)
         logger.debug("background worker flushed")
 
-    def full(self):
-        # type: () -> bool
+    def full(self) -> bool:
         return self._queue.full()
 
-    def _wait_flush(self, timeout, callback):
-        # type: (float, Optional[Any]) -> None
+    def _wait_flush(self, timeout: float, callback: "Optional[Any]") -> None:
         initial_timeout = min(0.1, timeout)
         if not self._timed_queue_join(initial_timeout):
             pending = self._queue.qsize() + 1
@@ -112,8 +107,7 @@ def _wait_flush(self, timeout, callback):
                 pending = self._queue.qsize() + 1
                 logger.error("flush timed out, dropped %s events", pending)
 
-    def submit(self, callback):
-        # type: (Callable[[], None]) -> bool
+    def submit(self, callback: "Callable[[], None]") -> bool:
         self._ensure_thread()
         try:
             self._queue.put_nowait(callback)
@@ -121,8 +115,7 @@ def submit(self, callback):
         except FullError:
             return False
 
-    def _target(self):
-        # type: () -> None
+    def _target(self) -> None:
         while True:
             callback = self._queue.get()
             try:
diff --git a/setup.py b/setup.py
index a815df7d61..a76dbc00d0 100644
--- a/setup.py
+++ b/setup.py
@@ -21,7 +21,7 @@ def get_file_text(file_name):
 
 setup(
     name="sentry-sdk",
-    version="1.32.0",
+    version="2.48.0",
     author="Sentry Team and Contributors",
     author_email="hello@sentry.io",
     url="https://github.com/getsentry/sentry-python",
@@ -37,41 +37,45 @@ def get_file_text(file_name):
     package_data={"sentry_sdk": ["py.typed"]},
     zip_safe=False,
     license="MIT",
+    python_requires=">=3.6",
     install_requires=[
-        'urllib3>=1.25.7; python_version<="3.4"',
-        'urllib3>=1.26.9; python_version=="3.5"',
-        'urllib3>=1.26.11; python_version>="3.6"',
+        "urllib3>=1.26.11",
         "certifi",
     ],
     extras_require={
         "aiohttp": ["aiohttp>=3.5"],
+        "anthropic": ["anthropic>=0.16"],
         "arq": ["arq>=0.23"],
         "asyncpg": ["asyncpg>=0.23"],
         "beam": ["apache-beam>=2.12"],
         "bottle": ["bottle>=0.12.13"],
         "celery": ["celery>=3"],
+        "celery-redbeat": ["celery-redbeat>=2"],
         "chalice": ["chalice>=1.16.0"],
         "clickhouse-driver": ["clickhouse-driver>=0.2.0"],
         "django": ["django>=1.8"],
         "falcon": ["falcon>=1.4"],
         "fastapi": ["fastapi>=0.79.0"],
         "flask": ["flask>=0.11", "blinker>=1.1", "markupsafe"],
-        "grpcio": ["grpcio>=1.21.1"],
+        "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"],
+        "langchain": ["langchain>=0.0.210"],
+        "langgraph": ["langgraph>=0.6.6"],
+        "launchdarkly": ["launchdarkly-server-sdk>=9.8.0"],
+        "litellm": ["litellm>=1.77.5"],
+        "litestar": ["litestar>=2.0.0"],
         "loguru": ["loguru>=0.5"],
+        "mcp": ["mcp>=1.15.0"],
+        "openai": ["openai>=1.0.0", "tiktoken>=0.3.0"],
+        "openfeature": ["openfeature-sdk>=0.7.1"],
         "opentelemetry": ["opentelemetry-distro>=0.35b0"],
-        "opentelemetry-experimental": [
-            "opentelemetry-distro~=0.40b0",
-            "opentelemetry-instrumentation-aiohttp-client~=0.40b0",
-            "opentelemetry-instrumentation-django~=0.40b0",
-            "opentelemetry-instrumentation-fastapi~=0.40b0",
-            "opentelemetry-instrumentation-flask~=0.40b0",
-            "opentelemetry-instrumentation-requests~=0.40b0",
-            "opentelemetry-instrumentation-sqlite3~=0.40b0",
-            "opentelemetry-instrumentation-urllib~=0.40b0",
-        ],
-        "pure_eval": ["pure_eval", "executing", "asttokens"],
+        "opentelemetry-experimental": ["opentelemetry-distro"],
+        "opentelemetry-otlp": ["opentelemetry-distro[otlp]>=0.35b0"],
+        "pure-eval": ["pure_eval", "executing", "asttokens"],
+        "pydantic_ai": ["pydantic-ai>=1.0.0"],
         "pymongo": ["pymongo>=3.1"],
         "pyspark": ["pyspark>=2.4.4"],
         "quart": ["quart>=0.16.1", "blinker>=1.1"],
@@ -80,7 +84,15 @@ def get_file_text(file_name):
         "sqlalchemy": ["sqlalchemy>=1.2"],
         "starlette": ["starlette>=0.19.1"],
         "starlite": ["starlite>=1.48"],
-        "tornado": ["tornado>=5"],
+        "statsig": ["statsig>=0.55.3"],
+        "tornado": ["tornado>=6"],
+        "unleash": ["UnleashClient>=6.0.1"],
+        "google-genai": ["google-genai>=1.29.0"],
+    },
+    entry_points={
+        "opentelemetry_propagator": [
+            "sentry=sentry_sdk.integrations.opentelemetry:SentryPropagator"
+        ]
     },
     classifiers=[
         "Development Status :: 5 - Production/Stable",
@@ -89,17 +101,16 @@ def get_file_text(file_name):
         "License :: OSI Approved :: BSD License",
         "Operating System :: OS Independent",
         "Programming Language :: Python",
-        "Programming Language :: Python :: 2",
-        "Programming Language :: Python :: 2.7",
         "Programming Language :: Python :: 3",
-        "Programming Language :: Python :: 3.4",
-        "Programming Language :: Python :: 3.5",
         "Programming Language :: Python :: 3.6",
         "Programming Language :: Python :: 3.7",
         "Programming Language :: Python :: 3.8",
         "Programming Language :: Python :: 3.9",
         "Programming Language :: Python :: 3.10",
         "Programming Language :: Python :: 3.11",
+        "Programming Language :: Python :: 3.12",
+        "Programming Language :: Python :: 3.13",
+        "Programming Language :: Python :: 3.14",
         "Topic :: Software Development :: Libraries :: Python Modules",
     ],
     options={"bdist_wheel": {"universal": "1"}},
diff --git a/test-requirements.txt b/test-requirements.txt
deleted file mode 100644
index 5933388bed..0000000000
--- a/test-requirements.txt
+++ /dev/null
@@ -1,15 +0,0 @@
-pip  # always use newest pip
-mock ; python_version<'3.3'
-pytest<7
-pytest-cov==2.8.1
-pytest-forked<=1.4.0
-pytest-localserver==0.5.0
-pytest-watch==4.2.0
-tox==3.7.0
-jsonschema==3.2.0
-pyrsistent==0.16.0 # TODO(py3): 0.17.0 requires python3, see https://github.com/tobgu/pyrsistent/issues/205
-executing<2.0.0  # TODO(py3): 2.0.0 requires python3
-asttokens
-responses
-pysocks
-ipdb
diff --git a/tests/__init__.py b/tests/__init__.py
index cac15f9333..2e4df719d5 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -1,6 +1,5 @@
 import sys
-
-import pytest
+import warnings
 
 # This is used in _capture_internal_warnings. We need to run this at import
 # time because that's where many deprecation warnings might get thrown.
@@ -9,5 +8,5 @@
 # gets loaded too late.
 assert "sentry_sdk" not in sys.modules
 
-_warning_recorder_mgr = pytest.warns(None)
+_warning_recorder_mgr = warnings.catch_warnings(record=True)
 _warning_recorder = _warning_recorder_mgr.__enter__()
diff --git a/tests/conftest.py b/tests/conftest.py
index d9d88067dc..dea36f8bda 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,11 +1,22 @@
 import json
 import os
 import socket
+import warnings
+import brotli
+import gzip
+import io
 from threading import Thread
+from contextlib import contextmanager
+from http.server import BaseHTTPRequestHandler, HTTPServer
+from unittest import mock
+from collections import namedtuple
 
 import pytest
+from pytest_localserver.http import WSGIServer
+from werkzeug.wrappers import Request, Response
 import jsonschema
 
+
 try:
     import gevent
 except ImportError:
@@ -16,27 +27,27 @@
 except ImportError:
     eventlet = None
 
-try:
-    # Python 2
-    import BaseHTTPServer
-
-    HTTPServer = BaseHTTPServer.HTTPServer
-    BaseHTTPRequestHandler = BaseHTTPServer.BaseHTTPRequestHandler
-except Exception:
-    # Python 3
-    from http.server import BaseHTTPRequestHandler, HTTPServer
-
-
 import sentry_sdk
-from sentry_sdk._compat import iteritems, reraise, string_types
-from sentry_sdk.envelope import Envelope
-from sentry_sdk.integrations import _installed_integrations  # noqa: F401
+import sentry_sdk.utils
+from sentry_sdk.envelope import Envelope, parse_json
+from sentry_sdk.integrations import (  # noqa: F401
+    _DEFAULT_INTEGRATIONS,
+    _installed_integrations,
+    _processed_integrations,
+)
 from sentry_sdk.profiler import teardown_profiler
+from sentry_sdk.profiler.continuous_profiler import teardown_continuous_profiler
 from sentry_sdk.transport import Transport
-from sentry_sdk.utils import capture_internal_exceptions
+from sentry_sdk.utils import reraise
 
 from tests import _warning_recorder, _warning_recorder_mgr
 
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+    from typing import Optional
+    from collections.abc import Iterator
+
 
 SENTRY_EVENT_SCHEMA = "./checkouts/data-schemas/relay/event.schema.json"
 
@@ -46,25 +57,27 @@
     with open(SENTRY_EVENT_SCHEMA) as f:
         SENTRY_EVENT_SCHEMA = json.load(f)
 
-try:
-    import pytest_benchmark
-except ImportError:
 
-    @pytest.fixture
-    def benchmark():
-        return lambda x: x()
+from sentry_sdk import scope
 
-else:
-    del pytest_benchmark
+
+@pytest.fixture(autouse=True)
+def clean_scopes():
+    """
+    Resets the scopes for every test to avoid leaking data between tests.
+    """
+    scope._global_scope = None
+    scope._isolation_scope.set(None)
+    scope._current_scope.set(None)
 
 
 @pytest.fixture(autouse=True)
-def internal_exceptions(request, monkeypatch):
+def internal_exceptions(request):
     errors = []
     if "tests_internal_exceptions" in request.keywords:
         return
 
-    def _capture_internal_exception(self, exc_info):
+    def _capture_internal_exception(exc_info):
         errors.append(exc_info)
 
     @request.addfinalizer
@@ -74,9 +87,7 @@ def _():
         for e in errors:
             reraise(*e)
 
-    monkeypatch.setattr(
-        sentry_sdk.Hub, "_capture_internal_exception", _capture_internal_exception
-    )
+    sentry_sdk.utils.capture_internal_exception = _capture_internal_exception
 
     return errors
 
@@ -142,35 +153,6 @@ def _capture_internal_warnings():
         raise AssertionError(warning)
 
 
-@pytest.fixture
-def monkeypatch_test_transport(monkeypatch, validate_event_schema):
-    def check_event(event):
-        def check_string_keys(map):
-            for key, value in iteritems(map):
-                assert isinstance(key, string_types)
-                if isinstance(value, dict):
-                    check_string_keys(value)
-
-        with capture_internal_exceptions():
-            check_string_keys(event)
-            validate_event_schema(event)
-
-    def check_envelope(envelope):
-        with capture_internal_exceptions():
-            # There used to be a check here for errors are not sent in envelopes.
-            # We changed the behaviour to send errors in envelopes when tracing is enabled.
-            # This is checked in test_client.py::test_sending_events_with_tracing
-            # and test_client.py::test_sending_events_with_no_tracing
-            pass
-
-    def inner(client):
-        monkeypatch.setattr(
-            client, "transport", TestTransport(check_event, check_envelope)
-        )
-
-    return inner
-
-
 @pytest.fixture
 def validate_event_schema(tmpdir):
     def inner(event):
@@ -187,18 +169,34 @@ def reset_integrations():
     with a clean slate to ensure monkeypatching works well,
     but this also means some other stuff will be monkeypatched twice.
     """
-    global _installed_integrations
+    global _DEFAULT_INTEGRATIONS, _processed_integrations
+    try:
+        _DEFAULT_INTEGRATIONS.remove(
+            "sentry_sdk.integrations.opentelemetry.integration.OpenTelemetryIntegration"
+        )
+    except ValueError:
+        pass
+    _processed_integrations.clear()
     _installed_integrations.clear()
 
 
 @pytest.fixture
-def sentry_init(monkeypatch_test_transport, request):
+def uninstall_integration():
+    """Use to force the next call to sentry_init to re-install/setup an integration."""
+
+    def inner(identifier):
+        _processed_integrations.discard(identifier)
+        _installed_integrations.discard(identifier)
+
+    return inner
+
+
+@pytest.fixture
+def sentry_init(request):
     def inner(*a, **kw):
-        hub = sentry_sdk.Hub.current
+        kw.setdefault("transport", TestTransport())
         client = sentry_sdk.Client(*a, **kw)
-        hub.bind_client(client)
-        if "transport" not in kw:
-            monkeypatch_test_transport(sentry_sdk.Hub.current.client)
+        sentry_sdk.get_global_scope().set_client(client)
 
     if request.node.get_closest_marker("forked"):
         # Do not run isolation if the test is already running in
@@ -206,38 +204,51 @@ def inner(*a, **kw):
         # fork)
         yield inner
     else:
-        with sentry_sdk.Hub(None):
+        old_client = sentry_sdk.get_global_scope().client
+        try:
+            sentry_sdk.get_current_scope().set_client(None)
             yield inner
+        finally:
+            sentry_sdk.get_global_scope().set_client(old_client)
 
 
 class TestTransport(Transport):
-    def __init__(self, capture_event_callback, capture_envelope_callback):
+    def __init__(self):
         Transport.__init__(self)
-        self.capture_event = capture_event_callback
-        self.capture_envelope = capture_envelope_callback
-        self._queue = None
+
+    def capture_envelope(self, _: Envelope) -> None:
+        """No-op capture_envelope for tests"""
+        pass
+
+
+class TestTransportWithOptions(Transport):
+    """TestTransport above does not pass in the options and for some tests we need them"""
+
+    __test__ = False
+
+    def __init__(self, options=None):
+        Transport.__init__(self, options)
+
+    def capture_envelope(self, _: Envelope) -> None:
+        """No-op capture_envelope for tests"""
+        pass
 
 
 @pytest.fixture
 def capture_events(monkeypatch):
     def inner():
         events = []
-        test_client = sentry_sdk.Hub.current.client
-        old_capture_event = test_client.transport.capture_event
+        test_client = sentry_sdk.get_client()
         old_capture_envelope = test_client.transport.capture_envelope
 
-        def append_event(event):
-            events.append(event)
-            return old_capture_event(event)
-
-        def append_envelope(envelope):
+        def append_event(envelope):
             for item in envelope:
                 if item.headers.get("type") in ("event", "transaction"):
-                    test_client.transport.capture_event(item.payload.json)
+                    events.append(item.payload.json)
             return old_capture_envelope(envelope)
 
-        monkeypatch.setattr(test_client.transport, "capture_event", append_event)
-        monkeypatch.setattr(test_client.transport, "capture_envelope", append_envelope)
+        monkeypatch.setattr(test_client.transport, "capture_envelope", append_event)
+
         return events
 
     return inner
@@ -247,42 +258,33 @@ def append_envelope(envelope):
 def capture_envelopes(monkeypatch):
     def inner():
         envelopes = []
-        test_client = sentry_sdk.Hub.current.client
-        old_capture_event = test_client.transport.capture_event
+        test_client = sentry_sdk.get_client()
         old_capture_envelope = test_client.transport.capture_envelope
 
-        def append_event(event):
-            envelope = Envelope()
-            envelope.add_event(event)
-            envelopes.append(envelope)
-            return old_capture_event(event)
-
         def append_envelope(envelope):
             envelopes.append(envelope)
             return old_capture_envelope(envelope)
 
-        monkeypatch.setattr(test_client.transport, "capture_event", append_event)
         monkeypatch.setattr(test_client.transport, "capture_envelope", append_envelope)
+
         return envelopes
 
     return inner
 
 
 @pytest.fixture
-def capture_client_reports(monkeypatch):
+def capture_record_lost_event_calls(monkeypatch):
     def inner():
-        reports = []
-        test_client = sentry_sdk.Hub.current.client
+        calls = []
+        test_client = sentry_sdk.get_client()
 
-        def record_lost_event(reason, data_category=None, item=None):
-            if data_category is None:
-                data_category = item.data_category
-            return reports.append((reason, data_category))
+        def record_lost_event(reason, data_category=None, item=None, *, quantity=1):
+            calls.append((reason, data_category, item, quantity))
 
         monkeypatch.setattr(
             test_client.transport, "record_lost_event", record_lost_event
         )
-        return reports
+        return calls
 
     return inner
 
@@ -296,19 +298,21 @@ def inner():
         events_r = os.fdopen(events_r, "rb", 0)
         events_w = os.fdopen(events_w, "wb", 0)
 
-        test_client = sentry_sdk.Hub.current.client
+        test_client = sentry_sdk.get_client()
 
-        old_capture_event = test_client.transport.capture_event
+        old_capture_envelope = test_client.transport.capture_envelope
 
-        def append(event):
-            events_w.write(json.dumps(event).encode("utf-8"))
-            events_w.write(b"\n")
-            return old_capture_event(event)
+        def append(envelope):
+            event = envelope.get_event() or envelope.get_transaction_event()
+            if event is not None:
+                events_w.write(json.dumps(event).encode("utf-8"))
+                events_w.write(b"\n")
+            return old_capture_envelope(envelope)
 
         def flush(timeout=None, callback=None):
             events_w.write(b"flush\n")
 
-        monkeypatch.setattr(test_client.transport, "capture_event", append)
+        monkeypatch.setattr(test_client.transport, "capture_envelope", append)
         monkeypatch.setattr(test_client, "flush", flush)
 
         return EventStreamReader(events_r, events_w)
@@ -316,7 +320,7 @@ def flush(timeout=None, callback=None):
     return inner
 
 
-class EventStreamReader(object):
+class EventStreamReader:
     def __init__(self, read_file, write_file):
         self.read_file = read_file
         self.write_file = write_file
@@ -382,7 +386,6 @@ def render_span(span):
 
         root_span = event["contexts"]["trace"]
 
-        # Return a list instead of a multiline string because black will know better how to format that
         return "\n".join(render_span(root_span))
 
     return inner
@@ -408,16 +411,10 @@ def string_containing_matcher():
 
     """
 
-    class StringContaining(object):
+    class StringContaining:
         def __init__(self, substring):
             self.substring = substring
-
-            try:
-                # the `unicode` type only exists in python 2, so if this blows up,
-                # we must be in py3 and have the `bytes` type
-                self.valid_types = (str, unicode)
-            except NameError:
-                self.valid_types = (str, bytes)
+            self.valid_types = (str, bytes)
 
         def __eq__(self, test_string):
             if not isinstance(test_string, self.valid_types):
@@ -491,7 +488,7 @@ def dictionary_containing_matcher():
     >>> f.assert_any_call(DictionaryContaining({"dogs": "yes"})) # no AssertionError
     """
 
-    class DictionaryContaining(object):
+    class DictionaryContaining:
         def __init__(self, subdict):
             self.subdict = subdict
 
@@ -531,7 +528,7 @@ def object_described_by_matcher():
 
     Used like this:
 
-    >>> class Dog(object):
+    >>> class Dog:
     ...     pass
     ...
     >>> maisey = Dog()
@@ -543,7 +540,7 @@ def object_described_by_matcher():
     >>> f.assert_any_call(ObjectDescribedBy(attrs={"name": "Maisey"})) # no AssertionError
     """
 
-    class ObjectDescribedBy(object):
+    class ObjectDescribedBy:
         def __init__(self, type=None, attrs=None):
             self.type = type
             self.attrs = attrs
@@ -573,14 +570,38 @@ def __ne__(self, test_obj):
 
 @pytest.fixture
 def teardown_profiling():
+    # Make sure that a previous test didn't leave the profiler running
+    teardown_profiler()
+    teardown_continuous_profiler()
+
     yield
+
+    # Make sure that to shut down the profiler after the test
     teardown_profiler()
+    teardown_continuous_profiler()
+
+
+@pytest.fixture()
+def suppress_deprecation_warnings():
+    """
+    Use this fixture to suppress deprecation warnings in a test.
+    Useful for testing deprecated SDK features.
+    """
+    with warnings.catch_warnings():
+        warnings.simplefilter("ignore", DeprecationWarning)
+        yield
 
 
 class MockServerRequestHandler(BaseHTTPRequestHandler):
     def do_GET(self):  # noqa: N802
-        # Process an HTTP GET request and return a response with an HTTP 200 status.
-        self.send_response(200)
+        # Process an HTTP GET request and return a response.
+        # If the path ends with /status/, return status code .
+        # Otherwise return a 200 response.
+        code = 200
+        if "/status/" in self.path:
+            code = int(self.path[-3:])
+
+        self.send_response(code)
         self.end_headers()
         return
 
@@ -598,7 +619,109 @@ def create_mock_http_server():
     mock_server_port = get_free_port()
     mock_server = HTTPServer(("localhost", mock_server_port), MockServerRequestHandler)
     mock_server_thread = Thread(target=mock_server.serve_forever)
-    mock_server_thread.setDaemon(True)
+    mock_server_thread.daemon = True
     mock_server_thread.start()
 
     return mock_server_port
+
+
+def unpack_werkzeug_response(response):
+    # werkzeug < 2.1 returns a tuple as client response, newer versions return
+    # an object
+    try:
+        return response.get_data(), response.status, response.headers
+    except AttributeError:
+        content, status, headers = response
+        return b"".join(content), status, headers
+
+
+def werkzeug_set_cookie(client, servername, key, value):
+    # client.set_cookie has a different signature in different werkzeug versions
+    try:
+        client.set_cookie(servername, key, value)
+    except TypeError:
+        client.set_cookie(key, value)
+
+
+@contextmanager
+def patch_start_tracing_child(
+    fake_transaction_is_none: bool = False,
+) -> "Iterator[Optional[mock.MagicMock]]":
+    if not fake_transaction_is_none:
+        fake_transaction = mock.MagicMock()
+        fake_start_child = mock.MagicMock()
+        fake_transaction.start_child = fake_start_child
+    else:
+        fake_transaction = None
+        fake_start_child = None
+
+    with mock.patch(
+        "sentry_sdk.tracing_utils.get_current_span", return_value=fake_transaction
+    ):
+        yield fake_start_child
+
+
+class ApproxDict(dict):
+    def __eq__(self, other):
+        # For an ApproxDict to equal another dict, the other dict just needs to contain
+        # all the keys from the ApproxDict with the same values.
+        #
+        # The other dict may contain additional keys with any value.
+        return all(key in other and other[key] == value for key, value in self.items())
+
+    def __ne__(self, other):
+        return not self.__eq__(other)
+
+
+CapturedData = namedtuple("CapturedData", ["path", "event", "envelope", "compressed"])
+
+
+class CapturingServer(WSGIServer):
+    def __init__(self, host="127.0.0.1", port=0, ssl_context=None):
+        WSGIServer.__init__(self, host, port, self, ssl_context=ssl_context)
+        self.code = 204
+        self.headers = {}
+        self.captured = []
+
+    def respond_with(self, code=200, headers=None):
+        self.code = code
+        if headers:
+            self.headers = headers
+
+    def clear_captured(self):
+        del self.captured[:]
+
+    def __call__(self, environ, start_response):
+        """
+        This is the WSGI application.
+        """
+        request = Request(environ)
+        event = envelope = None
+        content_encoding = request.headers.get("content-encoding")
+        if content_encoding == "gzip":
+            rdr = gzip.GzipFile(fileobj=io.BytesIO(request.data))
+            compressed = True
+        elif content_encoding == "br":
+            rdr = io.BytesIO(brotli.decompress(request.data))
+            compressed = True
+        else:
+            rdr = io.BytesIO(request.data)
+            compressed = False
+
+        if request.mimetype == "application/json":
+            event = parse_json(rdr.read())
+        else:
+            envelope = Envelope.deserialize_from(rdr)
+
+        self.captured.append(
+            CapturedData(
+                path=request.path,
+                event=event,
+                envelope=envelope,
+                compressed=compressed,
+            )
+        )
+
+        response = Response(status=self.code)
+        response.headers.extend(self.headers)
+        return response(environ, start_response)
diff --git a/tests/integrations/aiohttp/__init__.py b/tests/integrations/aiohttp/__init__.py
index 0e1409fda0..a585c11e34 100644
--- a/tests/integrations/aiohttp/__init__.py
+++ b/tests/integrations/aiohttp/__init__.py
@@ -1,3 +1,9 @@
+import os
+import sys
 import pytest
 
 pytest.importorskip("aiohttp")
+
+# Load `aiohttp_helpers` into the module search path to test request source path names relative to module. See
+# `test_request_source_with_module_in_search_path`
+sys.path.insert(0, os.path.join(os.path.dirname(__file__)))
diff --git a/tests/integrations/aiohttp/aiohttp_helpers/__init__.py b/tests/integrations/aiohttp/aiohttp_helpers/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/integrations/aiohttp/aiohttp_helpers/helpers.py b/tests/integrations/aiohttp/aiohttp_helpers/helpers.py
new file mode 100644
index 0000000000..86a6fa39e3
--- /dev/null
+++ b/tests/integrations/aiohttp/aiohttp_helpers/helpers.py
@@ -0,0 +1,2 @@
+async def get_request_with_client(client, url):
+    await client.get(url)
diff --git a/tests/integrations/aiohttp/test_aiohttp.py b/tests/integrations/aiohttp/test_aiohttp.py
index 8068365334..849f9d017b 100644
--- a/tests/integrations/aiohttp/test_aiohttp.py
+++ b/tests/integrations/aiohttp/test_aiohttp.py
@@ -1,19 +1,28 @@
+import os
+import datetime
 import asyncio
 import json
+
 from contextlib import suppress
+from unittest import mock
 
 import pytest
+
 from aiohttp import web
 from aiohttp.client import ServerDisconnectedError
 from aiohttp.web_request import Request
+from aiohttp.web_exceptions import (
+    HTTPInternalServerError,
+    HTTPNetworkAuthenticationRequired,
+    HTTPBadRequest,
+    HTTPNotFound,
+    HTTPUnavailableForLegalReasons,
+)
 
 from sentry_sdk import capture_message, start_transaction
-from sentry_sdk.integrations.aiohttp import AioHttpIntegration
-
-try:
-    from unittest import mock  # python 3.3 and above
-except ImportError:
-    import mock  # python < 3.3
+from sentry_sdk.integrations.aiohttp import AioHttpIntegration, create_trace_config
+from sentry_sdk.consts import SPANDATA
+from tests.conftest import ApproxDict
 
 
 @pytest.mark.asyncio
@@ -51,7 +60,7 @@ async def hello(request):
     assert request["url"] == "http://{host}/".format(host=host)
     assert request["headers"] == {
         "Accept": "*/*",
-        "Accept-Encoding": "gzip, deflate",
+        "Accept-Encoding": mock.ANY,
         "Host": host,
         "User-Agent": request["headers"]["User-Agent"],
         "baggage": mock.ANY,
@@ -256,12 +265,42 @@ async def hello(request):
     assert event["transaction_info"] == {"source": expected_source}
 
 
+@pytest.mark.tests_internal_exceptions
+@pytest.mark.asyncio
+async def test_tracing_unparseable_url(sentry_init, aiohttp_client, capture_events):
+    sentry_init(integrations=[AioHttpIntegration()], traces_sample_rate=1.0)
+
+    async def hello(request):
+        return web.Response(text="hello")
+
+    app = web.Application()
+    app.router.add_get("/", hello)
+
+    events = capture_events()
+
+    client = await aiohttp_client(app)
+    with mock.patch(
+        "sentry_sdk.integrations.aiohttp.parse_url", side_effect=ValueError
+    ):
+        resp = await client.get("/")
+
+    assert resp.status == 200
+
+    (event,) = events
+
+    assert event["type"] == "transaction"
+    assert (
+        event["transaction"]
+        == "tests.integrations.aiohttp.test_aiohttp.test_tracing_unparseable_url..hello"
+    )
+
+
 @pytest.mark.asyncio
 async def test_traces_sampler_gets_request_object_in_sampling_context(
     sentry_init,
     aiohttp_client,
-    DictionaryContaining,  # noqa:N803
-    ObjectDescribedBy,
+    DictionaryContaining,  # noqa: N803
+    ObjectDescribedBy,  # noqa: N803
 ):
     traces_sampler = mock.Mock()
     sentry_init(
@@ -377,13 +416,17 @@ async def hello(request):
     # The aiohttp_client is instrumented so will generate the sentry-trace header and add request.
     # Get the sentry-trace header from the request so we can later compare with transaction events.
     client = await aiohttp_client(app)
-    resp = await client.get("/")
+    with start_transaction():
+        # Headers are only added to the span if there is an active transaction
+        resp = await client.get("/")
+
     sentry_trace_header = resp.request_info.headers.get("sentry-trace")
     trace_id = sentry_trace_header.split("-")[0]
 
     assert resp.status == 500
 
-    msg_event, error_event, transaction_event = events
+    # Last item is the custom transaction event wrapping `client.get("/")`
+    msg_event, error_event, transaction_event, _ = events
 
     assert msg_event["contexts"]["trace"]
     assert "trace_id" in msg_event["contexts"]["trace"]
@@ -437,7 +480,7 @@ async def hello(request):
 
 @pytest.mark.asyncio
 async def test_crumb_capture(
-    sentry_init, aiohttp_raw_server, aiohttp_client, loop, capture_events
+    sentry_init, aiohttp_raw_server, aiohttp_client, capture_events
 ):
     def before_breadcrumb(crumb, hint):
         crumb["data"]["extra"] = "foo"
@@ -465,15 +508,71 @@ async def handler(request):
         crumb = event["breadcrumbs"]["values"][0]
         assert crumb["type"] == "http"
         assert crumb["category"] == "httplib"
-        assert crumb["data"] == {
-            "url": "http://127.0.0.1:{}/".format(raw_server.port),
-            "http.fragment": "",
-            "http.method": "GET",
-            "http.query": "",
-            "http.response.status_code": 200,
-            "reason": "OK",
-            "extra": "foo",
-        }
+        assert crumb["data"] == ApproxDict(
+            {
+                "url": "http://127.0.0.1:{}/".format(raw_server.port),
+                "http.fragment": "",
+                "http.method": "GET",
+                "http.query": "",
+                "http.response.status_code": 200,
+                "reason": "OK",
+                "extra": "foo",
+            }
+        )
+
+
+@pytest.mark.parametrize(
+    "status_code,level",
+    [
+        (200, None),
+        (301, None),
+        (403, "warning"),
+        (405, "warning"),
+        (500, "error"),
+    ],
+)
+@pytest.mark.asyncio
+async def test_crumb_capture_client_error(
+    sentry_init,
+    aiohttp_raw_server,
+    aiohttp_client,
+    capture_events,
+    status_code,
+    level,
+):
+    sentry_init(integrations=[AioHttpIntegration()])
+
+    async def handler(request):
+        return web.Response(status=status_code)
+
+    raw_server = await aiohttp_raw_server(handler)
+
+    with start_transaction():
+        events = capture_events()
+
+        client = await aiohttp_client(raw_server)
+        resp = await client.get("/")
+        assert resp.status == status_code
+        capture_message("Testing!")
+
+        (event,) = events
+
+        crumb = event["breadcrumbs"]["values"][0]
+        assert crumb["type"] == "http"
+        if level is None:
+            assert "level" not in crumb
+        else:
+            assert crumb["level"] == level
+        assert crumb["category"] == "httplib"
+        assert crumb["data"] == ApproxDict(
+            {
+                "url": "http://127.0.0.1:{}/".format(raw_server.port),
+                "http.fragment": "",
+                "http.method": "GET",
+                "http.query": "",
+                "http.response.status_code": status_code,
+            }
+        )
 
 
 @pytest.mark.asyncio
@@ -522,15 +621,513 @@ async def handler(request):
 
     raw_server = await aiohttp_raw_server(handler)
 
-    with start_transaction(
-        name="/interactions/other-dogs/new-dog",
-        op="greeting.sniff",
-        trace_id="0123456789012345678901234567890",
+    with mock.patch("sentry_sdk.tracing_utils.Random.randrange", return_value=500000):
+        with start_transaction(
+            name="/interactions/other-dogs/new-dog",
+            op="greeting.sniff",
+            trace_id="0123456789012345678901234567890",
+        ):
+            client = await aiohttp_client(raw_server)
+            resp = await client.get("/", headers={"bagGage": "custom=value"})
+
+            assert (
+                resp.request_info.headers["baggage"]
+                == "custom=value,sentry-trace_id=0123456789012345678901234567890,sentry-sample_rand=0.500000,sentry-environment=production,sentry-release=d08ebdb9309e1b004c6f52202de58a09c2268e42,sentry-transaction=/interactions/other-dogs/new-dog,sentry-sample_rate=1.0,sentry-sampled=true"
+            )
+
+
+@pytest.mark.asyncio
+async def test_request_source_disabled(
+    sentry_init,
+    aiohttp_raw_server,
+    aiohttp_client,
+    capture_events,
+):
+    sentry_options = {
+        "integrations": [AioHttpIntegration()],
+        "traces_sample_rate": 1.0,
+        "enable_http_request_source": False,
+        "http_request_source_threshold_ms": 0,
+    }
+
+    sentry_init(**sentry_options)
+
+    # server for making span request
+    async def handler(request):
+        return web.Response(text="OK")
+
+    raw_server = await aiohttp_raw_server(handler)
+
+    async def hello(request):
+        span_client = await aiohttp_client(raw_server)
+        await span_client.get("/")
+        return web.Response(text="hello")
+
+    app = web.Application()
+    app.router.add_get(r"/", hello)
+
+    events = capture_events()
+
+    client = await aiohttp_client(app)
+    await client.get("/")
+
+    (event,) = events
+
+    span = event["spans"][-1]
+    assert span["description"].startswith("GET")
+
+    data = span.get("data", {})
+
+    assert SPANDATA.CODE_LINENO not in data
+    assert SPANDATA.CODE_NAMESPACE not in data
+    assert SPANDATA.CODE_FILEPATH not in data
+    assert SPANDATA.CODE_FUNCTION not in data
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("enable_http_request_source", [None, True])
+async def test_request_source_enabled(
+    sentry_init,
+    aiohttp_raw_server,
+    aiohttp_client,
+    capture_events,
+    enable_http_request_source,
+):
+    sentry_options = {
+        "integrations": [AioHttpIntegration()],
+        "traces_sample_rate": 1.0,
+        "http_request_source_threshold_ms": 0,
+    }
+    if enable_http_request_source is not None:
+        sentry_options["enable_http_request_source"] = enable_http_request_source
+
+    sentry_init(**sentry_options)
+
+    # server for making span request
+    async def handler(request):
+        return web.Response(text="OK")
+
+    raw_server = await aiohttp_raw_server(handler)
+
+    async def hello(request):
+        span_client = await aiohttp_client(raw_server)
+        await span_client.get("/")
+        return web.Response(text="hello")
+
+    app = web.Application()
+    app.router.add_get(r"/", hello)
+
+    events = capture_events()
+
+    client = await aiohttp_client(app)
+    await client.get("/")
+
+    (event,) = events
+
+    span = event["spans"][-1]
+    assert span["description"].startswith("GET")
+
+    data = span.get("data", {})
+
+    assert SPANDATA.CODE_LINENO in data
+    assert SPANDATA.CODE_NAMESPACE in data
+    assert SPANDATA.CODE_FILEPATH in data
+    assert SPANDATA.CODE_FUNCTION in data
+
+
+@pytest.mark.asyncio
+async def test_request_source(
+    sentry_init, aiohttp_raw_server, aiohttp_client, capture_events
+):
+    sentry_init(
+        integrations=[AioHttpIntegration()],
+        traces_sample_rate=1.0,
+        enable_http_request_source=True,
+        http_request_source_threshold_ms=0,
+    )
+
+    # server for making span request
+    async def handler(request):
+        return web.Response(text="OK")
+
+    raw_server = await aiohttp_raw_server(handler)
+
+    async def handler_with_outgoing_request(request):
+        span_client = await aiohttp_client(raw_server)
+        await span_client.get("/")
+        return web.Response(text="hello")
+
+    app = web.Application()
+    app.router.add_get(r"/", handler_with_outgoing_request)
+
+    events = capture_events()
+
+    client = await aiohttp_client(app)
+    await client.get("/")
+
+    (event,) = events
+
+    span = event["spans"][-1]
+    assert span["description"].startswith("GET")
+
+    data = span.get("data", {})
+
+    assert SPANDATA.CODE_LINENO in data
+    assert SPANDATA.CODE_NAMESPACE in data
+    assert SPANDATA.CODE_FILEPATH in data
+    assert SPANDATA.CODE_FUNCTION in data
+
+    assert type(data.get(SPANDATA.CODE_LINENO)) == int
+    assert data.get(SPANDATA.CODE_LINENO) > 0
+    assert (
+        data.get(SPANDATA.CODE_NAMESPACE) == "tests.integrations.aiohttp.test_aiohttp"
+    )
+    assert data.get(SPANDATA.CODE_FILEPATH).endswith(
+        "tests/integrations/aiohttp/test_aiohttp.py"
+    )
+
+    is_relative_path = data.get(SPANDATA.CODE_FILEPATH)[0] != os.sep
+    assert is_relative_path
+
+    assert data.get(SPANDATA.CODE_FUNCTION) == "handler_with_outgoing_request"
+
+
+@pytest.mark.asyncio
+async def test_request_source_with_module_in_search_path(
+    sentry_init, aiohttp_raw_server, aiohttp_client, capture_events
+):
+    """
+    Test that request source is relative to the path of the module it ran in
+    """
+    sentry_init(
+        integrations=[AioHttpIntegration()],
+        traces_sample_rate=1.0,
+        enable_http_request_source=True,
+        http_request_source_threshold_ms=0,
+    )
+
+    # server for making span request
+    async def handler(request):
+        return web.Response(text="OK")
+
+    raw_server = await aiohttp_raw_server(handler)
+
+    from aiohttp_helpers.helpers import get_request_with_client
+
+    async def handler_with_outgoing_request(request):
+        span_client = await aiohttp_client(raw_server)
+        await get_request_with_client(span_client, "/")
+        return web.Response(text="hello")
+
+    app = web.Application()
+    app.router.add_get(r"/", handler_with_outgoing_request)
+
+    events = capture_events()
+
+    client = await aiohttp_client(app)
+    await client.get("/")
+
+    (event,) = events
+
+    span = event["spans"][-1]
+    assert span["description"].startswith("GET")
+
+    data = span.get("data", {})
+
+    assert SPANDATA.CODE_LINENO in data
+    assert SPANDATA.CODE_NAMESPACE in data
+    assert SPANDATA.CODE_FILEPATH in data
+    assert SPANDATA.CODE_FUNCTION in data
+
+    assert type(data.get(SPANDATA.CODE_LINENO)) == int
+    assert data.get(SPANDATA.CODE_LINENO) > 0
+    assert data.get(SPANDATA.CODE_NAMESPACE) == "aiohttp_helpers.helpers"
+    assert data.get(SPANDATA.CODE_FILEPATH) == "aiohttp_helpers/helpers.py"
+
+    is_relative_path = data.get(SPANDATA.CODE_FILEPATH)[0] != os.sep
+    assert is_relative_path
+
+    assert data.get(SPANDATA.CODE_FUNCTION) == "get_request_with_client"
+
+
+@pytest.mark.asyncio
+async def test_no_request_source_if_duration_too_short(
+    sentry_init, aiohttp_raw_server, aiohttp_client, capture_events
+):
+    sentry_init(
+        integrations=[AioHttpIntegration()],
+        traces_sample_rate=1.0,
+        enable_http_request_source=True,
+        http_request_source_threshold_ms=100,
+    )
+
+    # server for making span request
+    async def handler(request):
+        return web.Response(text="OK")
+
+    raw_server = await aiohttp_raw_server(handler)
+
+    async def handler_with_outgoing_request(request):
+        span_client = await aiohttp_client(raw_server)
+        await span_client.get("/")
+        return web.Response(text="hello")
+
+    app = web.Application()
+    app.router.add_get(r"/", handler_with_outgoing_request)
+
+    events = capture_events()
+
+    def fake_create_trace_context(*args, **kwargs):
+        trace_context = create_trace_config()
+
+        async def overwrite_timestamps(session, trace_config_ctx, params):
+            span = trace_config_ctx.span
+            span.start_timestamp = datetime.datetime(2024, 1, 1, microsecond=0)
+            span.timestamp = datetime.datetime(2024, 1, 1, microsecond=99999)
+
+        trace_context.on_request_end.insert(0, overwrite_timestamps)
+
+        return trace_context
+
+    with mock.patch(
+        "sentry_sdk.integrations.aiohttp.create_trace_config",
+        fake_create_trace_context,
     ):
-        client = await aiohttp_client(raw_server)
-        resp = await client.get("/", headers={"bagGage": "custom=value"})
+        client = await aiohttp_client(app)
+        await client.get("/")
 
-        assert (
-            resp.request_info.headers["baggage"]
-            == "custom=value,sentry-trace_id=0123456789012345678901234567890,sentry-environment=production,sentry-release=d08ebdb9309e1b004c6f52202de58a09c2268e42,sentry-transaction=/interactions/other-dogs/new-dog,sentry-sample_rate=1.0,sentry-sampled=true"
-        )
+    (event,) = events
+
+    span = event["spans"][-1]
+    assert span["description"].startswith("GET")
+
+    data = span.get("data", {})
+
+    assert SPANDATA.CODE_LINENO not in data
+    assert SPANDATA.CODE_NAMESPACE not in data
+    assert SPANDATA.CODE_FILEPATH not in data
+    assert SPANDATA.CODE_FUNCTION not in data
+
+
+@pytest.mark.asyncio
+async def test_request_source_if_duration_over_threshold(
+    sentry_init, aiohttp_raw_server, aiohttp_client, capture_events
+):
+    sentry_init(
+        integrations=[AioHttpIntegration()],
+        traces_sample_rate=1.0,
+        enable_http_request_source=True,
+        http_request_source_threshold_ms=100,
+    )
+
+    # server for making span request
+    async def handler(request):
+        return web.Response(text="OK")
+
+    raw_server = await aiohttp_raw_server(handler)
+
+    async def handler_with_outgoing_request(request):
+        span_client = await aiohttp_client(raw_server)
+        await span_client.get("/")
+        return web.Response(text="hello")
+
+    app = web.Application()
+    app.router.add_get(r"/", handler_with_outgoing_request)
+
+    events = capture_events()
+
+    def fake_create_trace_context(*args, **kwargs):
+        trace_context = create_trace_config()
+
+        async def overwrite_timestamps(session, trace_config_ctx, params):
+            span = trace_config_ctx.span
+            span.start_timestamp = datetime.datetime(2024, 1, 1, microsecond=0)
+            span.timestamp = datetime.datetime(2024, 1, 1, microsecond=100001)
+
+        trace_context.on_request_end.insert(0, overwrite_timestamps)
+
+        return trace_context
+
+    with mock.patch(
+        "sentry_sdk.integrations.aiohttp.create_trace_config",
+        fake_create_trace_context,
+    ):
+        client = await aiohttp_client(app)
+        await client.get("/")
+
+    (event,) = events
+
+    span = event["spans"][-1]
+    assert span["description"].startswith("GET")
+
+    data = span.get("data", {})
+
+    assert SPANDATA.CODE_LINENO in data
+    assert SPANDATA.CODE_NAMESPACE in data
+    assert SPANDATA.CODE_FILEPATH in data
+    assert SPANDATA.CODE_FUNCTION in data
+
+    assert type(data.get(SPANDATA.CODE_LINENO)) == int
+    assert data.get(SPANDATA.CODE_LINENO) > 0
+    assert (
+        data.get(SPANDATA.CODE_NAMESPACE) == "tests.integrations.aiohttp.test_aiohttp"
+    )
+    assert data.get(SPANDATA.CODE_FILEPATH).endswith(
+        "tests/integrations/aiohttp/test_aiohttp.py"
+    )
+
+    is_relative_path = data.get(SPANDATA.CODE_FILEPATH)[0] != os.sep
+    assert is_relative_path
+
+    assert data.get(SPANDATA.CODE_FUNCTION) == "handler_with_outgoing_request"
+
+
+@pytest.mark.asyncio
+async def test_span_origin(
+    sentry_init,
+    aiohttp_raw_server,
+    aiohttp_client,
+    capture_events,
+):
+    sentry_init(
+        integrations=[AioHttpIntegration()],
+        traces_sample_rate=1.0,
+    )
+
+    # server for making span request
+    async def handler(request):
+        return web.Response(text="OK")
+
+    raw_server = await aiohttp_raw_server(handler)
+
+    async def hello(request):
+        span_client = await aiohttp_client(raw_server)
+        await span_client.get("/")
+        return web.Response(text="hello")
+
+    app = web.Application()
+    app.router.add_get(r"/", hello)
+
+    events = capture_events()
+
+    client = await aiohttp_client(app)
+    await client.get("/")
+
+    (event,) = events
+    assert event["contexts"]["trace"]["origin"] == "auto.http.aiohttp"
+    assert event["spans"][0]["origin"] == "auto.http.aiohttp"
+
+
+@pytest.mark.parametrize(
+    ("integration_kwargs", "exception_to_raise", "should_capture"),
+    (
+        ({}, None, False),
+        ({}, HTTPBadRequest, False),
+        (
+            {},
+            HTTPUnavailableForLegalReasons(None),
+            False,
+        ),  # Highest 4xx status code (451)
+        ({}, HTTPInternalServerError, True),
+        ({}, HTTPNetworkAuthenticationRequired, True),  # Highest 5xx status code (511)
+        ({"failed_request_status_codes": set()}, HTTPInternalServerError, False),
+        (
+            {"failed_request_status_codes": set()},
+            HTTPNetworkAuthenticationRequired,
+            False,
+        ),
+        ({"failed_request_status_codes": {404, *range(500, 600)}}, HTTPNotFound, True),
+        (
+            {"failed_request_status_codes": {404, *range(500, 600)}},
+            HTTPInternalServerError,
+            True,
+        ),
+        (
+            {"failed_request_status_codes": {404, *range(500, 600)}},
+            HTTPBadRequest,
+            False,
+        ),
+    ),
+)
+@pytest.mark.asyncio
+async def test_failed_request_status_codes(
+    sentry_init,
+    aiohttp_client,
+    capture_events,
+    integration_kwargs,
+    exception_to_raise,
+    should_capture,
+):
+    sentry_init(integrations=[AioHttpIntegration(**integration_kwargs)])
+    events = capture_events()
+
+    async def handle(_):
+        if exception_to_raise is not None:
+            raise exception_to_raise
+        else:
+            return web.Response(status=200)
+
+    app = web.Application()
+    app.router.add_get("/", handle)
+
+    client = await aiohttp_client(app)
+    resp = await client.get("/")
+
+    expected_status = (
+        200 if exception_to_raise is None else exception_to_raise.status_code
+    )
+    assert resp.status == expected_status
+
+    if should_capture:
+        (event,) = events
+        assert event["exception"]["values"][0]["type"] == exception_to_raise.__name__
+    else:
+        assert not events
+
+
+@pytest.mark.asyncio
+async def test_failed_request_status_codes_with_returned_status(
+    sentry_init, aiohttp_client, capture_events
+):
+    """
+    Returning a web.Response with a failed_request_status_code should not be reported to Sentry.
+    """
+    sentry_init(integrations=[AioHttpIntegration(failed_request_status_codes={500})])
+    events = capture_events()
+
+    async def handle(_):
+        return web.Response(status=500)
+
+    app = web.Application()
+    app.router.add_get("/", handle)
+
+    client = await aiohttp_client(app)
+    resp = await client.get("/")
+
+    assert resp.status == 500
+    assert not events
+
+
+@pytest.mark.asyncio
+async def test_failed_request_status_codes_non_http_exception(
+    sentry_init, aiohttp_client, capture_events
+):
+    """
+    If an exception, which is not an instance of HTTPException, is raised, it should be captured, even if
+    failed_request_status_codes is empty.
+    """
+    sentry_init(integrations=[AioHttpIntegration(failed_request_status_codes=set())])
+    events = capture_events()
+
+    async def handle(_):
+        1 / 0
+
+    app = web.Application()
+    app.router.add_get("/", handle)
+
+    client = await aiohttp_client(app)
+    resp = await client.get("/")
+    assert resp.status == 500
+
+    (event,) = events
+    assert event["exception"]["values"][0]["type"] == "ZeroDivisionError"
diff --git a/tests/integrations/anthropic/__init__.py b/tests/integrations/anthropic/__init__.py
new file mode 100644
index 0000000000..29ac4e6ff4
--- /dev/null
+++ b/tests/integrations/anthropic/__init__.py
@@ -0,0 +1,3 @@
+import pytest
+
+pytest.importorskip("anthropic")
diff --git a/tests/integrations/anthropic/test_anthropic.py b/tests/integrations/anthropic/test_anthropic.py
new file mode 100644
index 0000000000..2204505d47
--- /dev/null
+++ b/tests/integrations/anthropic/test_anthropic.py
@@ -0,0 +1,1448 @@
+import pytest
+from unittest import mock
+import json
+
+try:
+    from unittest.mock import AsyncMock
+except ImportError:
+
+    class AsyncMock(mock.MagicMock):
+        async def __call__(self, *args, **kwargs):
+            return super(AsyncMock, self).__call__(*args, **kwargs)
+
+
+from anthropic import Anthropic, AnthropicError, AsyncAnthropic, AsyncStream, Stream
+from anthropic.types import MessageDeltaUsage, TextDelta, Usage
+from anthropic.types.content_block_delta_event import ContentBlockDeltaEvent
+from anthropic.types.content_block_start_event import ContentBlockStartEvent
+from anthropic.types.content_block_stop_event import ContentBlockStopEvent
+from anthropic.types.message import Message
+from anthropic.types.message_delta_event import MessageDeltaEvent
+from anthropic.types.message_start_event import MessageStartEvent
+
+try:
+    from anthropic.types import InputJSONDelta
+except ImportError:
+    try:
+        from anthropic.types import InputJsonDelta as InputJSONDelta
+    except ImportError:
+        pass
+
+try:
+    # 0.27+
+    from anthropic.types.raw_message_delta_event import Delta
+    from anthropic.types.tool_use_block import ToolUseBlock
+except ImportError:
+    # pre 0.27
+    from anthropic.types.message_delta_event import Delta
+
+try:
+    from anthropic.types.text_block import TextBlock
+except ImportError:
+    from anthropic.types.content_block import ContentBlock as TextBlock
+
+from sentry_sdk import start_transaction, start_span
+from sentry_sdk.consts import OP, SPANDATA
+from sentry_sdk.integrations.anthropic import (
+    AnthropicIntegration,
+    _set_output_data,
+    _collect_ai_data,
+)
+from sentry_sdk.utils import package_version
+
+
+ANTHROPIC_VERSION = package_version("anthropic")
+
+EXAMPLE_MESSAGE = Message(
+    id="id",
+    model="model",
+    role="assistant",
+    content=[TextBlock(type="text", text="Hi, I'm Claude.")],
+    type="message",
+    usage=Usage(input_tokens=10, output_tokens=20),
+)
+
+
+async def async_iterator(values):
+    for value in values:
+        yield value
+
+
+@pytest.mark.parametrize(
+    "send_default_pii, include_prompts",
+    [
+        (True, True),
+        (True, False),
+        (False, True),
+        (False, False),
+    ],
+)
+def test_nonstreaming_create_message(
+    sentry_init, capture_events, send_default_pii, include_prompts
+):
+    sentry_init(
+        integrations=[AnthropicIntegration(include_prompts=include_prompts)],
+        traces_sample_rate=1.0,
+        send_default_pii=send_default_pii,
+    )
+    events = capture_events()
+    client = Anthropic(api_key="z")
+    client.messages._post = mock.Mock(return_value=EXAMPLE_MESSAGE)
+
+    messages = [
+        {
+            "role": "user",
+            "content": "Hello, Claude",
+        }
+    ]
+
+    with start_transaction(name="anthropic"):
+        response = client.messages.create(
+            max_tokens=1024, messages=messages, model="model"
+        )
+
+    assert response == EXAMPLE_MESSAGE
+    usage = response.usage
+
+    assert usage.input_tokens == 10
+    assert usage.output_tokens == 20
+
+    assert len(events) == 1
+    (event,) = events
+
+    assert event["type"] == "transaction"
+    assert event["transaction"] == "anthropic"
+
+    assert len(event["spans"]) == 1
+    (span,) = event["spans"]
+
+    assert span["op"] == OP.GEN_AI_CHAT
+    assert span["description"] == "chat model"
+    assert span["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "chat"
+    assert span["data"][SPANDATA.GEN_AI_REQUEST_MODEL] == "model"
+
+    if send_default_pii and include_prompts:
+        assert (
+            span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES]
+            == '[{"role": "user", "content": "Hello, Claude"}]'
+        )
+        assert span["data"][SPANDATA.GEN_AI_RESPONSE_TEXT] == "Hi, I'm Claude."
+    else:
+        assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["data"]
+        assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["data"]
+
+    assert span["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10
+    assert span["data"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 20
+    assert span["data"][SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS] == 30
+    assert span["data"][SPANDATA.GEN_AI_RESPONSE_STREAMING] is False
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    "send_default_pii, include_prompts",
+    [
+        (True, True),
+        (True, False),
+        (False, True),
+        (False, False),
+    ],
+)
+async def test_nonstreaming_create_message_async(
+    sentry_init, capture_events, send_default_pii, include_prompts
+):
+    sentry_init(
+        integrations=[AnthropicIntegration(include_prompts=include_prompts)],
+        traces_sample_rate=1.0,
+        send_default_pii=send_default_pii,
+    )
+    events = capture_events()
+    client = AsyncAnthropic(api_key="z")
+    client.messages._post = AsyncMock(return_value=EXAMPLE_MESSAGE)
+
+    messages = [
+        {
+            "role": "user",
+            "content": "Hello, Claude",
+        }
+    ]
+
+    with start_transaction(name="anthropic"):
+        response = await client.messages.create(
+            max_tokens=1024, messages=messages, model="model"
+        )
+
+    assert response == EXAMPLE_MESSAGE
+    usage = response.usage
+
+    assert usage.input_tokens == 10
+    assert usage.output_tokens == 20
+
+    assert len(events) == 1
+    (event,) = events
+
+    assert event["type"] == "transaction"
+    assert event["transaction"] == "anthropic"
+
+    assert len(event["spans"]) == 1
+    (span,) = event["spans"]
+
+    assert span["op"] == OP.GEN_AI_CHAT
+    assert span["description"] == "chat model"
+    assert span["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "chat"
+    assert span["data"][SPANDATA.GEN_AI_REQUEST_MODEL] == "model"
+
+    if send_default_pii and include_prompts:
+        assert (
+            span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES]
+            == '[{"role": "user", "content": "Hello, Claude"}]'
+        )
+        assert span["data"][SPANDATA.GEN_AI_RESPONSE_TEXT] == "Hi, I'm Claude."
+    else:
+        assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["data"]
+        assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["data"]
+
+    assert span["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10
+    assert span["data"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 20
+    assert span["data"][SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS] == 30
+    assert span["data"][SPANDATA.GEN_AI_RESPONSE_STREAMING] is False
+
+
+@pytest.mark.parametrize(
+    "send_default_pii, include_prompts",
+    [
+        (True, True),
+        (True, False),
+        (False, True),
+        (False, False),
+    ],
+)
+def test_streaming_create_message(
+    sentry_init, capture_events, send_default_pii, include_prompts
+):
+    client = Anthropic(api_key="z")
+    returned_stream = Stream(cast_to=None, response=None, client=client)
+    returned_stream._iterator = [
+        MessageStartEvent(
+            message=EXAMPLE_MESSAGE,
+            type="message_start",
+        ),
+        ContentBlockStartEvent(
+            type="content_block_start",
+            index=0,
+            content_block=TextBlock(type="text", text=""),
+        ),
+        ContentBlockDeltaEvent(
+            delta=TextDelta(text="Hi", type="text_delta"),
+            index=0,
+            type="content_block_delta",
+        ),
+        ContentBlockDeltaEvent(
+            delta=TextDelta(text="!", type="text_delta"),
+            index=0,
+            type="content_block_delta",
+        ),
+        ContentBlockDeltaEvent(
+            delta=TextDelta(text=" I'm Claude!", type="text_delta"),
+            index=0,
+            type="content_block_delta",
+        ),
+        ContentBlockStopEvent(type="content_block_stop", index=0),
+        MessageDeltaEvent(
+            delta=Delta(),
+            usage=MessageDeltaUsage(output_tokens=10),
+            type="message_delta",
+        ),
+    ]
+
+    sentry_init(
+        integrations=[AnthropicIntegration(include_prompts=include_prompts)],
+        traces_sample_rate=1.0,
+        send_default_pii=send_default_pii,
+    )
+    events = capture_events()
+    client.messages._post = mock.Mock(return_value=returned_stream)
+
+    messages = [
+        {
+            "role": "user",
+            "content": "Hello, Claude",
+        }
+    ]
+
+    with start_transaction(name="anthropic"):
+        message = client.messages.create(
+            max_tokens=1024, messages=messages, model="model", stream=True
+        )
+
+        for _ in message:
+            pass
+
+    assert message == returned_stream
+    assert len(events) == 1
+    (event,) = events
+
+    assert event["type"] == "transaction"
+    assert event["transaction"] == "anthropic"
+
+    assert len(event["spans"]) == 1
+    (span,) = event["spans"]
+
+    assert span["op"] == OP.GEN_AI_CHAT
+    assert span["description"] == "chat model"
+    assert span["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "chat"
+    assert span["data"][SPANDATA.GEN_AI_REQUEST_MODEL] == "model"
+
+    if send_default_pii and include_prompts:
+        assert (
+            span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES]
+            == '[{"role": "user", "content": "Hello, Claude"}]'
+        )
+        assert span["data"][SPANDATA.GEN_AI_RESPONSE_TEXT] == "Hi! I'm Claude!"
+
+    else:
+        assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["data"]
+        assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["data"]
+
+    assert span["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10
+    assert span["data"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 30
+    assert span["data"][SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS] == 40
+    assert span["data"][SPANDATA.GEN_AI_RESPONSE_STREAMING] is True
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    "send_default_pii, include_prompts",
+    [
+        (True, True),
+        (True, False),
+        (False, True),
+        (False, False),
+    ],
+)
+async def test_streaming_create_message_async(
+    sentry_init, capture_events, send_default_pii, include_prompts
+):
+    client = AsyncAnthropic(api_key="z")
+    returned_stream = AsyncStream(cast_to=None, response=None, client=client)
+    returned_stream._iterator = async_iterator(
+        [
+            MessageStartEvent(
+                message=EXAMPLE_MESSAGE,
+                type="message_start",
+            ),
+            ContentBlockStartEvent(
+                type="content_block_start",
+                index=0,
+                content_block=TextBlock(type="text", text=""),
+            ),
+            ContentBlockDeltaEvent(
+                delta=TextDelta(text="Hi", type="text_delta"),
+                index=0,
+                type="content_block_delta",
+            ),
+            ContentBlockDeltaEvent(
+                delta=TextDelta(text="!", type="text_delta"),
+                index=0,
+                type="content_block_delta",
+            ),
+            ContentBlockDeltaEvent(
+                delta=TextDelta(text=" I'm Claude!", type="text_delta"),
+                index=0,
+                type="content_block_delta",
+            ),
+            ContentBlockStopEvent(type="content_block_stop", index=0),
+            MessageDeltaEvent(
+                delta=Delta(),
+                usage=MessageDeltaUsage(output_tokens=10),
+                type="message_delta",
+            ),
+        ]
+    )
+
+    sentry_init(
+        integrations=[AnthropicIntegration(include_prompts=include_prompts)],
+        traces_sample_rate=1.0,
+        send_default_pii=send_default_pii,
+    )
+    events = capture_events()
+    client.messages._post = AsyncMock(return_value=returned_stream)
+
+    messages = [
+        {
+            "role": "user",
+            "content": "Hello, Claude",
+        }
+    ]
+
+    with start_transaction(name="anthropic"):
+        message = await client.messages.create(
+            max_tokens=1024, messages=messages, model="model", stream=True
+        )
+
+        async for _ in message:
+            pass
+
+    assert message == returned_stream
+    assert len(events) == 1
+    (event,) = events
+
+    assert event["type"] == "transaction"
+    assert event["transaction"] == "anthropic"
+
+    assert len(event["spans"]) == 1
+    (span,) = event["spans"]
+
+    assert span["op"] == OP.GEN_AI_CHAT
+    assert span["description"] == "chat model"
+    assert span["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "chat"
+    assert span["data"][SPANDATA.GEN_AI_REQUEST_MODEL] == "model"
+
+    if send_default_pii and include_prompts:
+        assert (
+            span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES]
+            == '[{"role": "user", "content": "Hello, Claude"}]'
+        )
+        assert span["data"][SPANDATA.GEN_AI_RESPONSE_TEXT] == "Hi! I'm Claude!"
+
+    else:
+        assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["data"]
+        assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["data"]
+
+    assert span["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10
+    assert span["data"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 30
+    assert span["data"][SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS] == 40
+    assert span["data"][SPANDATA.GEN_AI_RESPONSE_STREAMING] is True
+
+
+@pytest.mark.skipif(
+    ANTHROPIC_VERSION < (0, 27),
+    reason="Versions <0.27.0 do not include InputJSONDelta, which was introduced in >=0.27.0 along with a new message delta type for tool calling.",
+)
+@pytest.mark.parametrize(
+    "send_default_pii, include_prompts",
+    [
+        (True, True),
+        (True, False),
+        (False, True),
+        (False, False),
+    ],
+)
+def test_streaming_create_message_with_input_json_delta(
+    sentry_init, capture_events, send_default_pii, include_prompts
+):
+    client = Anthropic(api_key="z")
+    returned_stream = Stream(cast_to=None, response=None, client=client)
+    returned_stream._iterator = [
+        MessageStartEvent(
+            message=Message(
+                id="msg_0",
+                content=[],
+                model="claude-3-5-sonnet-20240620",
+                role="assistant",
+                stop_reason=None,
+                stop_sequence=None,
+                type="message",
+                usage=Usage(input_tokens=366, output_tokens=10),
+            ),
+            type="message_start",
+        ),
+        ContentBlockStartEvent(
+            type="content_block_start",
+            index=0,
+            content_block=ToolUseBlock(
+                id="toolu_0", input={}, name="get_weather", type="tool_use"
+            ),
+        ),
+        ContentBlockDeltaEvent(
+            delta=InputJSONDelta(partial_json="", type="input_json_delta"),
+            index=0,
+            type="content_block_delta",
+        ),
+        ContentBlockDeltaEvent(
+            delta=InputJSONDelta(partial_json="{'location':", type="input_json_delta"),
+            index=0,
+            type="content_block_delta",
+        ),
+        ContentBlockDeltaEvent(
+            delta=InputJSONDelta(partial_json=" 'S", type="input_json_delta"),
+            index=0,
+            type="content_block_delta",
+        ),
+        ContentBlockDeltaEvent(
+            delta=InputJSONDelta(partial_json="an ", type="input_json_delta"),
+            index=0,
+            type="content_block_delta",
+        ),
+        ContentBlockDeltaEvent(
+            delta=InputJSONDelta(partial_json="Francisco, C", type="input_json_delta"),
+            index=0,
+            type="content_block_delta",
+        ),
+        ContentBlockDeltaEvent(
+            delta=InputJSONDelta(partial_json="A'}", type="input_json_delta"),
+            index=0,
+            type="content_block_delta",
+        ),
+        ContentBlockStopEvent(type="content_block_stop", index=0),
+        MessageDeltaEvent(
+            delta=Delta(stop_reason="tool_use", stop_sequence=None),
+            usage=MessageDeltaUsage(output_tokens=41),
+            type="message_delta",
+        ),
+    ]
+
+    sentry_init(
+        integrations=[AnthropicIntegration(include_prompts=include_prompts)],
+        traces_sample_rate=1.0,
+        send_default_pii=send_default_pii,
+    )
+    events = capture_events()
+    client.messages._post = mock.Mock(return_value=returned_stream)
+
+    messages = [
+        {
+            "role": "user",
+            "content": "What is the weather like in San Francisco?",
+        }
+    ]
+
+    with start_transaction(name="anthropic"):
+        message = client.messages.create(
+            max_tokens=1024, messages=messages, model="model", stream=True
+        )
+
+        for _ in message:
+            pass
+
+    assert message == returned_stream
+    assert len(events) == 1
+    (event,) = events
+
+    assert event["type"] == "transaction"
+    assert event["transaction"] == "anthropic"
+
+    assert len(event["spans"]) == 1
+    (span,) = event["spans"]
+
+    assert span["op"] == OP.GEN_AI_CHAT
+    assert span["description"] == "chat model"
+    assert span["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "chat"
+    assert span["data"][SPANDATA.GEN_AI_REQUEST_MODEL] == "model"
+
+    if send_default_pii and include_prompts:
+        assert (
+            span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES]
+            == '[{"role": "user", "content": "What is the weather like in San Francisco?"}]'
+        )
+        assert (
+            span["data"][SPANDATA.GEN_AI_RESPONSE_TEXT]
+            == "{'location': 'San Francisco, CA'}"
+        )
+    else:
+        assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["data"]
+        assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["data"]
+
+    assert span["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 366
+    assert span["data"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 51
+    assert span["data"][SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS] == 417
+    assert span["data"][SPANDATA.GEN_AI_RESPONSE_STREAMING] is True
+
+
+@pytest.mark.asyncio
+@pytest.mark.skipif(
+    ANTHROPIC_VERSION < (0, 27),
+    reason="Versions <0.27.0 do not include InputJSONDelta, which was introduced in >=0.27.0 along with a new message delta type for tool calling.",
+)
+@pytest.mark.parametrize(
+    "send_default_pii, include_prompts",
+    [
+        (True, True),
+        (True, False),
+        (False, True),
+        (False, False),
+    ],
+)
+async def test_streaming_create_message_with_input_json_delta_async(
+    sentry_init, capture_events, send_default_pii, include_prompts
+):
+    client = AsyncAnthropic(api_key="z")
+    returned_stream = AsyncStream(cast_to=None, response=None, client=client)
+    returned_stream._iterator = async_iterator(
+        [
+            MessageStartEvent(
+                message=Message(
+                    id="msg_0",
+                    content=[],
+                    model="claude-3-5-sonnet-20240620",
+                    role="assistant",
+                    stop_reason=None,
+                    stop_sequence=None,
+                    type="message",
+                    usage=Usage(input_tokens=366, output_tokens=10),
+                ),
+                type="message_start",
+            ),
+            ContentBlockStartEvent(
+                type="content_block_start",
+                index=0,
+                content_block=ToolUseBlock(
+                    id="toolu_0", input={}, name="get_weather", type="tool_use"
+                ),
+            ),
+            ContentBlockDeltaEvent(
+                delta=InputJSONDelta(partial_json="", type="input_json_delta"),
+                index=0,
+                type="content_block_delta",
+            ),
+            ContentBlockDeltaEvent(
+                delta=InputJSONDelta(
+                    partial_json="{'location':", type="input_json_delta"
+                ),
+                index=0,
+                type="content_block_delta",
+            ),
+            ContentBlockDeltaEvent(
+                delta=InputJSONDelta(partial_json=" 'S", type="input_json_delta"),
+                index=0,
+                type="content_block_delta",
+            ),
+            ContentBlockDeltaEvent(
+                delta=InputJSONDelta(partial_json="an ", type="input_json_delta"),
+                index=0,
+                type="content_block_delta",
+            ),
+            ContentBlockDeltaEvent(
+                delta=InputJSONDelta(
+                    partial_json="Francisco, C", type="input_json_delta"
+                ),
+                index=0,
+                type="content_block_delta",
+            ),
+            ContentBlockDeltaEvent(
+                delta=InputJSONDelta(partial_json="A'}", type="input_json_delta"),
+                index=0,
+                type="content_block_delta",
+            ),
+            ContentBlockStopEvent(type="content_block_stop", index=0),
+            MessageDeltaEvent(
+                delta=Delta(stop_reason="tool_use", stop_sequence=None),
+                usage=MessageDeltaUsage(output_tokens=41),
+                type="message_delta",
+            ),
+        ]
+    )
+
+    sentry_init(
+        integrations=[AnthropicIntegration(include_prompts=include_prompts)],
+        traces_sample_rate=1.0,
+        send_default_pii=send_default_pii,
+    )
+    events = capture_events()
+    client.messages._post = AsyncMock(return_value=returned_stream)
+
+    messages = [
+        {
+            "role": "user",
+            "content": "What is the weather like in San Francisco?",
+        }
+    ]
+
+    with start_transaction(name="anthropic"):
+        message = await client.messages.create(
+            max_tokens=1024, messages=messages, model="model", stream=True
+        )
+
+        async for _ in message:
+            pass
+
+    assert message == returned_stream
+    assert len(events) == 1
+    (event,) = events
+
+    assert event["type"] == "transaction"
+    assert event["transaction"] == "anthropic"
+
+    assert len(event["spans"]) == 1
+    (span,) = event["spans"]
+
+    assert span["op"] == OP.GEN_AI_CHAT
+    assert span["description"] == "chat model"
+    assert span["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "chat"
+    assert span["data"][SPANDATA.GEN_AI_REQUEST_MODEL] == "model"
+
+    if send_default_pii and include_prompts:
+        assert (
+            span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES]
+            == '[{"role": "user", "content": "What is the weather like in San Francisco?"}]'
+        )
+        assert (
+            span["data"][SPANDATA.GEN_AI_RESPONSE_TEXT]
+            == "{'location': 'San Francisco, CA'}"
+        )
+
+    else:
+        assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["data"]
+        assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["data"]
+
+    assert span["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 366
+    assert span["data"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 51
+    assert span["data"][SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS] == 417
+    assert span["data"][SPANDATA.GEN_AI_RESPONSE_STREAMING] is True
+
+
+def test_exception_message_create(sentry_init, capture_events):
+    sentry_init(integrations=[AnthropicIntegration()], traces_sample_rate=1.0)
+    events = capture_events()
+
+    client = Anthropic(api_key="z")
+    client.messages._post = mock.Mock(
+        side_effect=AnthropicError("API rate limit reached")
+    )
+    with pytest.raises(AnthropicError):
+        client.messages.create(
+            model="some-model",
+            messages=[{"role": "system", "content": "I'm throwing an exception"}],
+            max_tokens=1024,
+        )
+
+    (event, transaction) = events
+    assert event["level"] == "error"
+    assert transaction["contexts"]["trace"]["status"] == "internal_error"
+
+
+def test_span_status_error(sentry_init, capture_events):
+    sentry_init(integrations=[AnthropicIntegration()], traces_sample_rate=1.0)
+    events = capture_events()
+
+    with start_transaction(name="anthropic"):
+        client = Anthropic(api_key="z")
+        client.messages._post = mock.Mock(
+            side_effect=AnthropicError("API rate limit reached")
+        )
+        with pytest.raises(AnthropicError):
+            client.messages.create(
+                model="some-model",
+                messages=[{"role": "system", "content": "I'm throwing an exception"}],
+                max_tokens=1024,
+            )
+
+    (error, transaction) = events
+    assert error["level"] == "error"
+    assert transaction["spans"][0]["status"] == "internal_error"
+    assert transaction["spans"][0]["tags"]["status"] == "internal_error"
+    assert transaction["contexts"]["trace"]["status"] == "internal_error"
+    assert transaction["spans"][0]["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "chat"
+
+
+@pytest.mark.asyncio
+async def test_span_status_error_async(sentry_init, capture_events):
+    sentry_init(integrations=[AnthropicIntegration()], traces_sample_rate=1.0)
+    events = capture_events()
+
+    with start_transaction(name="anthropic"):
+        client = AsyncAnthropic(api_key="z")
+        client.messages._post = AsyncMock(
+            side_effect=AnthropicError("API rate limit reached")
+        )
+        with pytest.raises(AnthropicError):
+            await client.messages.create(
+                model="some-model",
+                messages=[{"role": "system", "content": "I'm throwing an exception"}],
+                max_tokens=1024,
+            )
+
+    (error, transaction) = events
+    assert error["level"] == "error"
+    assert transaction["spans"][0]["status"] == "internal_error"
+    assert transaction["spans"][0]["tags"]["status"] == "internal_error"
+    assert transaction["contexts"]["trace"]["status"] == "internal_error"
+    assert transaction["spans"][0]["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "chat"
+
+
+@pytest.mark.asyncio
+async def test_exception_message_create_async(sentry_init, capture_events):
+    sentry_init(integrations=[AnthropicIntegration()], traces_sample_rate=1.0)
+    events = capture_events()
+
+    client = AsyncAnthropic(api_key="z")
+    client.messages._post = AsyncMock(
+        side_effect=AnthropicError("API rate limit reached")
+    )
+    with pytest.raises(AnthropicError):
+        await client.messages.create(
+            model="some-model",
+            messages=[{"role": "system", "content": "I'm throwing an exception"}],
+            max_tokens=1024,
+        )
+
+    (event, transaction) = events
+    assert event["level"] == "error"
+    assert transaction["contexts"]["trace"]["status"] == "internal_error"
+
+
+def test_span_origin(sentry_init, capture_events):
+    sentry_init(
+        integrations=[AnthropicIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    client = Anthropic(api_key="z")
+    client.messages._post = mock.Mock(return_value=EXAMPLE_MESSAGE)
+
+    messages = [
+        {
+            "role": "user",
+            "content": "Hello, Claude",
+        }
+    ]
+
+    with start_transaction(name="anthropic"):
+        client.messages.create(max_tokens=1024, messages=messages, model="model")
+
+    (event,) = events
+
+    assert event["contexts"]["trace"]["origin"] == "manual"
+    assert event["spans"][0]["origin"] == "auto.ai.anthropic"
+    assert event["spans"][0]["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "chat"
+
+
+@pytest.mark.asyncio
+async def test_span_origin_async(sentry_init, capture_events):
+    sentry_init(
+        integrations=[AnthropicIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    client = AsyncAnthropic(api_key="z")
+    client.messages._post = AsyncMock(return_value=EXAMPLE_MESSAGE)
+
+    messages = [
+        {
+            "role": "user",
+            "content": "Hello, Claude",
+        }
+    ]
+
+    with start_transaction(name="anthropic"):
+        await client.messages.create(max_tokens=1024, messages=messages, model="model")
+
+    (event,) = events
+
+    assert event["contexts"]["trace"]["origin"] == "manual"
+    assert event["spans"][0]["origin"] == "auto.ai.anthropic"
+    assert event["spans"][0]["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "chat"
+
+
+@pytest.mark.skipif(
+    ANTHROPIC_VERSION < (0, 27),
+    reason="Versions <0.27.0 do not include InputJSONDelta.",
+)
+def test_collect_ai_data_with_input_json_delta():
+    event = ContentBlockDeltaEvent(
+        delta=InputJSONDelta(partial_json="test", type="input_json_delta"),
+        index=0,
+        type="content_block_delta",
+    )
+    model = None
+    input_tokens = 10
+    output_tokens = 20
+    content_blocks = []
+
+    model, new_input_tokens, new_output_tokens, new_content_blocks = _collect_ai_data(
+        event, model, input_tokens, output_tokens, content_blocks
+    )
+
+    assert model is None
+    assert new_input_tokens == input_tokens
+    assert new_output_tokens == output_tokens
+    assert new_content_blocks == ["test"]
+
+
+@pytest.mark.skipif(
+    ANTHROPIC_VERSION < (0, 27),
+    reason="Versions <0.27.0 do not include InputJSONDelta.",
+)
+def test_set_output_data_with_input_json_delta(sentry_init):
+    sentry_init(
+        integrations=[AnthropicIntegration(include_prompts=True)],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+
+    with start_transaction(name="test"):
+        span = start_span()
+        integration = AnthropicIntegration()
+        json_deltas = ["{'test': 'data',", "'more': 'json'}"]
+        _set_output_data(
+            span,
+            integration,
+            model="",
+            input_tokens=10,
+            output_tokens=20,
+            content_blocks=[{"text": "".join(json_deltas), "type": "text"}],
+        )
+
+        assert (
+            span._data.get(SPANDATA.GEN_AI_RESPONSE_TEXT)
+            == "{'test': 'data','more': 'json'}"
+        )
+        assert span._data.get(SPANDATA.GEN_AI_USAGE_INPUT_TOKENS) == 10
+        assert span._data.get(SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS) == 20
+        assert span._data.get(SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS) == 30
+
+
+def test_anthropic_message_role_mapping(sentry_init, capture_events):
+    """Test that Anthropic integration properly maps message roles like 'ai' to 'assistant'"""
+    sentry_init(
+        integrations=[AnthropicIntegration(include_prompts=True)],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+    events = capture_events()
+
+    client = Anthropic(api_key="z")
+
+    def mock_messages_create(*args, **kwargs):
+        return Message(
+            id="msg_1",
+            content=[TextBlock(text="Hi there!", type="text")],
+            model="claude-3-opus",
+            role="assistant",
+            stop_reason="end_turn",
+            stop_sequence=None,
+            type="message",
+            usage=Usage(input_tokens=10, output_tokens=5),
+        )
+
+    client.messages._post = mock.Mock(return_value=mock_messages_create())
+
+    # Test messages with mixed roles including "ai" that should be mapped to "assistant"
+    test_messages = [
+        {"role": "system", "content": "You are helpful."},
+        {"role": "user", "content": "Hello"},
+        {"role": "ai", "content": "Hi there!"},  # Should be mapped to "assistant"
+        {"role": "assistant", "content": "How can I help?"},  # Should stay "assistant"
+    ]
+
+    with start_transaction(name="anthropic tx"):
+        client.messages.create(
+            model="claude-3-opus", max_tokens=10, messages=test_messages
+        )
+
+    (event,) = events
+    span = event["spans"][0]
+
+    # Verify that the span was created correctly
+    assert span["op"] == "gen_ai.chat"
+    assert span["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "chat"
+    assert SPANDATA.GEN_AI_REQUEST_MESSAGES in span["data"]
+
+    # Parse the stored messages
+    stored_messages = json.loads(span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES])
+
+    # Verify that "ai" role was mapped to "assistant"
+    assert len(stored_messages) == 4
+    assert stored_messages[0]["role"] == "system"
+    assert stored_messages[1]["role"] == "user"
+    assert (
+        stored_messages[2]["role"] == "assistant"
+    )  # "ai" should be mapped to "assistant"
+    assert stored_messages[3]["role"] == "assistant"  # should stay "assistant"
+
+    # Verify content is preserved
+    assert stored_messages[2]["content"] == "Hi there!"
+    assert stored_messages[3]["content"] == "How can I help?"
+
+    # Verify no "ai" roles remain
+    roles = [msg["role"] for msg in stored_messages]
+    assert "ai" not in roles
+
+
+def test_anthropic_message_truncation(sentry_init, capture_events):
+    """Test that large messages are truncated properly in Anthropic integration."""
+    sentry_init(
+        integrations=[AnthropicIntegration(include_prompts=True)],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+    events = capture_events()
+
+    client = Anthropic(api_key="z")
+    client.messages._post = mock.Mock(return_value=EXAMPLE_MESSAGE)
+
+    large_content = (
+        "This is a very long message that will exceed our size limits. " * 1000
+    )
+    messages = [
+        {"role": "user", "content": "small message 1"},
+        {"role": "assistant", "content": large_content},
+        {"role": "user", "content": large_content},
+        {"role": "assistant", "content": "small message 4"},
+        {"role": "user", "content": "small message 5"},
+    ]
+
+    with start_transaction():
+        client.messages.create(max_tokens=1024, messages=messages, model="model")
+
+    assert len(events) > 0
+    tx = events[0]
+    assert tx["type"] == "transaction"
+
+    chat_spans = [
+        span for span in tx.get("spans", []) if span.get("op") == OP.GEN_AI_CHAT
+    ]
+    assert len(chat_spans) > 0
+
+    chat_span = chat_spans[0]
+    assert chat_span["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "chat"
+    assert SPANDATA.GEN_AI_REQUEST_MESSAGES in chat_span["data"]
+
+    messages_data = chat_span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES]
+    assert isinstance(messages_data, str)
+
+    parsed_messages = json.loads(messages_data)
+    assert isinstance(parsed_messages, list)
+    assert len(parsed_messages) == 2
+    assert "small message 4" in str(parsed_messages[0])
+    assert "small message 5" in str(parsed_messages[1])
+    assert tx["_meta"]["spans"]["0"]["data"]["gen_ai.request.messages"][""]["len"] == 5
+
+
+@pytest.mark.parametrize(
+    "send_default_pii, include_prompts",
+    [
+        (True, True),
+        (True, False),
+        (False, True),
+        (False, False),
+    ],
+)
+def test_nonstreaming_create_message_with_system_prompt(
+    sentry_init, capture_events, send_default_pii, include_prompts
+):
+    """Test that system prompts are properly captured in GEN_AI_REQUEST_MESSAGES."""
+    sentry_init(
+        integrations=[AnthropicIntegration(include_prompts=include_prompts)],
+        traces_sample_rate=1.0,
+        send_default_pii=send_default_pii,
+    )
+    events = capture_events()
+    client = Anthropic(api_key="z")
+    client.messages._post = mock.Mock(return_value=EXAMPLE_MESSAGE)
+
+    messages = [
+        {
+            "role": "user",
+            "content": "Hello, Claude",
+        }
+    ]
+
+    with start_transaction(name="anthropic"):
+        response = client.messages.create(
+            max_tokens=1024,
+            messages=messages,
+            model="model",
+            system="You are a helpful assistant.",
+        )
+
+    assert response == EXAMPLE_MESSAGE
+    usage = response.usage
+
+    assert usage.input_tokens == 10
+    assert usage.output_tokens == 20
+
+    assert len(events) == 1
+    (event,) = events
+
+    assert event["type"] == "transaction"
+    assert event["transaction"] == "anthropic"
+
+    assert len(event["spans"]) == 1
+    (span,) = event["spans"]
+
+    assert span["op"] == OP.GEN_AI_CHAT
+    assert span["description"] == "chat model"
+    assert span["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "chat"
+    assert span["data"][SPANDATA.GEN_AI_REQUEST_MODEL] == "model"
+
+    if send_default_pii and include_prompts:
+        assert SPANDATA.GEN_AI_REQUEST_MESSAGES in span["data"]
+        stored_messages = json.loads(span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES])
+        assert len(stored_messages) == 2
+        # System message should be first
+        assert stored_messages[0]["role"] == "system"
+        assert stored_messages[0]["content"] == "You are a helpful assistant."
+        # User message should be second
+        assert stored_messages[1]["role"] == "user"
+        assert stored_messages[1]["content"] == "Hello, Claude"
+        assert span["data"][SPANDATA.GEN_AI_RESPONSE_TEXT] == "Hi, I'm Claude."
+    else:
+        assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["data"]
+        assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["data"]
+
+    assert span["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10
+    assert span["data"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 20
+    assert span["data"][SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS] == 30
+    assert span["data"][SPANDATA.GEN_AI_RESPONSE_STREAMING] is False
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    "send_default_pii, include_prompts",
+    [
+        (True, True),
+        (True, False),
+        (False, True),
+        (False, False),
+    ],
+)
+async def test_nonstreaming_create_message_with_system_prompt_async(
+    sentry_init, capture_events, send_default_pii, include_prompts
+):
+    """Test that system prompts are properly captured in GEN_AI_REQUEST_MESSAGES (async)."""
+    sentry_init(
+        integrations=[AnthropicIntegration(include_prompts=include_prompts)],
+        traces_sample_rate=1.0,
+        send_default_pii=send_default_pii,
+    )
+    events = capture_events()
+    client = AsyncAnthropic(api_key="z")
+    client.messages._post = AsyncMock(return_value=EXAMPLE_MESSAGE)
+
+    messages = [
+        {
+            "role": "user",
+            "content": "Hello, Claude",
+        }
+    ]
+
+    with start_transaction(name="anthropic"):
+        response = await client.messages.create(
+            max_tokens=1024,
+            messages=messages,
+            model="model",
+            system="You are a helpful assistant.",
+        )
+
+    assert response == EXAMPLE_MESSAGE
+    usage = response.usage
+
+    assert usage.input_tokens == 10
+    assert usage.output_tokens == 20
+
+    assert len(events) == 1
+    (event,) = events
+
+    assert event["type"] == "transaction"
+    assert event["transaction"] == "anthropic"
+
+    assert len(event["spans"]) == 1
+    (span,) = event["spans"]
+
+    assert span["op"] == OP.GEN_AI_CHAT
+    assert span["description"] == "chat model"
+    assert span["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "chat"
+    assert span["data"][SPANDATA.GEN_AI_REQUEST_MODEL] == "model"
+
+    if send_default_pii and include_prompts:
+        assert SPANDATA.GEN_AI_REQUEST_MESSAGES in span["data"]
+        stored_messages = json.loads(span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES])
+        assert len(stored_messages) == 2
+        # System message should be first
+        assert stored_messages[0]["role"] == "system"
+        assert stored_messages[0]["content"] == "You are a helpful assistant."
+        # User message should be second
+        assert stored_messages[1]["role"] == "user"
+        assert stored_messages[1]["content"] == "Hello, Claude"
+        assert span["data"][SPANDATA.GEN_AI_RESPONSE_TEXT] == "Hi, I'm Claude."
+    else:
+        assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["data"]
+        assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["data"]
+
+    assert span["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10
+    assert span["data"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 20
+    assert span["data"][SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS] == 30
+    assert span["data"][SPANDATA.GEN_AI_RESPONSE_STREAMING] is False
+
+
+@pytest.mark.parametrize(
+    "send_default_pii, include_prompts",
+    [
+        (True, True),
+        (True, False),
+        (False, True),
+        (False, False),
+    ],
+)
+def test_streaming_create_message_with_system_prompt(
+    sentry_init, capture_events, send_default_pii, include_prompts
+):
+    """Test that system prompts are properly captured in streaming mode."""
+    client = Anthropic(api_key="z")
+    returned_stream = Stream(cast_to=None, response=None, client=client)
+    returned_stream._iterator = [
+        MessageStartEvent(
+            message=EXAMPLE_MESSAGE,
+            type="message_start",
+        ),
+        ContentBlockStartEvent(
+            type="content_block_start",
+            index=0,
+            content_block=TextBlock(type="text", text=""),
+        ),
+        ContentBlockDeltaEvent(
+            delta=TextDelta(text="Hi", type="text_delta"),
+            index=0,
+            type="content_block_delta",
+        ),
+        ContentBlockDeltaEvent(
+            delta=TextDelta(text="!", type="text_delta"),
+            index=0,
+            type="content_block_delta",
+        ),
+        ContentBlockDeltaEvent(
+            delta=TextDelta(text=" I'm Claude!", type="text_delta"),
+            index=0,
+            type="content_block_delta",
+        ),
+        ContentBlockStopEvent(type="content_block_stop", index=0),
+        MessageDeltaEvent(
+            delta=Delta(),
+            usage=MessageDeltaUsage(output_tokens=10),
+            type="message_delta",
+        ),
+    ]
+
+    sentry_init(
+        integrations=[AnthropicIntegration(include_prompts=include_prompts)],
+        traces_sample_rate=1.0,
+        send_default_pii=send_default_pii,
+    )
+    events = capture_events()
+    client.messages._post = mock.Mock(return_value=returned_stream)
+
+    messages = [
+        {
+            "role": "user",
+            "content": "Hello, Claude",
+        }
+    ]
+
+    with start_transaction(name="anthropic"):
+        message = client.messages.create(
+            max_tokens=1024,
+            messages=messages,
+            model="model",
+            stream=True,
+            system="You are a helpful assistant.",
+        )
+
+        for _ in message:
+            pass
+
+    assert message == returned_stream
+    assert len(events) == 1
+    (event,) = events
+
+    assert event["type"] == "transaction"
+    assert event["transaction"] == "anthropic"
+
+    assert len(event["spans"]) == 1
+    (span,) = event["spans"]
+
+    assert span["op"] == OP.GEN_AI_CHAT
+    assert span["description"] == "chat model"
+    assert span["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "chat"
+    assert span["data"][SPANDATA.GEN_AI_REQUEST_MODEL] == "model"
+
+    if send_default_pii and include_prompts:
+        assert SPANDATA.GEN_AI_REQUEST_MESSAGES in span["data"]
+        stored_messages = json.loads(span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES])
+        assert len(stored_messages) == 2
+        # System message should be first
+        assert stored_messages[0]["role"] == "system"
+        assert stored_messages[0]["content"] == "You are a helpful assistant."
+        # User message should be second
+        assert stored_messages[1]["role"] == "user"
+        assert stored_messages[1]["content"] == "Hello, Claude"
+        assert span["data"][SPANDATA.GEN_AI_RESPONSE_TEXT] == "Hi! I'm Claude!"
+
+    else:
+        assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["data"]
+        assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["data"]
+
+    assert span["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10
+    assert span["data"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 30
+    assert span["data"][SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS] == 40
+    assert span["data"][SPANDATA.GEN_AI_RESPONSE_STREAMING] is True
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    "send_default_pii, include_prompts",
+    [
+        (True, True),
+        (True, False),
+        (False, True),
+        (False, False),
+    ],
+)
+async def test_streaming_create_message_with_system_prompt_async(
+    sentry_init, capture_events, send_default_pii, include_prompts
+):
+    """Test that system prompts are properly captured in streaming mode (async)."""
+    client = AsyncAnthropic(api_key="z")
+    returned_stream = AsyncStream(cast_to=None, response=None, client=client)
+    returned_stream._iterator = async_iterator(
+        [
+            MessageStartEvent(
+                message=EXAMPLE_MESSAGE,
+                type="message_start",
+            ),
+            ContentBlockStartEvent(
+                type="content_block_start",
+                index=0,
+                content_block=TextBlock(type="text", text=""),
+            ),
+            ContentBlockDeltaEvent(
+                delta=TextDelta(text="Hi", type="text_delta"),
+                index=0,
+                type="content_block_delta",
+            ),
+            ContentBlockDeltaEvent(
+                delta=TextDelta(text="!", type="text_delta"),
+                index=0,
+                type="content_block_delta",
+            ),
+            ContentBlockDeltaEvent(
+                delta=TextDelta(text=" I'm Claude!", type="text_delta"),
+                index=0,
+                type="content_block_delta",
+            ),
+            ContentBlockStopEvent(type="content_block_stop", index=0),
+            MessageDeltaEvent(
+                delta=Delta(),
+                usage=MessageDeltaUsage(output_tokens=10),
+                type="message_delta",
+            ),
+        ]
+    )
+
+    sentry_init(
+        integrations=[AnthropicIntegration(include_prompts=include_prompts)],
+        traces_sample_rate=1.0,
+        send_default_pii=send_default_pii,
+    )
+    events = capture_events()
+    client.messages._post = AsyncMock(return_value=returned_stream)
+
+    messages = [
+        {
+            "role": "user",
+            "content": "Hello, Claude",
+        }
+    ]
+
+    with start_transaction(name="anthropic"):
+        message = await client.messages.create(
+            max_tokens=1024,
+            messages=messages,
+            model="model",
+            stream=True,
+            system="You are a helpful assistant.",
+        )
+
+        async for _ in message:
+            pass
+
+    assert message == returned_stream
+    assert len(events) == 1
+    (event,) = events
+
+    assert event["type"] == "transaction"
+    assert event["transaction"] == "anthropic"
+
+    assert len(event["spans"]) == 1
+    (span,) = event["spans"]
+
+    assert span["op"] == OP.GEN_AI_CHAT
+    assert span["description"] == "chat model"
+    assert span["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "chat"
+    assert span["data"][SPANDATA.GEN_AI_REQUEST_MODEL] == "model"
+
+    if send_default_pii and include_prompts:
+        assert SPANDATA.GEN_AI_REQUEST_MESSAGES in span["data"]
+        stored_messages = json.loads(span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES])
+        assert len(stored_messages) == 2
+        # System message should be first
+        assert stored_messages[0]["role"] == "system"
+        assert stored_messages[0]["content"] == "You are a helpful assistant."
+        # User message should be second
+        assert stored_messages[1]["role"] == "user"
+        assert stored_messages[1]["content"] == "Hello, Claude"
+        assert span["data"][SPANDATA.GEN_AI_RESPONSE_TEXT] == "Hi! I'm Claude!"
+
+    else:
+        assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["data"]
+        assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["data"]
+
+    assert span["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10
+    assert span["data"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 30
+    assert span["data"][SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS] == 40
+    assert span["data"][SPANDATA.GEN_AI_RESPONSE_STREAMING] is True
+
+
+def test_system_prompt_with_complex_structure(sentry_init, capture_events):
+    """Test that complex system prompt structures (list of text blocks) are properly captured."""
+    sentry_init(
+        integrations=[AnthropicIntegration(include_prompts=True)],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+    events = capture_events()
+    client = Anthropic(api_key="z")
+    client.messages._post = mock.Mock(return_value=EXAMPLE_MESSAGE)
+
+    # System prompt as list of text blocks
+    system_prompt = [
+        {"type": "text", "text": "You are a helpful assistant."},
+        {"type": "text", "text": "Be concise and clear."},
+    ]
+
+    messages = [
+        {
+            "role": "user",
+            "content": "Hello",
+        }
+    ]
+
+    with start_transaction(name="anthropic"):
+        response = client.messages.create(
+            max_tokens=1024, messages=messages, model="model", system=system_prompt
+        )
+
+    assert response == EXAMPLE_MESSAGE
+    assert len(events) == 1
+    (event,) = events
+
+    assert len(event["spans"]) == 1
+    (span,) = event["spans"]
+
+    assert span["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "chat"
+    assert SPANDATA.GEN_AI_REQUEST_MESSAGES in span["data"]
+    stored_messages = json.loads(span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES])
+
+    # Should have system message first, then user message
+    assert len(stored_messages) == 2
+    assert stored_messages[0]["role"] == "system"
+    # System content should be a list of text blocks
+    assert isinstance(stored_messages[0]["content"], list)
+    assert len(stored_messages[0]["content"]) == 2
+    assert stored_messages[0]["content"][0]["type"] == "text"
+    assert stored_messages[0]["content"][0]["text"] == "You are a helpful assistant."
+    assert stored_messages[0]["content"][1]["type"] == "text"
+    assert stored_messages[0]["content"][1]["text"] == "Be concise and clear."
+    assert stored_messages[1]["role"] == "user"
+    assert stored_messages[1]["content"] == "Hello"
diff --git a/tests/integrations/arq/test_arq.py b/tests/integrations/arq/test_arq.py
index 0ed9da992b..d8b7e715f2 100644
--- a/tests/integrations/arq/test_arq.py
+++ b/tests/integrations/arq/test_arq.py
@@ -1,7 +1,9 @@
 import asyncio
+from datetime import timedelta
+
 import pytest
 
-from sentry_sdk import start_transaction
+from sentry_sdk import get_client, start_transaction
 from sentry_sdk.integrations.arq import ArqIntegration
 
 import arq.worker
@@ -60,7 +62,6 @@ def inner(
             integrations=[ArqIntegration()],
             traces_sample_rate=1.0,
             send_default_pii=True,
-            debug=True,
         )
 
         server = FakeRedis()
@@ -84,14 +85,65 @@ class WorkerSettings:
     return inner
 
 
+@pytest.fixture
+def init_arq_with_dict_settings(sentry_init):
+    def inner(
+        cls_functions=None,
+        cls_cron_jobs=None,
+        kw_functions=None,
+        kw_cron_jobs=None,
+        allow_abort_jobs_=False,
+    ):
+        cls_functions = cls_functions or []
+        cls_cron_jobs = cls_cron_jobs or []
+
+        kwargs = {}
+        if kw_functions is not None:
+            kwargs["functions"] = kw_functions
+        if kw_cron_jobs is not None:
+            kwargs["cron_jobs"] = kw_cron_jobs
+
+        sentry_init(
+            integrations=[ArqIntegration()],
+            traces_sample_rate=1.0,
+            send_default_pii=True,
+        )
+
+        server = FakeRedis()
+        pool = ArqRedis(pool_or_conn=server.connection_pool)
+
+        worker_settings = {
+            "functions": cls_functions,
+            "cron_jobs": cls_cron_jobs,
+            "redis_pool": pool,
+            "allow_abort_jobs": allow_abort_jobs_,
+        }
+
+        if not worker_settings["functions"]:
+            del worker_settings["functions"]
+        if not worker_settings["cron_jobs"]:
+            del worker_settings["cron_jobs"]
+
+        worker = arq.worker.create_worker(worker_settings, **kwargs)
+
+        return pool, worker
+
+    return inner
+
+
 @pytest.mark.asyncio
-async def test_job_result(init_arq):
+@pytest.mark.parametrize(
+    "init_arq_settings", ["init_arq", "init_arq_with_dict_settings"]
+)
+async def test_job_result(init_arq_settings, request):
     async def increase(ctx, num):
         return num + 1
 
+    init_fixture_method = request.getfixturevalue(init_arq_settings)
+
     increase.__qualname__ = increase.__name__
 
-    pool, worker = init_arq([increase])
+    pool, worker = init_fixture_method([increase])
 
     job = await pool.enqueue_job("increase", 3)
 
@@ -106,14 +158,19 @@ async def increase(ctx, num):
 
 
 @pytest.mark.asyncio
-async def test_job_retry(capture_events, init_arq):
+@pytest.mark.parametrize(
+    "init_arq_settings", ["init_arq", "init_arq_with_dict_settings"]
+)
+async def test_job_retry(capture_events, init_arq_settings, request):
     async def retry_job(ctx):
         if ctx["job_try"] < 2:
             raise arq.worker.Retry
 
+    init_fixture_method = request.getfixturevalue(init_arq_settings)
+
     retry_job.__qualname__ = retry_job.__name__
 
-    pool, worker = init_arq([retry_job])
+    pool, worker = init_fixture_method([retry_job])
 
     job = await pool.enqueue_job("retry_job")
 
@@ -140,11 +197,18 @@ async def retry_job(ctx):
     "source", [("cls_functions", "cls_cron_jobs"), ("kw_functions", "kw_cron_jobs")]
 )
 @pytest.mark.parametrize("job_fails", [True, False], ids=["error", "success"])
+@pytest.mark.parametrize(
+    "init_arq_settings", ["init_arq", "init_arq_with_dict_settings"]
+)
 @pytest.mark.asyncio
-async def test_job_transaction(capture_events, init_arq, source, job_fails):
+async def test_job_transaction(
+    capture_events, init_arq_settings, source, job_fails, request
+):
     async def division(_, a, b=0):
         return a / b
 
+    init_fixture_method = request.getfixturevalue(init_arq_settings)
+
     division.__qualname__ = division.__name__
 
     cron_func = async_partial(division, a=1, b=int(not job_fails))
@@ -153,7 +217,9 @@ async def division(_, a, b=0):
     cron_job = cron(cron_func, minute=0, run_at_startup=True)
 
     functions_key, cron_jobs_key = source
-    pool, worker = init_arq(**{functions_key: [division], cron_jobs_key: [cron_job]})
+    pool, worker = init_fixture_method(
+        **{functions_key: [division], cron_jobs_key: [cron_job]}
+    )
 
     events = capture_events()
 
@@ -214,12 +280,17 @@ async def division(_, a, b=0):
 
 
 @pytest.mark.parametrize("source", ["cls_functions", "kw_functions"])
+@pytest.mark.parametrize(
+    "init_arq_settings", ["init_arq", "init_arq_with_dict_settings"]
+)
 @pytest.mark.asyncio
-async def test_enqueue_job(capture_events, init_arq, source):
+async def test_enqueue_job(capture_events, init_arq_settings, source, request):
     async def dummy_job(_):
         pass
 
-    pool, _ = init_arq(**{source: [dummy_job]})
+    init_fixture_method = request.getfixturevalue(init_arq_settings)
+
+    pool, _ = init_fixture_method(**{source: [dummy_job]})
 
     events = capture_events()
 
@@ -234,3 +305,121 @@ async def dummy_job(_):
     assert len(event["spans"])
     assert event["spans"][0]["op"] == "queue.submit.arq"
     assert event["spans"][0]["description"] == "dummy_job"
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    "init_arq_settings", ["init_arq", "init_arq_with_dict_settings"]
+)
+async def test_execute_job_without_integration(init_arq_settings, request):
+    async def dummy_job(_ctx):
+        pass
+
+    init_fixture_method = request.getfixturevalue(init_arq_settings)
+
+    dummy_job.__qualname__ = dummy_job.__name__
+
+    pool, worker = init_fixture_method([dummy_job])
+    # remove the integration to trigger the edge case
+    get_client().integrations.pop("arq")
+
+    job = await pool.enqueue_job("dummy_job")
+
+    await worker.run_job(job.job_id, timestamp_ms())
+
+    assert await job.result() is None
+
+
+@pytest.mark.parametrize("source", ["cls_functions", "kw_functions"])
+@pytest.mark.parametrize(
+    "init_arq_settings", ["init_arq", "init_arq_with_dict_settings"]
+)
+@pytest.mark.asyncio
+async def test_span_origin_producer(capture_events, init_arq_settings, source, request):
+    async def dummy_job(_):
+        pass
+
+    init_fixture_method = request.getfixturevalue(init_arq_settings)
+
+    pool, _ = init_fixture_method(**{source: [dummy_job]})
+
+    events = capture_events()
+
+    with start_transaction():
+        await pool.enqueue_job("dummy_job")
+
+    (event,) = events
+    assert event["contexts"]["trace"]["origin"] == "manual"
+    assert event["spans"][0]["origin"] == "auto.queue.arq"
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    "init_arq_settings", ["init_arq", "init_arq_with_dict_settings"]
+)
+async def test_span_origin_consumer(capture_events, init_arq_settings, request):
+    async def job(ctx):
+        pass
+
+    init_fixture_method = request.getfixturevalue(init_arq_settings)
+
+    job.__qualname__ = job.__name__
+
+    pool, worker = init_fixture_method([job])
+
+    job = await pool.enqueue_job("retry_job")
+
+    events = capture_events()
+
+    await worker.run_job(job.job_id, timestamp_ms())
+
+    (event,) = events
+
+    assert event["contexts"]["trace"]["origin"] == "auto.queue.arq"
+    assert event["spans"][0]["origin"] == "auto.db.redis"
+    assert event["spans"][1]["origin"] == "auto.db.redis"
+
+
+@pytest.mark.asyncio
+async def test_job_concurrency(capture_events, init_arq):
+    """
+    10 - division starts
+    70 - sleepy starts
+    110 - division raises error
+    120 - sleepy finishes
+
+    """
+
+    async def sleepy(_):
+        await asyncio.sleep(0.05)
+
+    async def division(_):
+        await asyncio.sleep(0.1)
+        return 1 / 0
+
+    sleepy.__qualname__ = sleepy.__name__
+    division.__qualname__ = division.__name__
+
+    pool, worker = init_arq([sleepy, division])
+
+    events = capture_events()
+
+    await pool.enqueue_job(
+        "division", _job_id="123", _defer_by=timedelta(milliseconds=10)
+    )
+    await pool.enqueue_job(
+        "sleepy", _job_id="456", _defer_by=timedelta(milliseconds=70)
+    )
+
+    loop = asyncio.get_event_loop()
+    task = loop.create_task(worker.async_run())
+    await asyncio.sleep(1)
+
+    task.cancel()
+
+    await worker.close()
+
+    exception_event = events[1]
+    assert exception_event["exception"]["values"][0]["type"] == "ZeroDivisionError"
+    assert exception_event["transaction"] == "division"
+    assert exception_event["extra"]["arq-job"]["task"] == "division"
diff --git a/tests/integrations/asgi/test_asgi.py b/tests/integrations/asgi/test_asgi.py
index d60991e99e..ec2796c140 100644
--- a/tests/integrations/asgi/test_asgi.py
+++ b/tests/integrations/asgi/test_asgi.py
@@ -1,21 +1,15 @@
-import sys
-
 from collections import Counter
 
 import pytest
 import sentry_sdk
 from sentry_sdk import capture_message
+from sentry_sdk.tracing import TransactionSource
 from sentry_sdk.integrations._asgi_common import _get_ip, _get_headers
 from sentry_sdk.integrations.asgi import SentryAsgiMiddleware, _looks_like_asgi3
 
 from async_asgi_testclient import TestClient
 
 
-minimum_python_36 = pytest.mark.skipif(
-    sys.version_info < (3, 6), reason="ASGI is only supported in Python >= 3.6"
-)
-
-
 @pytest.fixture
 def asgi3_app():
     async def app(scope, receive, send):
@@ -133,7 +127,32 @@ async def app(scope, receive, send):
     return app
 
 
-@minimum_python_36
+@pytest.fixture
+def asgi3_custom_transaction_app():
+    async def app(scope, receive, send):
+        sentry_sdk.get_current_scope().set_transaction_name(
+            "foobar", source=TransactionSource.CUSTOM
+        )
+        await send(
+            {
+                "type": "http.response.start",
+                "status": 200,
+                "headers": [
+                    [b"content-type", b"text/plain"],
+                ],
+            }
+        )
+
+        await send(
+            {
+                "type": "http.response.body",
+                "body": b"Hello, world!",
+            }
+        )
+
+    return app
+
+
 def test_invalid_transaction_style(asgi3_app):
     with pytest.raises(ValueError) as exp:
         SentryAsgiMiddleware(asgi3_app, transaction_style="URL")
@@ -144,7 +163,6 @@ def test_invalid_transaction_style(asgi3_app):
     )
 
 
-@minimum_python_36
 @pytest.mark.asyncio
 async def test_capture_transaction(
     sentry_init,
@@ -176,7 +194,6 @@ async def test_capture_transaction(
     }
 
 
-@minimum_python_36
 @pytest.mark.asyncio
 async def test_capture_transaction_with_error(
     sentry_init,
@@ -214,7 +231,6 @@ async def test_capture_transaction_with_error(
     assert transaction_event["request"] == error_event["request"]
 
 
-@minimum_python_36
 @pytest.mark.asyncio
 async def test_has_trace_if_performance_enabled(
     sentry_init,
@@ -247,7 +263,6 @@ async def test_has_trace_if_performance_enabled(
     )
 
 
-@minimum_python_36
 @pytest.mark.asyncio
 async def test_has_trace_if_performance_disabled(
     sentry_init,
@@ -271,7 +286,6 @@ async def test_has_trace_if_performance_disabled(
     assert "trace_id" in error_event["contexts"]["trace"]
 
 
-@minimum_python_36
 @pytest.mark.asyncio
 async def test_trace_from_headers_if_performance_enabled(
     sentry_init,
@@ -305,7 +319,6 @@ async def test_trace_from_headers_if_performance_enabled(
     assert transaction_event["contexts"]["trace"]["trace_id"] == trace_id
 
 
-@minimum_python_36
 @pytest.mark.asyncio
 async def test_trace_from_headers_if_performance_disabled(
     sentry_init,
@@ -334,40 +347,35 @@ async def test_trace_from_headers_if_performance_disabled(
     assert error_event["contexts"]["trace"]["trace_id"] == trace_id
 
 
-@minimum_python_36
 @pytest.mark.asyncio
 async def test_websocket(sentry_init, asgi3_ws_app, capture_events, request):
-    sentry_init(debug=True, send_default_pii=True)
+    sentry_init(send_default_pii=True, traces_sample_rate=1.0)
 
     events = capture_events()
 
     asgi3_ws_app = SentryAsgiMiddleware(asgi3_ws_app)
 
-    scope = {
-        "type": "websocket",
-        "endpoint": asgi3_app,
-        "client": ("127.0.0.1", 60457),
-        "route": "some_url",
-        "headers": [
-            ("accept", "*/*"),
-        ],
-    }
+    request_url = "/ws"
 
     with pytest.raises(ValueError):
-        async with TestClient(asgi3_ws_app, scope=scope) as client:
-            async with client.websocket_connect("/ws") as ws:
-                await ws.receive_text()
+        client = TestClient(asgi3_ws_app)
+        async with client.websocket_connect(request_url) as ws:
+            await ws.receive_text()
 
-    msg_event, error_event = events
+    msg_event, error_event, transaction_event = events
 
+    assert msg_event["transaction"] == request_url
+    assert msg_event["transaction_info"] == {"source": "url"}
     assert msg_event["message"] == "Some message to the world!"
 
     (exc,) = error_event["exception"]["values"]
     assert exc["type"] == "ValueError"
     assert exc["value"] == "Oh no"
 
+    assert transaction_event["transaction"] == request_url
+    assert transaction_event["transaction_info"] == {"source": "url"}
+
 
-@minimum_python_36
 @pytest.mark.asyncio
 async def test_auto_session_tracking_with_aggregates(
     sentry_init, asgi3_app, capture_envelopes
@@ -406,7 +414,6 @@ async def test_auto_session_tracking_with_aggregates(
     assert len(session_aggregates) == 1
 
 
-@minimum_python_36
 @pytest.mark.parametrize(
     "url,transaction_style,expected_transaction,expected_source",
     [
@@ -470,7 +477,6 @@ async def __call__():
         pass
 
 
-@minimum_python_36
 def test_looks_like_asgi3(asgi3_app):
     # branch: inspect.isclass(app)
     assert _looks_like_asgi3(MockAsgi3App)
@@ -487,7 +493,6 @@ def test_looks_like_asgi3(asgi3_app):
     assert not _looks_like_asgi3(asgi2)
 
 
-@minimum_python_36
 def test_get_ip_x_forwarded_for():
     headers = [
         (b"x-forwarded-for", b"8.8.8.8"),
@@ -525,7 +530,6 @@ def test_get_ip_x_forwarded_for():
     assert ip == "5.5.5.5"
 
 
-@minimum_python_36
 def test_get_ip_x_real_ip():
     headers = [
         (b"x-real-ip", b"10.10.10.10"),
@@ -550,7 +554,6 @@ def test_get_ip_x_real_ip():
     assert ip == "8.8.8.8"
 
 
-@minimum_python_36
 def test_get_ip():
     # if now headers are provided the ip is taken from the client.
     headers = []
@@ -584,7 +587,6 @@ def test_get_ip():
     assert ip == "10.10.10.10"
 
 
-@minimum_python_36
 def test_get_headers():
     headers = [
         (b"x-real-ip", b"10.10.10.10"),
@@ -602,7 +604,6 @@ def test_get_headers():
     }
 
 
-@minimum_python_36
 @pytest.mark.asyncio
 @pytest.mark.parametrize(
     "request_url,transaction_style,expected_transaction_name,expected_transaction_source",
@@ -635,7 +636,6 @@ async def test_transaction_name(
     """
     sentry_init(
         traces_sample_rate=1.0,
-        debug=True,
     )
 
     envelopes = capture_envelopes()
@@ -654,7 +654,6 @@ async def test_transaction_name(
     )
 
 
-@minimum_python_36
 @pytest.mark.asyncio
 @pytest.mark.parametrize(
     "request_url, transaction_style,expected_transaction_name,expected_transaction_source",
@@ -698,10 +697,26 @@ def dummy_traces_sampler(sampling_context):
     sentry_init(
         traces_sampler=dummy_traces_sampler,
         traces_sample_rate=1.0,
-        debug=True,
     )
 
     app = SentryAsgiMiddleware(asgi3_app, transaction_style=transaction_style)
 
     async with TestClient(app) as client:
         await client.get(request_url)
+
+
+@pytest.mark.asyncio
+async def test_custom_transaction_name(
+    sentry_init, asgi3_custom_transaction_app, capture_events
+):
+    sentry_init(traces_sample_rate=1.0)
+    events = capture_events()
+    app = SentryAsgiMiddleware(asgi3_custom_transaction_app)
+
+    async with TestClient(app) as client:
+        await client.get("/test")
+
+    (transaction_event,) = events
+    assert transaction_event["type"] == "transaction"
+    assert transaction_event["transaction"] == "foobar"
+    assert transaction_event["transaction_info"] == {"source": "custom"}
diff --git a/tests/integrations/asyncio/test_asyncio_py3.py b/tests/integrations/asyncio/test_asyncio.py
similarity index 82%
rename from tests/integrations/asyncio/test_asyncio_py3.py
rename to tests/integrations/asyncio/test_asyncio.py
index c563f37b7d..11b60fb0e1 100644
--- a/tests/integrations/asyncio/test_asyncio_py3.py
+++ b/tests/integrations/asyncio/test_asyncio.py
@@ -1,6 +1,7 @@
 import asyncio
 import inspect
 import sys
+from unittest.mock import MagicMock, patch
 
 import pytest
 
@@ -8,19 +9,14 @@
 from sentry_sdk.consts import OP
 from sentry_sdk.integrations.asyncio import AsyncioIntegration, patch_asyncio
 
-try:
-    from unittest.mock import MagicMock, patch
-except ImportError:
-    from mock import MagicMock, patch
-
 try:
     from contextvars import Context, ContextVar
 except ImportError:
     pass  # All tests will be skipped with incompatible versions
 
 
-minimum_python_37 = pytest.mark.skipif(
-    sys.version_info < (3, 7), reason="Asyncio tests need Python >= 3.7"
+minimum_python_38 = pytest.mark.skipif(
+    sys.version_info < (3, 8), reason="Asyncio tests need Python >= 3.8"
 )
 
 
@@ -42,14 +38,6 @@ async def boom():
     1 / 0
 
 
-@pytest.fixture(scope="session")
-def event_loop(request):
-    """Create an instance of the default event loop for each test case."""
-    loop = asyncio.get_event_loop_policy().new_event_loop()
-    yield loop
-    loop.close()
-
-
 def get_sentry_task_factory(mock_get_running_loop):
     """
     Patches (mocked) asyncio and gets the sentry_task_factory.
@@ -61,17 +49,15 @@ def get_sentry_task_factory(mock_get_running_loop):
     return patched_factory
 
 
-@minimum_python_37
-@pytest.mark.asyncio
+@minimum_python_38
+@pytest.mark.asyncio(loop_scope="module")
 async def test_create_task(
     sentry_init,
     capture_events,
-    event_loop,
 ):
     sentry_init(
         traces_sample_rate=1.0,
         send_default_pii=True,
-        debug=True,
         integrations=[
             AsyncioIntegration(),
         ],
@@ -80,11 +66,20 @@ async def test_create_task(
     events = capture_events()
 
     with sentry_sdk.start_transaction(name="test_transaction_for_create_task"):
-        with sentry_sdk.start_span(op="root", description="not so important"):
-            tasks = [event_loop.create_task(foo()), event_loop.create_task(bar())]
+        with sentry_sdk.start_span(op="root", name="not so important"):
+            foo_task = asyncio.create_task(foo())
+            bar_task = asyncio.create_task(bar())
+
+            if hasattr(foo_task.get_coro(), "__name__"):
+                assert foo_task.get_coro().__name__ == "foo"
+            if hasattr(bar_task.get_coro(), "__name__"):
+                assert bar_task.get_coro().__name__ == "bar"
+
+            tasks = [foo_task, bar_task]
+
             await asyncio.wait(tasks, return_when=asyncio.FIRST_EXCEPTION)
 
-            sentry_sdk.flush()
+    sentry_sdk.flush()
 
     (transaction_event,) = events
 
@@ -106,8 +101,8 @@ async def test_create_task(
     )
 
 
-@minimum_python_37
-@pytest.mark.asyncio
+@minimum_python_38
+@pytest.mark.asyncio(loop_scope="module")
 async def test_gather(
     sentry_init,
     capture_events,
@@ -115,7 +110,6 @@ async def test_gather(
     sentry_init(
         traces_sample_rate=1.0,
         send_default_pii=True,
-        debug=True,
         integrations=[
             AsyncioIntegration(),
         ],
@@ -124,10 +118,10 @@ async def test_gather(
     events = capture_events()
 
     with sentry_sdk.start_transaction(name="test_transaction_for_gather"):
-        with sentry_sdk.start_span(op="root", description="not so important"):
+        with sentry_sdk.start_span(op="root", name="not so important"):
             await asyncio.gather(foo(), bar(), return_exceptions=True)
 
-        sentry_sdk.flush()
+    sentry_sdk.flush()
 
     (transaction_event,) = events
 
@@ -149,17 +143,15 @@ async def test_gather(
     )
 
 
-@minimum_python_37
-@pytest.mark.asyncio
+@minimum_python_38
+@pytest.mark.asyncio(loop_scope="module")
 async def test_exception(
     sentry_init,
     capture_events,
-    event_loop,
 ):
     sentry_init(
         traces_sample_rate=1.0,
         send_default_pii=True,
-        debug=True,
         integrations=[
             AsyncioIntegration(),
         ],
@@ -168,11 +160,11 @@ async def test_exception(
     events = capture_events()
 
     with sentry_sdk.start_transaction(name="test_exception"):
-        with sentry_sdk.start_span(op="root", description="not so important"):
-            tasks = [event_loop.create_task(boom()), event_loop.create_task(bar())]
+        with sentry_sdk.start_span(op="root", name="not so important"):
+            tasks = [asyncio.create_task(boom()), asyncio.create_task(bar())]
             await asyncio.wait(tasks, return_when=asyncio.FIRST_EXCEPTION)
 
-            sentry_sdk.flush()
+    sentry_sdk.flush()
 
     (error_event, _) = events
 
@@ -184,8 +176,8 @@ async def test_exception(
     assert error_event["exception"]["values"][0]["mechanism"]["type"] == "asyncio"
 
 
-@minimum_python_37
-@pytest.mark.asyncio
+@minimum_python_38
+@pytest.mark.asyncio(loop_scope="module")
 async def test_task_result(sentry_init):
     sentry_init(
         integrations=[
@@ -201,7 +193,7 @@ async def add(a, b):
 
 
 @minimum_python_311
-@pytest.mark.asyncio
+@pytest.mark.asyncio(loop_scope="module")
 async def test_task_with_context(sentry_init):
     """
     Integration test to ensure working context parameter in Python 3.11+
@@ -230,7 +222,7 @@ async def retrieve_value():
     assert retrieve_task.result() == "changed value"
 
 
-@minimum_python_37
+@minimum_python_38
 @patch("asyncio.get_running_loop")
 def test_patch_asyncio(mock_get_running_loop):
     """
@@ -249,8 +241,7 @@ def test_patch_asyncio(mock_get_running_loop):
     assert callable(sentry_task_factory)
 
 
-@minimum_python_37
-@pytest.mark.forked
+@minimum_python_38
 @patch("asyncio.get_running_loop")
 @patch("sentry_sdk.integrations.asyncio.Task")
 def test_sentry_task_factory_no_factory(MockTask, mock_get_running_loop):  # noqa: N803
@@ -279,8 +270,7 @@ def test_sentry_task_factory_no_factory(MockTask, mock_get_running_loop):  # noq
     assert task_kwargs["loop"] == mock_loop
 
 
-@minimum_python_37
-@pytest.mark.forked
+@minimum_python_38
 @patch("asyncio.get_running_loop")
 def test_sentry_task_factory_with_factory(mock_get_running_loop):
     mock_loop = mock_get_running_loop.return_value
@@ -310,7 +300,8 @@ def test_sentry_task_factory_with_factory(mock_get_running_loop):
 @patch("asyncio.get_running_loop")
 @patch("sentry_sdk.integrations.asyncio.Task")
 def test_sentry_task_factory_context_no_factory(
-    MockTask, mock_get_running_loop  # noqa: N803
+    MockTask,
+    mock_get_running_loop,  # noqa: N803
 ):
     mock_loop = mock_get_running_loop.return_value
     mock_coro = MagicMock()
@@ -368,3 +359,30 @@ def test_sentry_task_factory_context_with_factory(mock_get_running_loop):
 
     assert "context" in task_factory_kwargs
     assert task_factory_kwargs["context"] == mock_context
+
+
+@minimum_python_38
+@pytest.mark.asyncio(loop_scope="module")
+async def test_span_origin(
+    sentry_init,
+    capture_events,
+):
+    sentry_init(
+        integrations=[AsyncioIntegration()],
+        traces_sample_rate=1.0,
+    )
+
+    events = capture_events()
+
+    with sentry_sdk.start_transaction(name="something"):
+        tasks = [
+            asyncio.create_task(foo()),
+        ]
+        await asyncio.wait(tasks, return_when=asyncio.FIRST_EXCEPTION)
+
+    sentry_sdk.flush()
+
+    (event,) = events
+
+    assert event["contexts"]["trace"]["origin"] == "manual"
+    assert event["spans"][0]["origin"] == "auto.function.asyncio"
diff --git a/tests/integrations/asyncpg/__init__.py b/tests/integrations/asyncpg/__init__.py
index 50f607f3a6..d988407a2d 100644
--- a/tests/integrations/asyncpg/__init__.py
+++ b/tests/integrations/asyncpg/__init__.py
@@ -1,4 +1,10 @@
+import os
+import sys
 import pytest
 
 pytest.importorskip("asyncpg")
 pytest.importorskip("pytest_asyncio")
+
+# Load `asyncpg_helpers` into the module search path to test query source path names relative to module. See
+# `test_query_source_with_module_in_search_path`
+sys.path.insert(0, os.path.join(os.path.dirname(__file__)))
diff --git a/tests/integrations/asyncpg/asyncpg_helpers/__init__.py b/tests/integrations/asyncpg/asyncpg_helpers/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/integrations/asyncpg/asyncpg_helpers/helpers.py b/tests/integrations/asyncpg/asyncpg_helpers/helpers.py
new file mode 100644
index 0000000000..8de809ba1b
--- /dev/null
+++ b/tests/integrations/asyncpg/asyncpg_helpers/helpers.py
@@ -0,0 +1,2 @@
+async def execute_query_in_connection(query, connection):
+    await connection.execute(query)
diff --git a/tests/integrations/asyncpg/test_asyncpg.py b/tests/integrations/asyncpg/test_asyncpg.py
index 50d6a6c6e5..e23612c055 100644
--- a/tests/integrations/asyncpg/test_asyncpg.py
+++ b/tests/integrations/asyncpg/test_asyncpg.py
@@ -3,44 +3,56 @@
 
 Tests need a local postgresql instance running, this can best be done using
 ```sh
-docker run --rm --name some-postgres -e POSTGRES_USER=foo -e POSTGRES_PASSWORD=bar -d -p 5432:5432 postgres
+docker run --rm --name some-postgres -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=sentry -d -p 5432:5432 postgres
 ```
 
 The tests use the following credentials to establish a database connection.
 """
-import os
-
-
-PG_NAME = os.getenv("SENTRY_PYTHON_TEST_POSTGRES_NAME", "postgres")
-PG_USER = os.getenv("SENTRY_PYTHON_TEST_POSTGRES_USER", "foo")
-PG_PASSWORD = os.getenv("SENTRY_PYTHON_TEST_POSTGRES_PASSWORD", "bar")
-PG_HOST = os.getenv("SENTRY_PYTHON_TEST_POSTGRES_HOST", "localhost")
-PG_PORT = 5432
-
 
+import os
 import datetime
+from contextlib import contextmanager
+from unittest import mock
 
 import asyncpg
 import pytest
-
 import pytest_asyncio
-
 from asyncpg import connect, Connection
 
-from sentry_sdk import capture_message
+from sentry_sdk import capture_message, start_transaction
 from sentry_sdk.integrations.asyncpg import AsyncPGIntegration
+from sentry_sdk.consts import SPANDATA
+from sentry_sdk.tracing_utils import record_sql_queries
+from tests.conftest import ApproxDict
+
+PG_HOST = os.getenv("SENTRY_PYTHON_TEST_POSTGRES_HOST", "localhost")
+PG_PORT = int(os.getenv("SENTRY_PYTHON_TEST_POSTGRES_PORT", "5432"))
+PG_USER = os.getenv("SENTRY_PYTHON_TEST_POSTGRES_USER", "postgres")
+PG_PASSWORD = os.getenv("SENTRY_PYTHON_TEST_POSTGRES_PASSWORD", "sentry")
+PG_NAME_BASE = os.getenv("SENTRY_PYTHON_TEST_POSTGRES_NAME", "postgres")
 
 
-PG_CONNECTION_URI = f"postgresql://{PG_USER}:{PG_PASSWORD}@{PG_HOST}/{PG_NAME}"
+def _get_db_name():
+    pid = os.getpid()
+    return f"{PG_NAME_BASE}_{pid}"
+
+
+PG_NAME = _get_db_name()
+
+PG_CONNECTION_URI = "postgresql://{}:{}@{}/{}".format(
+    PG_USER, PG_PASSWORD, PG_HOST, PG_NAME
+)
 CRUMBS_CONNECT = {
     "category": "query",
-    "data": {
-        "db.name": PG_NAME,
-        "db.system": "postgresql",
-        "db.user": PG_USER,
-        "server.address": PG_HOST,
-        "server.port": PG_PORT,
-    },
+    "data": ApproxDict(
+        {
+            "db.name": PG_NAME,
+            "db.system": "postgresql",
+            "db.user": PG_USER,
+            "server.address": PG_HOST,
+            "server.port": PG_PORT,
+        }
+    ),
     "message": "connect",
     "type": "default",
 }
@@ -48,6 +60,21 @@
 
 @pytest_asyncio.fixture(autouse=True)
 async def _clean_pg():
+    # Create the test database if it doesn't exist
+    default_conn = await connect(
+        "postgresql://{}:{}@{}".format(PG_USER, PG_PASSWORD, PG_HOST)
+    )
+    try:
+        # Check if database exists, create if not
+        result = await default_conn.fetchval(
+            "SELECT 1 FROM pg_database WHERE datname = $1", PG_NAME
+        )
+        if not result:
+            await default_conn.execute(f'CREATE DATABASE "{PG_NAME}"')
+    finally:
+        await default_conn.close()
+
+    # Now connect to our test database and set up the table
     conn = await connect(PG_CONNECTION_URI)
     await conn.execute("DROP TABLE IF EXISTS users")
     await conn.execute(
@@ -458,3 +485,304 @@ async def test_connection_pool(sentry_init, capture_events) -> None:
             "type": "default",
         },
     ]
+
+
+@pytest.mark.asyncio
+async def test_query_source_disabled(sentry_init, capture_events):
+    sentry_options = {
+        "integrations": [AsyncPGIntegration()],
+        "enable_tracing": True,
+        "enable_db_query_source": False,
+        "db_query_source_threshold_ms": 0,
+    }
+
+    sentry_init(**sentry_options)
+
+    events = capture_events()
+
+    with start_transaction(name="test_transaction", sampled=True):
+        conn: Connection = await connect(PG_CONNECTION_URI)
+
+        await conn.execute(
+            "INSERT INTO users(name, password, dob) VALUES ('Alice', 'secret', '1990-12-25')",
+        )
+
+        await conn.close()
+
+    (event,) = events
+
+    span = event["spans"][-1]
+    assert span["description"].startswith("INSERT INTO")
+
+    data = span.get("data", {})
+
+    assert SPANDATA.CODE_LINENO not in data
+    assert SPANDATA.CODE_NAMESPACE not in data
+    assert SPANDATA.CODE_FILEPATH not in data
+    assert SPANDATA.CODE_FUNCTION not in data
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("enable_db_query_source", [None, True])
+async def test_query_source_enabled(
+    sentry_init, capture_events, enable_db_query_source
+):
+    sentry_options = {
+        "integrations": [AsyncPGIntegration()],
+        "enable_tracing": True,
+        "db_query_source_threshold_ms": 0,
+    }
+    if enable_db_query_source is not None:
+        sentry_options["enable_db_query_source"] = enable_db_query_source
+
+    sentry_init(**sentry_options)
+
+    events = capture_events()
+
+    with start_transaction(name="test_transaction", sampled=True):
+        conn: Connection = await connect(PG_CONNECTION_URI)
+
+        await conn.execute(
+            "INSERT INTO users(name, password, dob) VALUES ('Alice', 'secret', '1990-12-25')",
+        )
+
+        await conn.close()
+
+    (event,) = events
+
+    span = event["spans"][-1]
+    assert span["description"].startswith("INSERT INTO")
+
+    data = span.get("data", {})
+
+    assert SPANDATA.CODE_LINENO in data
+    assert SPANDATA.CODE_NAMESPACE in data
+    assert SPANDATA.CODE_FILEPATH in data
+    assert SPANDATA.CODE_FUNCTION in data
+
+
+@pytest.mark.asyncio
+async def test_query_source(sentry_init, capture_events):
+    sentry_init(
+        integrations=[AsyncPGIntegration()],
+        enable_tracing=True,
+        enable_db_query_source=True,
+        db_query_source_threshold_ms=0,
+    )
+
+    events = capture_events()
+
+    with start_transaction(name="test_transaction", sampled=True):
+        conn: Connection = await connect(PG_CONNECTION_URI)
+
+        await conn.execute(
+            "INSERT INTO users(name, password, dob) VALUES ('Alice', 'secret', '1990-12-25')",
+        )
+
+        await conn.close()
+
+    (event,) = events
+
+    span = event["spans"][-1]
+    assert span["description"].startswith("INSERT INTO")
+
+    data = span.get("data", {})
+
+    assert SPANDATA.CODE_LINENO in data
+    assert SPANDATA.CODE_NAMESPACE in data
+    assert SPANDATA.CODE_FILEPATH in data
+    assert SPANDATA.CODE_FUNCTION in data
+
+    assert type(data.get(SPANDATA.CODE_LINENO)) == int
+    assert data.get(SPANDATA.CODE_LINENO) > 0
+    assert (
+        data.get(SPANDATA.CODE_NAMESPACE) == "tests.integrations.asyncpg.test_asyncpg"
+    )
+    assert data.get(SPANDATA.CODE_FILEPATH).endswith(
+        "tests/integrations/asyncpg/test_asyncpg.py"
+    )
+
+    is_relative_path = data.get(SPANDATA.CODE_FILEPATH)[0] != os.sep
+    assert is_relative_path
+
+    assert data.get(SPANDATA.CODE_FUNCTION) == "test_query_source"
+
+
+@pytest.mark.asyncio
+async def test_query_source_with_module_in_search_path(sentry_init, capture_events):
+    """
+    Test that query source is relative to the path of the module it ran in
+    """
+    sentry_init(
+        integrations=[AsyncPGIntegration()],
+        enable_tracing=True,
+        enable_db_query_source=True,
+        db_query_source_threshold_ms=0,
+    )
+
+    events = capture_events()
+
+    from asyncpg_helpers.helpers import execute_query_in_connection
+
+    with start_transaction(name="test_transaction", sampled=True):
+        conn: Connection = await connect(PG_CONNECTION_URI)
+
+        await execute_query_in_connection(
+            "INSERT INTO users(name, password, dob) VALUES ('Alice', 'secret', '1990-12-25')",
+            conn,
+        )
+
+        await conn.close()
+
+    (event,) = events
+
+    span = event["spans"][-1]
+    assert span["description"].startswith("INSERT INTO")
+
+    data = span.get("data", {})
+
+    assert SPANDATA.CODE_LINENO in data
+    assert SPANDATA.CODE_NAMESPACE in data
+    assert SPANDATA.CODE_FILEPATH in data
+    assert SPANDATA.CODE_FUNCTION in data
+
+    assert type(data.get(SPANDATA.CODE_LINENO)) == int
+    assert data.get(SPANDATA.CODE_LINENO) > 0
+    assert data.get(SPANDATA.CODE_NAMESPACE) == "asyncpg_helpers.helpers"
+    assert data.get(SPANDATA.CODE_FILEPATH) == "asyncpg_helpers/helpers.py"
+
+    is_relative_path = data.get(SPANDATA.CODE_FILEPATH)[0] != os.sep
+    assert is_relative_path
+
+    assert data.get(SPANDATA.CODE_FUNCTION) == "execute_query_in_connection"
+
+
+@pytest.mark.asyncio
+async def test_no_query_source_if_duration_too_short(sentry_init, capture_events):
+    sentry_init(
+        integrations=[AsyncPGIntegration()],
+        enable_tracing=True,
+        enable_db_query_source=True,
+        db_query_source_threshold_ms=100,
+    )
+
+    events = capture_events()
+
+    with start_transaction(name="test_transaction", sampled=True):
+        conn: Connection = await connect(PG_CONNECTION_URI)
+
+        @contextmanager
+        def fake_record_sql_queries(*args, **kwargs):
+            with record_sql_queries(*args, **kwargs) as span:
+                pass
+            span.start_timestamp = datetime.datetime(2024, 1, 1, microsecond=0)
+            span.timestamp = datetime.datetime(2024, 1, 1, microsecond=99999)
+            yield span
+
+        with mock.patch(
+            "sentry_sdk.integrations.asyncpg.record_sql_queries",
+            fake_record_sql_queries,
+        ):
+            await conn.execute(
+                "INSERT INTO users(name, password, dob) VALUES ('Alice', 'secret', '1990-12-25')",
+            )
+
+        await conn.close()
+
+    (event,) = events
+
+    span = event["spans"][-1]
+    assert span["description"].startswith("INSERT INTO")
+
+    data = span.get("data", {})
+
+    assert SPANDATA.CODE_LINENO not in data
+    assert SPANDATA.CODE_NAMESPACE not in data
+    assert SPANDATA.CODE_FILEPATH not in data
+    assert SPANDATA.CODE_FUNCTION not in data
+
+
+@pytest.mark.asyncio
+async def test_query_source_if_duration_over_threshold(sentry_init, capture_events):
+    sentry_init(
+        integrations=[AsyncPGIntegration()],
+        enable_tracing=True,
+        enable_db_query_source=True,
+        db_query_source_threshold_ms=100,
+    )
+
+    events = capture_events()
+
+    with start_transaction(name="test_transaction", sampled=True):
+        conn: Connection = await connect(PG_CONNECTION_URI)
+
+        @contextmanager
+        def fake_record_sql_queries(*args, **kwargs):
+            with record_sql_queries(*args, **kwargs) as span:
+                pass
+            span.start_timestamp = datetime.datetime(2024, 1, 1, microsecond=0)
+            span.timestamp = datetime.datetime(2024, 1, 1, microsecond=100001)
+            yield span
+
+        with mock.patch(
+            "sentry_sdk.integrations.asyncpg.record_sql_queries",
+            fake_record_sql_queries,
+        ):
+            await conn.execute(
+                "INSERT INTO users(name, password, dob) VALUES ('Alice', 'secret', '1990-12-25')",
+            )
+
+        await conn.close()
+
+    (event,) = events
+
+    span = event["spans"][-1]
+    assert span["description"].startswith("INSERT INTO")
+
+    data = span.get("data", {})
+
+    assert SPANDATA.CODE_LINENO in data
+    assert SPANDATA.CODE_NAMESPACE in data
+    assert SPANDATA.CODE_FILEPATH in data
+    assert SPANDATA.CODE_FUNCTION in data
+
+    assert type(data.get(SPANDATA.CODE_LINENO)) == int
+    assert data.get(SPANDATA.CODE_LINENO) > 0
+    assert (
+        data.get(SPANDATA.CODE_NAMESPACE) == "tests.integrations.asyncpg.test_asyncpg"
+    )
+    assert data.get(SPANDATA.CODE_FILEPATH).endswith(
+        "tests/integrations/asyncpg/test_asyncpg.py"
+    )
+
+    is_relative_path = data.get(SPANDATA.CODE_FILEPATH)[0] != os.sep
+    assert is_relative_path
+
+    assert (
+        data.get(SPANDATA.CODE_FUNCTION)
+        == "test_query_source_if_duration_over_threshold"
+    )
+
+
+@pytest.mark.asyncio
+async def test_span_origin(sentry_init, capture_events):
+    sentry_init(
+        integrations=[AsyncPGIntegration()],
+        traces_sample_rate=1.0,
+    )
+
+    events = capture_events()
+
+    with start_transaction(name="test_transaction"):
+        conn: Connection = await connect(PG_CONNECTION_URI)
+
+        await conn.execute("SELECT 1")
+        await conn.fetchrow("SELECT 2")
+        await conn.close()
+
+    (event,) = events
+
+    assert event["contexts"]["trace"]["origin"] == "manual"
+
+    for span in event["spans"]:
+        assert span["origin"] == "auto.db.asyncpg"
diff --git a/tests/integrations/aws_lambda/__init__.py b/tests/integrations/aws_lambda/__init__.py
index 71eb245353..449f4dc95d 100644
--- a/tests/integrations/aws_lambda/__init__.py
+++ b/tests/integrations/aws_lambda/__init__.py
@@ -1,3 +1,5 @@
 import pytest
 
 pytest.importorskip("boto3")
+pytest.importorskip("fastapi")
+pytest.importorskip("uvicorn")
diff --git a/tests/integrations/aws_lambda/client.py b/tests/integrations/aws_lambda/client.py
deleted file mode 100644
index d8e430f3d7..0000000000
--- a/tests/integrations/aws_lambda/client.py
+++ /dev/null
@@ -1,239 +0,0 @@
-import sys
-import os
-import shutil
-import tempfile
-import subprocess
-import boto3
-import uuid
-import base64
-
-
-def get_boto_client():
-    return boto3.client(
-        "lambda",
-        aws_access_key_id=os.environ["SENTRY_PYTHON_TEST_AWS_ACCESS_KEY_ID"],
-        aws_secret_access_key=os.environ["SENTRY_PYTHON_TEST_AWS_SECRET_ACCESS_KEY"],
-        region_name="us-east-1",
-    )
-
-
-def build_no_code_serverless_function_and_layer(
-    client, tmpdir, fn_name, runtime, timeout, initial_handler
-):
-    """
-    Util function that auto instruments the no code implementation of the python
-    sdk by creating a layer containing the Python-sdk, and then creating a func
-    that uses that layer
-    """
-    from scripts.build_aws_lambda_layer import build_layer_dir
-
-    build_layer_dir(dest_abs_path=tmpdir)
-
-    with open(os.path.join(tmpdir, "serverless-ball.zip"), "rb") as serverless_zip:
-        response = client.publish_layer_version(
-            LayerName="python-serverless-sdk-test",
-            Description="Created as part of testsuite for getsentry/sentry-python",
-            Content={"ZipFile": serverless_zip.read()},
-        )
-
-    with open(os.path.join(tmpdir, "ball.zip"), "rb") as zip:
-        client.create_function(
-            FunctionName=fn_name,
-            Runtime=runtime,
-            Timeout=timeout,
-            Environment={
-                "Variables": {
-                    "SENTRY_INITIAL_HANDLER": initial_handler,
-                    "SENTRY_DSN": "https://123abc@example.com/123",
-                    "SENTRY_TRACES_SAMPLE_RATE": "1.0",
-                }
-            },
-            Role=os.environ["SENTRY_PYTHON_TEST_AWS_IAM_ROLE"],
-            Handler="sentry_sdk.integrations.init_serverless_sdk.sentry_lambda_handler",
-            Layers=[response["LayerVersionArn"]],
-            Code={"ZipFile": zip.read()},
-            Description="Created as part of testsuite for getsentry/sentry-python",
-        )
-
-
-def run_lambda_function(
-    client,
-    runtime,
-    code,
-    payload,
-    add_finalizer,
-    syntax_check=True,
-    timeout=30,
-    layer=None,
-    initial_handler=None,
-    subprocess_kwargs=(),
-):
-    subprocess_kwargs = dict(subprocess_kwargs)
-
-    with tempfile.TemporaryDirectory() as tmpdir:
-        if initial_handler:
-            # If Initial handler value is provided i.e. it is not the default
-            # `test_lambda.test_handler`, then create another dir level so that our path is
-            # test_dir.test_lambda.test_handler
-            test_dir_path = os.path.join(tmpdir, "test_dir")
-            python_init_file = os.path.join(test_dir_path, "__init__.py")
-            os.makedirs(test_dir_path)
-            with open(python_init_file, "w"):
-                # Create __init__ file to make it a python package
-                pass
-
-            test_lambda_py = os.path.join(tmpdir, "test_dir", "test_lambda.py")
-        else:
-            test_lambda_py = os.path.join(tmpdir, "test_lambda.py")
-
-        with open(test_lambda_py, "w") as f:
-            f.write(code)
-
-        if syntax_check:
-            # Check file for valid syntax first, and that the integration does not
-            # crash when not running in Lambda (but rather a local deployment tool
-            # such as chalice's)
-            subprocess.check_call([sys.executable, test_lambda_py])
-
-        fn_name = "test_function_{}".format(uuid.uuid4())
-
-        if layer is None:
-            setup_cfg = os.path.join(tmpdir, "setup.cfg")
-            with open(setup_cfg, "w") as f:
-                f.write("[install]\nprefix=")
-
-            subprocess.check_call(
-                [sys.executable, "setup.py", "sdist", "-d", os.path.join(tmpdir, "..")],
-                **subprocess_kwargs
-            )
-
-            subprocess.check_call(
-                "pip install mock==3.0.0 funcsigs -t .",
-                cwd=tmpdir,
-                shell=True,
-                **subprocess_kwargs
-            )
-
-            # https://docs.aws.amazon.com/lambda/latest/dg/lambda-python-how-to-create-deployment-package.html
-            subprocess.check_call(
-                "pip install ../*.tar.gz -t .",
-                cwd=tmpdir,
-                shell=True,
-                **subprocess_kwargs
-            )
-
-            shutil.make_archive(os.path.join(tmpdir, "ball"), "zip", tmpdir)
-
-            with open(os.path.join(tmpdir, "ball.zip"), "rb") as zip:
-                client.create_function(
-                    FunctionName=fn_name,
-                    Runtime=runtime,
-                    Timeout=timeout,
-                    Role=os.environ["SENTRY_PYTHON_TEST_AWS_IAM_ROLE"],
-                    Handler="test_lambda.test_handler",
-                    Code={"ZipFile": zip.read()},
-                    Description="Created as part of testsuite for getsentry/sentry-python",
-                )
-        else:
-            subprocess.run(
-                ["zip", "-q", "-x", "**/__pycache__/*", "-r", "ball.zip", "./"],
-                cwd=tmpdir,
-                check=True,
-            )
-
-            # Default initial handler
-            if not initial_handler:
-                initial_handler = "test_lambda.test_handler"
-
-            build_no_code_serverless_function_and_layer(
-                client, tmpdir, fn_name, runtime, timeout, initial_handler
-            )
-
-        @add_finalizer
-        def clean_up():
-            client.delete_function(FunctionName=fn_name)
-
-            # this closes the web socket so we don't get a
-            #   ResourceWarning: unclosed 
-            # warning on every test
-            # based on https://github.com/boto/botocore/pull/1810
-            # (if that's ever merged, this can just become client.close())
-            session = client._endpoint.http_session
-            managers = [session._manager] + list(session._proxy_managers.values())
-            for manager in managers:
-                manager.clear()
-
-        response = client.invoke(
-            FunctionName=fn_name,
-            InvocationType="RequestResponse",
-            LogType="Tail",
-            Payload=payload,
-        )
-
-        assert 200 <= response["StatusCode"] < 300, response
-        return response
-
-
-_REPL_CODE = """
-import os
-
-def test_handler(event, context):
-    line = {line!r}
-    if line.startswith(">>> "):
-        exec(line[4:])
-    elif line.startswith("$ "):
-        os.system(line[2:])
-    else:
-        print("Start a line with $ or >>>")
-
-    return b""
-"""
-
-try:
-    import click
-except ImportError:
-    pass
-else:
-
-    @click.command()
-    @click.option(
-        "--runtime", required=True, help="name of the runtime to use, eg python3.8"
-    )
-    @click.option("--verbose", is_flag=True, default=False)
-    def repl(runtime, verbose):
-        """
-        Launch a "REPL" against AWS Lambda to inspect their runtime.
-        """
-
-        cleanup = []
-        client = get_boto_client()
-
-        print("Start a line with `$ ` to run shell commands, or `>>> ` to run Python")
-
-        while True:
-            line = input()
-
-            response = run_lambda_function(
-                client,
-                runtime,
-                _REPL_CODE.format(line=line),
-                b"",
-                cleanup.append,
-                subprocess_kwargs={
-                    "stdout": subprocess.DEVNULL,
-                    "stderr": subprocess.DEVNULL,
-                }
-                if not verbose
-                else {},
-            )
-
-            for line in base64.b64decode(response["LogResult"]).splitlines():
-                print(line.decode("utf8"))
-
-            for f in cleanup:
-                f()
-
-            cleanup = []
-
-    if __name__ == "__main__":
-        repl()
diff --git a/tests/integrations/aws_lambda/lambda_functions/BasicException/index.py b/tests/integrations/aws_lambda/lambda_functions/BasicException/index.py
new file mode 100644
index 0000000000..875b984e2a
--- /dev/null
+++ b/tests/integrations/aws_lambda/lambda_functions/BasicException/index.py
@@ -0,0 +1,6 @@
+def handler(event, context):
+    raise RuntimeError("Oh!")
+
+    return {
+        "event": event,
+    }
diff --git a/tests/integrations/aws_lambda/lambda_functions/BasicOk/index.py b/tests/integrations/aws_lambda/lambda_functions/BasicOk/index.py
new file mode 100644
index 0000000000..257fea04f0
--- /dev/null
+++ b/tests/integrations/aws_lambda/lambda_functions/BasicOk/index.py
@@ -0,0 +1,4 @@
+def handler(event, context):
+    return {
+        "event": event,
+    }
diff --git a/tests/integrations/aws_lambda/lambda_functions/InitError/index.py b/tests/integrations/aws_lambda/lambda_functions/InitError/index.py
new file mode 100644
index 0000000000..20b4fcc111
--- /dev/null
+++ b/tests/integrations/aws_lambda/lambda_functions/InitError/index.py
@@ -0,0 +1,3 @@
+# We have no handler() here and try to call a non-existing function.
+
+func()  # noqa: F821
diff --git a/tests/integrations/aws_lambda/lambda_functions/TimeoutError/index.py b/tests/integrations/aws_lambda/lambda_functions/TimeoutError/index.py
new file mode 100644
index 0000000000..01334bbfbc
--- /dev/null
+++ b/tests/integrations/aws_lambda/lambda_functions/TimeoutError/index.py
@@ -0,0 +1,8 @@
+import time
+
+
+def handler(event, context):
+    time.sleep(15)
+    return {
+        "event": event,
+    }
diff --git a/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/RaiseErrorPerformanceDisabled/.gitignore b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/RaiseErrorPerformanceDisabled/.gitignore
new file mode 100644
index 0000000000..1c56884372
--- /dev/null
+++ b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/RaiseErrorPerformanceDisabled/.gitignore
@@ -0,0 +1,11 @@
+# Need to add some ignore rules in this directory, because the unit tests will add the Sentry SDK and its dependencies
+# into this directory to create a Lambda function package that contains everything needed to instrument a Lambda function using Sentry.
+
+# Ignore everything
+*
+
+# But not index.py
+!index.py
+
+# And not .gitignore itself
+!.gitignore
diff --git a/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/RaiseErrorPerformanceDisabled/index.py b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/RaiseErrorPerformanceDisabled/index.py
new file mode 100644
index 0000000000..12f43f0009
--- /dev/null
+++ b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/RaiseErrorPerformanceDisabled/index.py
@@ -0,0 +1,14 @@
+import os
+import sentry_sdk
+from sentry_sdk.integrations.aws_lambda import AwsLambdaIntegration
+
+
+sentry_sdk.init(
+    dsn=os.environ.get("SENTRY_DSN"),
+    traces_sample_rate=None,  # this is the default, just added for clarity
+    integrations=[AwsLambdaIntegration()],
+)
+
+
+def handler(event, context):
+    raise Exception("Oh!")
diff --git a/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/RaiseErrorPerformanceEnabled/.gitignore b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/RaiseErrorPerformanceEnabled/.gitignore
new file mode 100644
index 0000000000..1c56884372
--- /dev/null
+++ b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/RaiseErrorPerformanceEnabled/.gitignore
@@ -0,0 +1,11 @@
+# Need to add some ignore rules in this directory, because the unit tests will add the Sentry SDK and its dependencies
+# into this directory to create a Lambda function package that contains everything needed to instrument a Lambda function using Sentry.
+
+# Ignore everything
+*
+
+# But not index.py
+!index.py
+
+# And not .gitignore itself
+!.gitignore
diff --git a/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/RaiseErrorPerformanceEnabled/index.py b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/RaiseErrorPerformanceEnabled/index.py
new file mode 100644
index 0000000000..c694299682
--- /dev/null
+++ b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/RaiseErrorPerformanceEnabled/index.py
@@ -0,0 +1,14 @@
+import os
+import sentry_sdk
+from sentry_sdk.integrations.aws_lambda import AwsLambdaIntegration
+
+
+sentry_sdk.init(
+    dsn=os.environ.get("SENTRY_DSN"),
+    traces_sample_rate=1.0,
+    integrations=[AwsLambdaIntegration()],
+)
+
+
+def handler(event, context):
+    raise Exception("Oh!")
diff --git a/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/TimeoutErrorScopeModified/.gitignore b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/TimeoutErrorScopeModified/.gitignore
new file mode 100644
index 0000000000..1c56884372
--- /dev/null
+++ b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/TimeoutErrorScopeModified/.gitignore
@@ -0,0 +1,11 @@
+# Need to add some ignore rules in this directory, because the unit tests will add the Sentry SDK and its dependencies
+# into this directory to create a Lambda function package that contains everything needed to instrument a Lambda function using Sentry.
+
+# Ignore everything
+*
+
+# But not index.py
+!index.py
+
+# And not .gitignore itself
+!.gitignore
diff --git a/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/TimeoutErrorScopeModified/index.py b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/TimeoutErrorScopeModified/index.py
new file mode 100644
index 0000000000..109245b90d
--- /dev/null
+++ b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/TimeoutErrorScopeModified/index.py
@@ -0,0 +1,19 @@
+import os
+import time
+
+import sentry_sdk
+from sentry_sdk.integrations.aws_lambda import AwsLambdaIntegration
+
+sentry_sdk.init(
+    dsn=os.environ.get("SENTRY_DSN"),
+    traces_sample_rate=1.0,
+    integrations=[AwsLambdaIntegration(timeout_warning=True)],
+)
+
+
+def handler(event, context):
+    sentry_sdk.set_tag("custom_tag", "custom_value")
+    time.sleep(15)
+    return {
+        "event": event,
+    }
diff --git a/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/TracesSampler/.gitignore b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/TracesSampler/.gitignore
new file mode 100644
index 0000000000..1c56884372
--- /dev/null
+++ b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/TracesSampler/.gitignore
@@ -0,0 +1,11 @@
+# Need to add some ignore rules in this directory, because the unit tests will add the Sentry SDK and its dependencies
+# into this directory to create a Lambda function package that contains everything needed to instrument a Lambda function using Sentry.
+
+# Ignore everything
+*
+
+# But not index.py
+!index.py
+
+# And not .gitignore itself
+!.gitignore
diff --git a/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/TracesSampler/index.py b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/TracesSampler/index.py
new file mode 100644
index 0000000000..ce797faf71
--- /dev/null
+++ b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/TracesSampler/index.py
@@ -0,0 +1,49 @@
+import json
+import os
+import sentry_sdk
+from sentry_sdk.integrations.aws_lambda import AwsLambdaIntegration
+
+# Global variables to store sampling context for verification
+sampling_context_data = {
+    "aws_event_present": False,
+    "aws_context_present": False,
+    "event_data": None,
+}
+
+
+def trace_sampler(sampling_context):
+    # Store the sampling context for verification
+    global sampling_context_data
+
+    # Check if aws_event and aws_context are in the sampling_context
+    if "aws_event" in sampling_context:
+        sampling_context_data["aws_event_present"] = True
+        sampling_context_data["event_data"] = sampling_context["aws_event"]
+
+    if "aws_context" in sampling_context:
+        sampling_context_data["aws_context_present"] = True
+
+    print("Sampling context data:", sampling_context_data)
+    return 1.0  # Always sample
+
+
+sentry_sdk.init(
+    dsn=os.environ.get("SENTRY_DSN"),
+    traces_sample_rate=1.0,
+    traces_sampler=trace_sampler,
+    integrations=[AwsLambdaIntegration()],
+)
+
+
+def handler(event, context):
+    # Return the sampling context data for verification
+    return {
+        "statusCode": 200,
+        "body": json.dumps(
+            {
+                "message": "Hello from Lambda with embedded Sentry SDK!",
+                "event": event,
+                "sampling_context_data": sampling_context_data,
+            }
+        ),
+    }
diff --git a/tests/integrations/aws_lambda/test_aws.py b/tests/integrations/aws_lambda/test_aws.py
deleted file mode 100644
index 5825e5fca9..0000000000
--- a/tests/integrations/aws_lambda/test_aws.py
+++ /dev/null
@@ -1,802 +0,0 @@
-"""
-# AWS Lambda system tests
-
-This testsuite uses boto3 to upload actual lambda functions to AWS, execute
-them and assert some things about the externally observed behavior. What that
-means for you is that those tests won't run without AWS access keys:
-
-    export SENTRY_PYTHON_TEST_AWS_ACCESS_KEY_ID=..
-    export SENTRY_PYTHON_TEST_AWS_SECRET_ACCESS_KEY=...
-    export SENTRY_PYTHON_TEST_AWS_IAM_ROLE="arn:aws:iam::920901907255:role/service-role/lambda"
-
-If you need to debug a new runtime, use this REPL to figure things out:
-
-    pip3 install click
-    python3 tests/integrations/aws_lambda/client.py --runtime=python4.0
-"""
-import base64
-import json
-import os
-import re
-from textwrap import dedent
-
-import pytest
-
-
-LAMBDA_PRELUDE = """
-from sentry_sdk.integrations.aws_lambda import AwsLambdaIntegration, get_lambda_bootstrap
-import sentry_sdk
-import json
-import time
-
-from sentry_sdk.transport import HttpTransport
-
-def event_processor(event):
-    # AWS Lambda truncates the log output to 4kb, which is small enough to miss
-    # parts of even a single error-event/transaction-envelope pair if considered
-    # in full, so only grab the data we need.
-
-    event_data = {}
-    event_data["contexts"] = {}
-    event_data["contexts"]["trace"] = event.get("contexts", {}).get("trace")
-    event_data["exception"] = event.get("exception")
-    event_data["extra"] = event.get("extra")
-    event_data["level"] = event.get("level")
-    event_data["request"] = event.get("request")
-    event_data["tags"] = event.get("tags")
-    event_data["transaction"] = event.get("transaction")
-
-    return event_data
-
-def envelope_processor(envelope):
-    # AWS Lambda truncates the log output to 4kb, which is small enough to miss
-    # parts of even a single error-event/transaction-envelope pair if considered
-    # in full, so only grab the data we need.
-
-    (item,) = envelope.items
-    envelope_json = json.loads(item.get_bytes())
-
-    envelope_data = {}
-    envelope_data["contexts"] = {}
-    envelope_data["type"] = envelope_json["type"]
-    envelope_data["transaction"] = envelope_json["transaction"]
-    envelope_data["contexts"]["trace"] = envelope_json["contexts"]["trace"]
-    envelope_data["request"] = envelope_json["request"]
-    envelope_data["tags"] = envelope_json["tags"]
-
-    return envelope_data
-
-
-class TestTransport(HttpTransport):
-    def _send_event(self, event):
-        event = event_processor(event)
-        # Writing a single string to stdout holds the GIL (seems like) and
-        # therefore cannot be interleaved with other threads. This is why we
-        # explicitly add a newline at the end even though `print` would provide
-        # us one.
-        print("\\nEVENT: {}\\n".format(json.dumps(event)))
-
-    def _send_envelope(self, envelope):
-        envelope = envelope_processor(envelope)
-        print("\\nENVELOPE: {}\\n".format(json.dumps(envelope)))
-
-
-def init_sdk(timeout_warning=False, **extra_init_args):
-    sentry_sdk.init(
-        dsn="https://123abc@example.com/123",
-        transport=TestTransport,
-        integrations=[AwsLambdaIntegration(timeout_warning=timeout_warning)],
-        shutdown_timeout=10,
-        **extra_init_args
-    )
-"""
-
-
-@pytest.fixture
-def lambda_client():
-    if "SENTRY_PYTHON_TEST_AWS_ACCESS_KEY_ID" not in os.environ:
-        pytest.skip("AWS environ vars not set")
-
-    from tests.integrations.aws_lambda.client import get_boto_client
-
-    return get_boto_client()
-
-
-@pytest.fixture(
-    params=[
-        "python3.7",
-        "python3.8",
-        "python3.9",
-    ]
-)
-def lambda_runtime(request):
-    return request.param
-
-
-@pytest.fixture
-def run_lambda_function(request, lambda_client, lambda_runtime):
-    def inner(
-        code, payload, timeout=30, syntax_check=True, layer=None, initial_handler=None
-    ):
-        from tests.integrations.aws_lambda.client import run_lambda_function
-
-        response = run_lambda_function(
-            client=lambda_client,
-            runtime=lambda_runtime,
-            code=code,
-            payload=payload,
-            add_finalizer=request.addfinalizer,
-            timeout=timeout,
-            syntax_check=syntax_check,
-            layer=layer,
-            initial_handler=initial_handler,
-        )
-
-        # for better debugging
-        response["LogResult"] = base64.b64decode(response["LogResult"]).splitlines()
-        response["Payload"] = json.loads(response["Payload"].read().decode("utf-8"))
-        del response["ResponseMetadata"]
-
-        events = []
-        envelopes = []
-
-        for line in response["LogResult"]:
-            print("AWS:", line)
-            if line.startswith(b"EVENT: "):
-                line = line[len(b"EVENT: ") :]
-                events.append(json.loads(line.decode("utf-8")))
-            elif line.startswith(b"ENVELOPE: "):
-                line = line[len(b"ENVELOPE: ") :]
-                envelopes.append(json.loads(line.decode("utf-8")))
-            else:
-                continue
-
-        return envelopes, events, response
-
-    return inner
-
-
-def test_basic(run_lambda_function):
-    envelopes, events, response = run_lambda_function(
-        LAMBDA_PRELUDE
-        + dedent(
-            """
-        init_sdk()
-
-        def event_processor(event):
-            # Delay event output like this to test proper shutdown
-            time.sleep(1)
-            return event
-
-        def test_handler(event, context):
-            raise Exception("something went wrong")
-        """
-        ),
-        b'{"foo": "bar"}',
-    )
-
-    assert response["FunctionError"] == "Unhandled"
-
-    (event,) = events
-    assert event["level"] == "error"
-    (exception,) = event["exception"]["values"]
-    assert exception["type"] == "Exception"
-    assert exception["value"] == "something went wrong"
-
-    (frame1,) = exception["stacktrace"]["frames"]
-    assert frame1["filename"] == "test_lambda.py"
-    assert frame1["abs_path"] == "/var/task/test_lambda.py"
-    assert frame1["function"] == "test_handler"
-
-    assert frame1["in_app"] is True
-
-    assert exception["mechanism"]["type"] == "aws_lambda"
-    assert not exception["mechanism"]["handled"]
-
-    assert event["extra"]["lambda"]["function_name"].startswith("test_function_")
-
-    logs_url = event["extra"]["cloudwatch logs"]["url"]
-    assert logs_url.startswith("https://console.aws.amazon.com/cloudwatch/home?region=")
-    assert not re.search("(=;|=$)", logs_url)
-    assert event["extra"]["cloudwatch logs"]["log_group"].startswith(
-        "/aws/lambda/test_function_"
-    )
-
-    log_stream_re = "^[0-9]{4}/[0-9]{2}/[0-9]{2}/\\[[^\\]]+][a-f0-9]+$"
-    log_stream = event["extra"]["cloudwatch logs"]["log_stream"]
-
-    assert re.match(log_stream_re, log_stream)
-
-
-def test_initialization_order(run_lambda_function):
-    """Zappa lazily imports our code, so by the time we monkeypatch the handler
-    as seen by AWS already runs. At this point at least draining the queue
-    should work."""
-
-    envelopes, events, _response = run_lambda_function(
-        LAMBDA_PRELUDE
-        + dedent(
-            """
-            def test_handler(event, context):
-                init_sdk()
-                sentry_sdk.capture_exception(Exception("something went wrong"))
-        """
-        ),
-        b'{"foo": "bar"}',
-    )
-
-    (event,) = events
-    assert event["level"] == "error"
-    (exception,) = event["exception"]["values"]
-    assert exception["type"] == "Exception"
-    assert exception["value"] == "something went wrong"
-
-
-def test_request_data(run_lambda_function):
-    envelopes, events, _response = run_lambda_function(
-        LAMBDA_PRELUDE
-        + dedent(
-            """
-        init_sdk()
-        def test_handler(event, context):
-            sentry_sdk.capture_message("hi")
-            return "ok"
-        """
-        ),
-        payload=b"""
-        {
-          "resource": "/asd",
-          "path": "/asd",
-          "httpMethod": "GET",
-          "headers": {
-            "Host": "iwsz2c7uwi.execute-api.us-east-1.amazonaws.com",
-            "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.13; rv:62.0) Gecko/20100101 Firefox/62.0",
-            "X-Forwarded-Proto": "https"
-          },
-          "queryStringParameters": {
-            "bonkers": "true"
-          },
-          "pathParameters": null,
-          "stageVariables": null,
-          "requestContext": {
-            "identity": {
-              "sourceIp": "213.47.147.207",
-              "userArn": "42"
-            }
-          },
-          "body": null,
-          "isBase64Encoded": false
-        }
-        """,
-    )
-
-    (event,) = events
-
-    assert event["request"] == {
-        "headers": {
-            "Host": "iwsz2c7uwi.execute-api.us-east-1.amazonaws.com",
-            "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.13; rv:62.0) Gecko/20100101 Firefox/62.0",
-            "X-Forwarded-Proto": "https",
-        },
-        "method": "GET",
-        "query_string": {"bonkers": "true"},
-        "url": "https://iwsz2c7uwi.execute-api.us-east-1.amazonaws.com/asd",
-    }
-
-
-def test_init_error(run_lambda_function, lambda_runtime):
-    envelopes, events, response = run_lambda_function(
-        LAMBDA_PRELUDE
-        + (
-            "def event_processor(event):\n"
-            '    return event["exception"]["values"][0]["value"]\n'
-            "init_sdk()\n"
-            "func()"
-        ),
-        b'{"foo": "bar"}',
-        syntax_check=False,
-    )
-
-    (event,) = events
-    assert "name 'func' is not defined" in event
-
-
-def test_timeout_error(run_lambda_function):
-    envelopes, events, response = run_lambda_function(
-        LAMBDA_PRELUDE
-        + dedent(
-            """
-        init_sdk(timeout_warning=True)
-
-        def test_handler(event, context):
-            time.sleep(10)
-            return 0
-        """
-        ),
-        b'{"foo": "bar"}',
-        timeout=3,
-    )
-
-    (event,) = events
-    assert event["level"] == "error"
-    (exception,) = event["exception"]["values"]
-    assert exception["type"] == "ServerlessTimeoutWarning"
-    assert exception["value"] in (
-        "WARNING : Function is expected to get timed out. Configured timeout duration = 4 seconds.",
-        "WARNING : Function is expected to get timed out. Configured timeout duration = 3 seconds.",
-    )
-
-    assert exception["mechanism"]["type"] == "threading"
-    assert not exception["mechanism"]["handled"]
-
-    assert event["extra"]["lambda"]["function_name"].startswith("test_function_")
-
-    logs_url = event["extra"]["cloudwatch logs"]["url"]
-    assert logs_url.startswith("https://console.aws.amazon.com/cloudwatch/home?region=")
-    assert not re.search("(=;|=$)", logs_url)
-    assert event["extra"]["cloudwatch logs"]["log_group"].startswith(
-        "/aws/lambda/test_function_"
-    )
-
-    log_stream_re = "^[0-9]{4}/[0-9]{2}/[0-9]{2}/\\[[^\\]]+][a-f0-9]+$"
-    log_stream = event["extra"]["cloudwatch logs"]["log_stream"]
-
-    assert re.match(log_stream_re, log_stream)
-
-
-def test_performance_no_error(run_lambda_function):
-    envelopes, events, response = run_lambda_function(
-        LAMBDA_PRELUDE
-        + dedent(
-            """
-        init_sdk(traces_sample_rate=1.0)
-
-        def test_handler(event, context):
-            return "test_string"
-        """
-        ),
-        b'{"foo": "bar"}',
-    )
-
-    (envelope,) = envelopes
-    assert envelope["type"] == "transaction"
-    assert envelope["contexts"]["trace"]["op"] == "function.aws.lambda"
-    assert envelope["transaction"].startswith("test_function_")
-    assert envelope["transaction_info"] == {"source": "component"}
-    assert envelope["transaction"] in envelope["request"]["url"]
-
-
-def test_performance_error(run_lambda_function):
-    envelopes, events, response = run_lambda_function(
-        LAMBDA_PRELUDE
-        + dedent(
-            """
-        init_sdk(traces_sample_rate=1.0)
-
-        def test_handler(event, context):
-            raise Exception("something went wrong")
-        """
-        ),
-        b'{"foo": "bar"}',
-    )
-
-    (event,) = events
-    assert event["level"] == "error"
-    (exception,) = event["exception"]["values"]
-    assert exception["type"] == "Exception"
-    assert exception["value"] == "something went wrong"
-
-    (envelope,) = envelopes
-
-    assert envelope["type"] == "transaction"
-    assert envelope["contexts"]["trace"]["op"] == "function.aws.lambda"
-    assert envelope["transaction"].startswith("test_function_")
-    assert envelope["transaction_info"] == {"source": "component"}
-    assert envelope["transaction"] in envelope["request"]["url"]
-
-
-@pytest.mark.parametrize(
-    "aws_event, has_request_data, batch_size",
-    [
-        (b"1231", False, 1),
-        (b"11.21", False, 1),
-        (b'"Good dog!"', False, 1),
-        (b"true", False, 1),
-        (
-            b"""
-            [
-                {"good dog": "Maisey"},
-                {"good dog": "Charlie"},
-                {"good dog": "Cory"},
-                {"good dog": "Bodhi"}
-            ]
-            """,
-            False,
-            4,
-        ),
-        (
-            b"""
-            [
-                {
-                    "headers": {
-                        "Host": "dogs.are.great",
-                        "X-Forwarded-Proto": "http"
-                    },
-                    "httpMethod": "GET",
-                    "path": "/tricks/kangaroo",
-                    "queryStringParameters": {
-                        "completed_successfully": "true",
-                        "treat_provided": "true",
-                        "treat_type": "cheese"
-                    },
-                    "dog": "Maisey"
-                },
-                {
-                    "headers": {
-                        "Host": "dogs.are.great",
-                        "X-Forwarded-Proto": "http"
-                    },
-                    "httpMethod": "GET",
-                    "path": "/tricks/kangaroo",
-                    "queryStringParameters": {
-                        "completed_successfully": "true",
-                        "treat_provided": "true",
-                        "treat_type": "cheese"
-                    },
-                    "dog": "Charlie"
-                }
-            ]
-            """,
-            True,
-            2,
-        ),
-    ],
-)
-def test_non_dict_event(
-    run_lambda_function,
-    aws_event,
-    has_request_data,
-    batch_size,
-    DictionaryContaining,  # noqa:N803
-):
-    envelopes, events, response = run_lambda_function(
-        LAMBDA_PRELUDE
-        + dedent(
-            """
-        init_sdk(traces_sample_rate=1.0)
-
-        def test_handler(event, context):
-            raise Exception("More treats, please!")
-        """
-        ),
-        aws_event,
-    )
-
-    assert response["FunctionError"] == "Unhandled"
-
-    error_event = events[0]
-    assert error_event["level"] == "error"
-    assert error_event["contexts"]["trace"]["op"] == "function.aws.lambda"
-
-    function_name = error_event["extra"]["lambda"]["function_name"]
-    assert function_name.startswith("test_function_")
-    assert error_event["transaction"] == function_name
-
-    exception = error_event["exception"]["values"][0]
-    assert exception["type"] == "Exception"
-    assert exception["value"] == "More treats, please!"
-    assert exception["mechanism"]["type"] == "aws_lambda"
-
-    envelope = envelopes[0]
-    assert envelope["type"] == "transaction"
-    assert envelope["contexts"]["trace"] == DictionaryContaining(
-        error_event["contexts"]["trace"]
-    )
-    assert envelope["contexts"]["trace"]["status"] == "internal_error"
-    assert envelope["transaction"] == error_event["transaction"]
-    assert envelope["request"]["url"] == error_event["request"]["url"]
-
-    if has_request_data:
-        request_data = {
-            "headers": {"Host": "dogs.are.great", "X-Forwarded-Proto": "http"},
-            "method": "GET",
-            "url": "http://dogs.are.great/tricks/kangaroo",
-            "query_string": {
-                "completed_successfully": "true",
-                "treat_provided": "true",
-                "treat_type": "cheese",
-            },
-        }
-    else:
-        request_data = {"url": "awslambda:///{}".format(function_name)}
-
-    assert error_event["request"] == request_data
-    assert envelope["request"] == request_data
-
-    if batch_size > 1:
-        assert error_event["tags"]["batch_size"] == batch_size
-        assert error_event["tags"]["batch_request"] is True
-        assert envelope["tags"]["batch_size"] == batch_size
-        assert envelope["tags"]["batch_request"] is True
-
-
-def test_traces_sampler_gets_correct_values_in_sampling_context(
-    run_lambda_function,
-    DictionaryContaining,  # noqa:N803
-    ObjectDescribedBy,
-    StringContaining,
-):
-    # TODO: This whole thing is a little hacky, specifically around the need to
-    # get `conftest.py` code into the AWS runtime, which is why there's both
-    # `inspect.getsource` and a copy of `_safe_is_equal` included directly in
-    # the code below. Ideas which have been discussed to fix this:
-
-    # - Include the test suite as a module installed in the package which is
-    #   shot up to AWS
-    # - In client.py, copy `conftest.py` (or wherever the necessary code lives)
-    #   from the test suite into the main SDK directory so it gets included as
-    #   "part of the SDK"
-
-    # It's also worth noting why it's necessary to run the assertions in the AWS
-    # runtime rather than asserting on side effects the way we do with events
-    # and envelopes. The reasons are two-fold:
-
-    # - We're testing against the `LambdaContext` class, which only exists in
-    #   the AWS runtime
-    # - If we were to transmit call args data they way we transmit event and
-    #   envelope data (through JSON), we'd quickly run into the problem that all
-    #   sorts of stuff isn't serializable by `json.dumps` out of the box, up to
-    #   and including `datetime` objects (so anything with a timestamp is
-    #   automatically out)
-
-    # Perhaps these challenges can be solved in a cleaner and more systematic
-    # way if we ever decide to refactor the entire AWS testing apparatus.
-
-    import inspect
-
-    envelopes, events, response = run_lambda_function(
-        LAMBDA_PRELUDE
-        + dedent(inspect.getsource(StringContaining))
-        + dedent(inspect.getsource(DictionaryContaining))
-        + dedent(inspect.getsource(ObjectDescribedBy))
-        + dedent(
-            """
-            try:
-                from unittest import mock  # python 3.3 and above
-            except ImportError:
-                import mock  # python < 3.3
-
-            def _safe_is_equal(x, y):
-                # copied from conftest.py - see docstring and comments there
-                try:
-                    is_equal = x.__eq__(y)
-                except AttributeError:
-                    is_equal = NotImplemented
-
-                if is_equal == NotImplemented:
-                    # using == smoothes out weird variations exposed by raw __eq__
-                    return x == y
-
-                return is_equal
-
-            def test_handler(event, context):
-                # this runs after the transaction has started, which means we
-                # can make assertions about traces_sampler
-                try:
-                    traces_sampler.assert_any_call(
-                        DictionaryContaining(
-                            {
-                                "aws_event": DictionaryContaining({
-                                    "httpMethod": "GET",
-                                    "path": "/sit/stay/rollover",
-                                    "headers": {"Host": "dogs.are.great", "X-Forwarded-Proto": "http"},
-                                }),
-                                "aws_context": ObjectDescribedBy(
-                                    type=get_lambda_bootstrap().LambdaContext,
-                                    attrs={
-                                        'function_name': StringContaining("test_function"),
-                                        'function_version': '$LATEST',
-                                    }
-                                )
-                            }
-                        )
-                    )
-                except AssertionError:
-                    # catch the error and return it because the error itself will
-                    # get swallowed by the SDK as an "internal exception"
-                    return {"AssertionError raised": True,}
-
-                return {"AssertionError raised": False,}
-
-
-            traces_sampler = mock.Mock(return_value=True)
-
-            init_sdk(
-                traces_sampler=traces_sampler,
-            )
-        """
-        ),
-        b'{"httpMethod": "GET", "path": "/sit/stay/rollover", "headers": {"Host": "dogs.are.great", "X-Forwarded-Proto": "http"}}',
-    )
-
-    assert response["Payload"]["AssertionError raised"] is False
-
-
-def test_serverless_no_code_instrumentation(run_lambda_function):
-    """
-    Test that ensures that just by adding a lambda layer containing the
-    python sdk, with no code changes sentry is able to capture errors
-    """
-
-    for initial_handler in [
-        None,
-        "test_dir/test_lambda.test_handler",
-        "test_dir.test_lambda.test_handler",
-    ]:
-        print("Testing Initial Handler ", initial_handler)
-        _, _, response = run_lambda_function(
-            dedent(
-                """
-            import sentry_sdk
-
-            def test_handler(event, context):
-                current_client = sentry_sdk.Hub.current.client
-
-                assert current_client is not None
-
-                assert len(current_client.options['integrations']) == 1
-                assert isinstance(current_client.options['integrations'][0],
-                                  sentry_sdk.integrations.aws_lambda.AwsLambdaIntegration)
-
-                raise Exception("something went wrong")
-            """
-            ),
-            b'{"foo": "bar"}',
-            layer=True,
-            initial_handler=initial_handler,
-        )
-        assert response["FunctionError"] == "Unhandled"
-        assert response["StatusCode"] == 200
-
-        assert response["Payload"]["errorType"] != "AssertionError"
-
-        assert response["Payload"]["errorType"] == "Exception"
-        assert response["Payload"]["errorMessage"] == "something went wrong"
-
-        assert "sentry_handler" in response["LogResult"][3].decode("utf-8")
-
-
-def test_error_has_new_trace_context_performance_enabled(run_lambda_function):
-    envelopes, _, _ = run_lambda_function(
-        LAMBDA_PRELUDE
-        + dedent(
-            """
-        init_sdk(traces_sample_rate=1.0)
-
-        def test_handler(event, context):
-            sentry_sdk.capture_message("hi")
-            raise Exception("something went wrong")
-        """
-        ),
-        payload=b'{"foo": "bar"}',
-    )
-
-    (msg_event, error_event, transaction_event) = envelopes
-
-    assert "trace" in msg_event["contexts"]
-    assert "trace_id" in msg_event["contexts"]["trace"]
-
-    assert "trace" in error_event["contexts"]
-    assert "trace_id" in error_event["contexts"]["trace"]
-
-    assert "trace" in transaction_event["contexts"]
-    assert "trace_id" in transaction_event["contexts"]["trace"]
-
-    assert (
-        msg_event["contexts"]["trace"]["trace_id"]
-        == error_event["contexts"]["trace"]["trace_id"]
-        == transaction_event["contexts"]["trace"]["trace_id"]
-    )
-
-
-def test_error_has_new_trace_context_performance_disabled(run_lambda_function):
-    _, events, _ = run_lambda_function(
-        LAMBDA_PRELUDE
-        + dedent(
-            """
-        init_sdk(traces_sample_rate=None) # this is the default, just added for clarity
-
-        def test_handler(event, context):
-            sentry_sdk.capture_message("hi")
-            raise Exception("something went wrong")
-        """
-        ),
-        payload=b'{"foo": "bar"}',
-    )
-
-    (msg_event, error_event) = events
-
-    assert "trace" in msg_event["contexts"]
-    assert "trace_id" in msg_event["contexts"]["trace"]
-
-    assert "trace" in error_event["contexts"]
-    assert "trace_id" in error_event["contexts"]["trace"]
-
-    assert (
-        msg_event["contexts"]["trace"]["trace_id"]
-        == error_event["contexts"]["trace"]["trace_id"]
-    )
-
-
-def test_error_has_existing_trace_context_performance_enabled(run_lambda_function):
-    trace_id = "471a43a4192642f0b136d5159a501701"
-    parent_span_id = "6e8f22c393e68f19"
-    parent_sampled = 1
-    sentry_trace_header = "{}-{}-{}".format(trace_id, parent_span_id, parent_sampled)
-
-    envelopes, _, _ = run_lambda_function(
-        LAMBDA_PRELUDE
-        + dedent(
-            """
-        init_sdk(traces_sample_rate=1.0)
-
-        def test_handler(event, context):
-            sentry_sdk.capture_message("hi")
-            raise Exception("something went wrong")
-        """
-        ),
-        payload=b'{"sentry_trace": "%s"}' % sentry_trace_header.encode(),
-    )
-
-    (msg_event, error_event, transaction_event) = envelopes
-
-    assert "trace" in msg_event["contexts"]
-    assert "trace_id" in msg_event["contexts"]["trace"]
-
-    assert "trace" in error_event["contexts"]
-    assert "trace_id" in error_event["contexts"]["trace"]
-
-    assert "trace" in transaction_event["contexts"]
-    assert "trace_id" in transaction_event["contexts"]["trace"]
-
-    assert (
-        msg_event["contexts"]["trace"]["trace_id"]
-        == error_event["contexts"]["trace"]["trace_id"]
-        == transaction_event["contexts"]["trace"]["trace_id"]
-        == "471a43a4192642f0b136d5159a501701"
-    )
-
-
-def test_error_has_existing_trace_context_performance_disabled(run_lambda_function):
-    trace_id = "471a43a4192642f0b136d5159a501701"
-    parent_span_id = "6e8f22c393e68f19"
-    parent_sampled = 1
-    sentry_trace_header = "{}-{}-{}".format(trace_id, parent_span_id, parent_sampled)
-
-    _, events, _ = run_lambda_function(
-        LAMBDA_PRELUDE
-        + dedent(
-            """
-        init_sdk(traces_sample_rate=None)  # this is the default, just added for clarity
-
-        def test_handler(event, context):
-            sentry_sdk.capture_message("hi")
-            raise Exception("something went wrong")
-        """
-        ),
-        payload=b'{"sentry_trace": "%s"}' % sentry_trace_header.encode(),
-    )
-
-    (msg_event, error_event) = events
-
-    assert "trace" in msg_event["contexts"]
-    assert "trace_id" in msg_event["contexts"]["trace"]
-
-    assert "trace" in error_event["contexts"]
-    assert "trace_id" in error_event["contexts"]["trace"]
-
-    assert (
-        msg_event["contexts"]["trace"]["trace_id"]
-        == error_event["contexts"]["trace"]["trace_id"]
-        == "471a43a4192642f0b136d5159a501701"
-    )
diff --git a/tests/integrations/aws_lambda/test_aws_lambda.py b/tests/integrations/aws_lambda/test_aws_lambda.py
new file mode 100644
index 0000000000..664220464c
--- /dev/null
+++ b/tests/integrations/aws_lambda/test_aws_lambda.py
@@ -0,0 +1,575 @@
+import boto3
+import docker
+import json
+import pytest
+import subprocess
+import tempfile
+import time
+import yaml
+
+from unittest import mock
+
+from aws_cdk import App
+
+from .utils import LocalLambdaStack, SentryServerForTesting, SAM_PORT
+
+
+DOCKER_NETWORK_NAME = "lambda-test-network"
+SAM_TEMPLATE_FILE = "sam.template.yaml"
+
+
+@pytest.fixture(scope="session", autouse=True)
+def test_environment():
+    print("[test_environment fixture] Setting up AWS Lambda test infrastructure")
+
+    # Create a Docker network
+    docker_client = docker.from_env()
+    docker_client.networks.prune()
+    docker_client.networks.create(DOCKER_NETWORK_NAME, driver="bridge")
+
+    # Start Sentry server
+    server = SentryServerForTesting()
+    server.start()
+    time.sleep(1)  # Give it a moment to start up
+
+    # Create local AWS SAM stack
+    app = App()
+    stack = LocalLambdaStack(app, "LocalLambdaStack")
+
+    # Write SAM template to file
+    template = app.synth().get_stack_by_name("LocalLambdaStack").template
+    with open(SAM_TEMPLATE_FILE, "w") as f:
+        yaml.dump(template, f)
+
+    # Write SAM debug log to file
+    debug_log_file = tempfile.gettempdir() + "/sentry_aws_lambda_tests_sam_debug.log"
+    debug_log = open(debug_log_file, "w")
+    print("[test_environment fixture] Writing SAM debug log to: %s" % debug_log_file)
+
+    # Start SAM local
+    process = subprocess.Popen(
+        [
+            "sam",
+            "local",
+            "start-lambda",
+            "--debug",
+            "--template",
+            SAM_TEMPLATE_FILE,
+            "--warm-containers",
+            "EAGER",
+            "--docker-network",
+            DOCKER_NETWORK_NAME,
+        ],
+        stdout=debug_log,
+        stderr=debug_log,
+        text=True,  # This makes stdout/stderr return strings instead of bytes
+    )
+
+    try:
+        # Wait for SAM to be ready
+        LocalLambdaStack.wait_for_stack()
+
+        def before_test():
+            server.clear_envelopes()
+
+        yield {
+            "stack": stack,
+            "server": server,
+            "before_test": before_test,
+        }
+
+    finally:
+        print("[test_environment fixture] Tearing down AWS Lambda test infrastructure")
+
+        process.terminate()
+        process.wait(timeout=5)  # Give it time to shut down gracefully
+
+        # Force kill if still running
+        if process.poll() is None:
+            process.kill()
+
+
+@pytest.fixture(autouse=True)
+def clear_before_test(test_environment):
+    test_environment["before_test"]()
+
+
+@pytest.fixture
+def lambda_client():
+    """
+    Create a boto3 client configured to use the local AWS SAM instance.
+    """
+    return boto3.client(
+        "lambda",
+        endpoint_url=f"http://127.0.0.1:{SAM_PORT}",  # noqa: E231
+        aws_access_key_id="dummy",
+        aws_secret_access_key="dummy",
+        region_name="us-east-1",
+    )
+
+
+def test_basic_no_exception(lambda_client, test_environment):
+    lambda_client.invoke(
+        FunctionName="BasicOk",
+        Payload=json.dumps({}),
+    )
+    envelopes = test_environment["server"].envelopes
+
+    (transaction_event,) = envelopes
+
+    assert transaction_event["type"] == "transaction"
+    assert transaction_event["transaction"] == "BasicOk"
+    assert transaction_event["sdk"]["name"] == "sentry.python.aws_lambda"
+    assert transaction_event["tags"] == {"aws_region": "us-east-1"}
+
+    assert transaction_event["extra"]["cloudwatch logs"] == {
+        "log_group": mock.ANY,
+        "log_stream": mock.ANY,
+        "url": mock.ANY,
+    }
+    assert transaction_event["extra"]["lambda"] == {
+        "aws_request_id": mock.ANY,
+        "execution_duration_in_millis": mock.ANY,
+        "function_name": "BasicOk",
+        "function_version": "$LATEST",
+        "invoked_function_arn": "arn:aws:lambda:us-east-1:012345678912:function:BasicOk",
+        "remaining_time_in_millis": mock.ANY,
+    }
+    assert transaction_event["contexts"]["trace"] == {
+        "op": "function.aws",
+        "description": mock.ANY,
+        "span_id": mock.ANY,
+        "parent_span_id": mock.ANY,
+        "trace_id": mock.ANY,
+        "origin": "auto.function.aws_lambda",
+        "data": mock.ANY,
+    }
+
+
+def test_basic_exception(lambda_client, test_environment):
+    lambda_client.invoke(
+        FunctionName="BasicException",
+        Payload=json.dumps({}),
+    )
+    envelopes = test_environment["server"].envelopes
+
+    # The second envelope we ignore.
+    # It is the transaction that we test in test_basic_no_exception.
+    (error_event, _) = envelopes
+
+    assert error_event["level"] == "error"
+    assert error_event["exception"]["values"][0]["type"] == "RuntimeError"
+    assert error_event["exception"]["values"][0]["value"] == "Oh!"
+    assert error_event["sdk"]["name"] == "sentry.python.aws_lambda"
+
+    assert error_event["tags"] == {"aws_region": "us-east-1"}
+    assert error_event["extra"]["cloudwatch logs"] == {
+        "log_group": mock.ANY,
+        "log_stream": mock.ANY,
+        "url": mock.ANY,
+    }
+    assert error_event["extra"]["lambda"] == {
+        "aws_request_id": mock.ANY,
+        "execution_duration_in_millis": mock.ANY,
+        "function_name": "BasicException",
+        "function_version": "$LATEST",
+        "invoked_function_arn": "arn:aws:lambda:us-east-1:012345678912:function:BasicException",
+        "remaining_time_in_millis": mock.ANY,
+    }
+    assert error_event["contexts"]["trace"] == {
+        "op": "function.aws",
+        "description": mock.ANY,
+        "span_id": mock.ANY,
+        "parent_span_id": mock.ANY,
+        "trace_id": mock.ANY,
+        "origin": "auto.function.aws_lambda",
+        "data": mock.ANY,
+    }
+
+
+def test_init_error(lambda_client, test_environment):
+    lambda_client.invoke(
+        FunctionName="InitError",
+        Payload=json.dumps({}),
+    )
+    envelopes = test_environment["server"].envelopes
+
+    (error_event, transaction_event) = envelopes
+
+    assert (
+        error_event["exception"]["values"][0]["value"] == "name 'func' is not defined"
+    )
+    assert transaction_event["transaction"] == "InitError"
+
+
+def test_timeout_error(lambda_client, test_environment):
+    lambda_client.invoke(
+        FunctionName="TimeoutError",
+        Payload=json.dumps({}),
+    )
+    envelopes = test_environment["server"].envelopes
+
+    (error_event,) = envelopes
+
+    assert error_event["level"] == "error"
+    assert error_event["extra"]["lambda"]["function_name"] == "TimeoutError"
+
+    (exception,) = error_event["exception"]["values"]
+    assert not exception["mechanism"]["handled"]
+    assert exception["type"] == "ServerlessTimeoutWarning"
+    assert exception["value"].startswith(
+        "WARNING : Function is expected to get timed out. Configured timeout duration ="
+    )
+    assert exception["mechanism"]["type"] == "threading"
+
+
+def test_timeout_error_scope_modified(lambda_client, test_environment):
+    lambda_client.invoke(
+        FunctionName="TimeoutErrorScopeModified",
+        Payload=json.dumps({}),
+    )
+    envelopes = test_environment["server"].envelopes
+
+    (error_event,) = envelopes
+
+    assert error_event["level"] == "error"
+    assert (
+        error_event["extra"]["lambda"]["function_name"] == "TimeoutErrorScopeModified"
+    )
+
+    (exception,) = error_event["exception"]["values"]
+    assert not exception["mechanism"]["handled"]
+    assert exception["type"] == "ServerlessTimeoutWarning"
+    assert exception["value"].startswith(
+        "WARNING : Function is expected to get timed out. Configured timeout duration ="
+    )
+    assert exception["mechanism"]["type"] == "threading"
+
+    assert error_event["tags"]["custom_tag"] == "custom_value"
+
+
+@pytest.mark.parametrize(
+    "aws_event, has_request_data, batch_size",
+    [
+        (b"1231", False, 1),
+        (b"11.21", False, 1),
+        (b'"Good dog!"', False, 1),
+        (b"true", False, 1),
+        (
+            b"""
+            [
+                {"good dog": "Maisey"},
+                {"good dog": "Charlie"},
+                {"good dog": "Cory"},
+                {"good dog": "Bodhi"}
+            ]
+            """,
+            False,
+            4,
+        ),
+        (
+            b"""
+            [
+                {
+                    "headers": {
+                        "Host": "x1.io",
+                        "X-Forwarded-Proto": "https"
+                    },
+                    "httpMethod": "GET",
+                    "path": "/1",
+                    "queryStringParameters": {
+                        "done": "f"
+                    },
+                    "d": "D1"
+                },
+                {
+                    "headers": {
+                        "Host": "x2.io",
+                        "X-Forwarded-Proto": "http"
+                    },
+                    "httpMethod": "POST",
+                    "path": "/2",
+                    "queryStringParameters": {
+                        "done": "t"
+                    },
+                    "d": "D2"
+                }
+            ]
+            """,
+            True,
+            2,
+        ),
+        (b"[]", False, 1),
+    ],
+    ids=[
+        "event as integer",
+        "event as float",
+        "event as string",
+        "event as bool",
+        "event as list of dicts",
+        "event as dict",
+        "event as empty list",
+    ],
+)
+def test_non_dict_event(
+    lambda_client, test_environment, aws_event, has_request_data, batch_size
+):
+    lambda_client.invoke(
+        FunctionName="BasicException",
+        Payload=aws_event,
+    )
+    envelopes = test_environment["server"].envelopes
+
+    (error_event, transaction_event) = envelopes
+
+    assert transaction_event["type"] == "transaction"
+    assert transaction_event["transaction"] == "BasicException"
+    assert transaction_event["sdk"]["name"] == "sentry.python.aws_lambda"
+    assert transaction_event["contexts"]["trace"]["status"] == "internal_error"
+
+    assert error_event["level"] == "error"
+    assert error_event["transaction"] == "BasicException"
+    assert error_event["sdk"]["name"] == "sentry.python.aws_lambda"
+    assert error_event["exception"]["values"][0]["type"] == "RuntimeError"
+    assert error_event["exception"]["values"][0]["value"] == "Oh!"
+    assert error_event["exception"]["values"][0]["mechanism"]["type"] == "aws_lambda"
+
+    if has_request_data:
+        request_data = {
+            "headers": {"Host": "x1.io", "X-Forwarded-Proto": "https"},
+            "method": "GET",
+            "url": "https://x1.io/1",
+            "query_string": {
+                "done": "f",
+            },
+        }
+    else:
+        request_data = {"url": "awslambda:///BasicException"}
+
+    assert error_event["request"] == request_data
+    assert transaction_event["request"] == request_data
+
+    if batch_size > 1:
+        assert error_event["tags"]["batch_size"] == batch_size
+        assert error_event["tags"]["batch_request"] is True
+        assert transaction_event["tags"]["batch_size"] == batch_size
+        assert transaction_event["tags"]["batch_request"] is True
+
+
+def test_request_data(lambda_client, test_environment):
+    payload = b"""
+        {
+          "resource": "/asd",
+          "path": "/asd",
+          "httpMethod": "GET",
+          "headers": {
+            "Host": "iwsz2c7uwi.execute-api.us-east-1.amazonaws.com",
+            "User-Agent": "custom",
+            "X-Forwarded-Proto": "https"
+          },
+          "queryStringParameters": {
+            "bonkers": "true"
+          },
+          "pathParameters": null,
+          "stageVariables": null,
+          "requestContext": {
+            "identity": {
+              "sourceIp": "213.47.147.207",
+              "userArn": "42"
+            }
+          },
+          "body": null,
+          "isBase64Encoded": false
+        }
+    """
+
+    lambda_client.invoke(
+        FunctionName="BasicOk",
+        Payload=payload,
+    )
+    envelopes = test_environment["server"].envelopes
+
+    (transaction_event,) = envelopes
+
+    assert transaction_event["request"] == {
+        "headers": {
+            "Host": "iwsz2c7uwi.execute-api.us-east-1.amazonaws.com",
+            "User-Agent": "custom",
+            "X-Forwarded-Proto": "https",
+        },
+        "method": "GET",
+        "query_string": {"bonkers": "true"},
+        "url": "https://iwsz2c7uwi.execute-api.us-east-1.amazonaws.com/asd",
+    }
+
+
+def test_trace_continuation(lambda_client, test_environment):
+    trace_id = "471a43a4192642f0b136d5159a501701"
+    parent_span_id = "6e8f22c393e68f19"
+    parent_sampled = 1
+    sentry_trace_header = "{}-{}-{}".format(trace_id, parent_span_id, parent_sampled)
+
+    # We simulate here AWS Api Gateway's behavior of passing HTTP headers
+    # as the `headers` dict in the event passed to the Lambda function.
+    payload = {
+        "headers": {
+            "sentry-trace": sentry_trace_header,
+        }
+    }
+
+    lambda_client.invoke(
+        FunctionName="BasicException",
+        Payload=json.dumps(payload),
+    )
+    envelopes = test_environment["server"].envelopes
+
+    (error_event, transaction_event) = envelopes
+
+    assert (
+        error_event["contexts"]["trace"]["trace_id"]
+        == transaction_event["contexts"]["trace"]["trace_id"]
+        == "471a43a4192642f0b136d5159a501701"
+    )
+
+
+@pytest.mark.parametrize(
+    "payload",
+    [
+        {},
+        {"headers": None},
+        {"headers": ""},
+        {"headers": {}},
+        {"headers": []},  # EventBridge sends an empty list
+    ],
+    ids=[
+        "no headers",
+        "none headers",
+        "empty string headers",
+        "empty dict headers",
+        "empty list headers",
+    ],
+)
+def test_headers(lambda_client, test_environment, payload):
+    lambda_client.invoke(
+        FunctionName="BasicException",
+        Payload=json.dumps(payload),
+    )
+    envelopes = test_environment["server"].envelopes
+
+    (error_event, _) = envelopes
+
+    assert error_event["level"] == "error"
+    assert error_event["exception"]["values"][0]["type"] == "RuntimeError"
+    assert error_event["exception"]["values"][0]["value"] == "Oh!"
+
+
+def test_span_origin(lambda_client, test_environment):
+    lambda_client.invoke(
+        FunctionName="BasicOk",
+        Payload=json.dumps({}),
+    )
+    envelopes = test_environment["server"].envelopes
+
+    (transaction_event,) = envelopes
+
+    assert (
+        transaction_event["contexts"]["trace"]["origin"] == "auto.function.aws_lambda"
+    )
+
+
+def test_traces_sampler_has_correct_sampling_context(lambda_client, test_environment):
+    """
+    Test that aws_event and aws_context are passed in the custom_sampling_context
+    when using the AWS Lambda integration.
+    """
+    test_payload = {"test_key": "test_value"}
+    response = lambda_client.invoke(
+        FunctionName="TracesSampler",
+        Payload=json.dumps(test_payload),
+    )
+    response_payload = json.loads(response["Payload"].read().decode())
+    sampling_context_data = json.loads(response_payload["body"])[
+        "sampling_context_data"
+    ]
+    assert sampling_context_data.get("aws_event_present") is True
+    assert sampling_context_data.get("aws_context_present") is True
+    assert sampling_context_data.get("event_data", {}).get("test_key") == "test_value"
+
+
+@pytest.mark.parametrize(
+    "lambda_function_name",
+    ["RaiseErrorPerformanceEnabled", "RaiseErrorPerformanceDisabled"],
+)
+def test_error_has_new_trace_context(
+    lambda_client, test_environment, lambda_function_name
+):
+    lambda_client.invoke(
+        FunctionName=lambda_function_name,
+        Payload=json.dumps({}),
+    )
+    envelopes = test_environment["server"].envelopes
+
+    if lambda_function_name == "RaiseErrorPerformanceEnabled":
+        (error_event, transaction_event) = envelopes
+    else:
+        (error_event,) = envelopes
+        transaction_event = None
+
+    assert "trace" in error_event["contexts"]
+    assert "trace_id" in error_event["contexts"]["trace"]
+
+    if transaction_event:
+        assert "trace" in transaction_event["contexts"]
+        assert "trace_id" in transaction_event["contexts"]["trace"]
+        assert (
+            error_event["contexts"]["trace"]["trace_id"]
+            == transaction_event["contexts"]["trace"]["trace_id"]
+        )
+
+
+@pytest.mark.parametrize(
+    "lambda_function_name",
+    ["RaiseErrorPerformanceEnabled", "RaiseErrorPerformanceDisabled"],
+)
+def test_error_has_existing_trace_context(
+    lambda_client, test_environment, lambda_function_name
+):
+    trace_id = "471a43a4192642f0b136d5159a501701"
+    parent_span_id = "6e8f22c393e68f19"
+    parent_sampled = 1
+    sentry_trace_header = "{}-{}-{}".format(trace_id, parent_span_id, parent_sampled)
+
+    # We simulate here AWS Api Gateway's behavior of passing HTTP headers
+    # as the `headers` dict in the event passed to the Lambda function.
+    payload = {
+        "headers": {
+            "sentry-trace": sentry_trace_header,
+        }
+    }
+
+    lambda_client.invoke(
+        FunctionName=lambda_function_name,
+        Payload=json.dumps(payload),
+    )
+    envelopes = test_environment["server"].envelopes
+
+    if lambda_function_name == "RaiseErrorPerformanceEnabled":
+        (error_event, transaction_event) = envelopes
+    else:
+        (error_event,) = envelopes
+        transaction_event = None
+
+    assert "trace" in error_event["contexts"]
+    assert "trace_id" in error_event["contexts"]["trace"]
+    assert (
+        error_event["contexts"]["trace"]["trace_id"]
+        == "471a43a4192642f0b136d5159a501701"
+    )
+
+    if transaction_event:
+        assert "trace" in transaction_event["contexts"]
+        assert "trace_id" in transaction_event["contexts"]["trace"]
+        assert (
+            transaction_event["contexts"]["trace"]["trace_id"]
+            == "471a43a4192642f0b136d5159a501701"
+        )
diff --git a/tests/integrations/aws_lambda/utils.py b/tests/integrations/aws_lambda/utils.py
new file mode 100644
index 0000000000..d20c9352e7
--- /dev/null
+++ b/tests/integrations/aws_lambda/utils.py
@@ -0,0 +1,294 @@
+import gzip
+import json
+import os
+import shutil
+import subprocess
+import requests
+import sys
+import time
+import threading
+import socket
+import platform
+
+from aws_cdk import (
+    CfnResource,
+    Stack,
+)
+from constructs import Construct
+from fastapi import FastAPI, Request
+import uvicorn
+
+from scripts.build_aws_lambda_layer import build_packaged_zip, DIST_PATH
+
+
+LAMBDA_FUNCTION_DIR = "./tests/integrations/aws_lambda/lambda_functions/"
+LAMBDA_FUNCTION_WITH_EMBEDDED_SDK_DIR = (
+    "./tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/"
+)
+LAMBDA_FUNCTION_TIMEOUT = 10
+SAM_PORT = 3001
+
+PYTHON_VERSION = f"python{sys.version_info.major}.{sys.version_info.minor}"
+
+
+def get_host_ip():
+    """
+    Returns the IP address of the host we are running on.
+    """
+    if os.environ.get("GITHUB_ACTIONS"):
+        # Running in GitHub Actions
+        hostname = socket.gethostname()
+        host = socket.gethostbyname(hostname)
+    else:
+        # Running locally
+        if platform.system() in ["Darwin", "Windows"]:
+            # Windows or MacOS
+            host = "host.docker.internal"
+        else:
+            # Linux
+            hostname = socket.gethostname()
+            host = socket.gethostbyname(hostname)
+
+    return host
+
+
+def get_project_root():
+    """
+    Returns the absolute path to the project root directory.
+    """
+    # Start from the current file's directory
+    current_dir = os.path.dirname(os.path.abspath(__file__))
+
+    # Navigate up to the project root (4 levels up from tests/integrations/aws_lambda/)
+    # This is equivalent to the multiple dirname() calls
+    project_root = os.path.abspath(os.path.join(current_dir, "../../../"))
+
+    return project_root
+
+
+class LocalLambdaStack(Stack):
+    """
+    Uses the AWS CDK to create a local SAM stack containing Lambda functions.
+    """
+
+    def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None:
+        print("[LocalLambdaStack] Creating local SAM Lambda Stack")
+        super().__init__(scope, construct_id, **kwargs)
+
+        # Override the template synthesis
+        self.template_options.template_format_version = "2010-09-09"
+        self.template_options.transforms = ["AWS::Serverless-2016-10-31"]
+
+        print("[LocalLambdaStack] Create Sentry Lambda layer package")
+        filename = "sentry-sdk-lambda-layer.zip"
+        build_packaged_zip(
+            make_dist=True,
+            out_zip_filename=filename,
+        )
+
+        print(
+            "[LocalLambdaStack] Add Sentry Lambda layer containing the Sentry SDK to the SAM stack"
+        )
+        self.sentry_layer = CfnResource(
+            self,
+            "SentryPythonServerlessSDK",
+            type="AWS::Serverless::LayerVersion",
+            properties={
+                "ContentUri": os.path.join(DIST_PATH, filename),
+                "CompatibleRuntimes": [
+                    PYTHON_VERSION,
+                ],
+            },
+        )
+
+        dsn = f"http://123@{get_host_ip()}:9999/0"  # noqa: E231
+        print("[LocalLambdaStack] Using Sentry DSN: %s" % dsn)
+
+        print(
+            "[LocalLambdaStack] Add all Lambda functions defined in "
+            "/tests/integrations/aws_lambda/lambda_functions/ to the SAM stack"
+        )
+        lambda_dirs = [
+            d
+            for d in os.listdir(LAMBDA_FUNCTION_DIR)
+            if os.path.isdir(os.path.join(LAMBDA_FUNCTION_DIR, d))
+        ]
+        for lambda_dir in lambda_dirs:
+            CfnResource(
+                self,
+                lambda_dir,
+                type="AWS::Serverless::Function",
+                properties={
+                    "CodeUri": os.path.join(LAMBDA_FUNCTION_DIR, lambda_dir),
+                    "Handler": "sentry_sdk.integrations.init_serverless_sdk.sentry_lambda_handler",
+                    "Runtime": PYTHON_VERSION,
+                    "Timeout": LAMBDA_FUNCTION_TIMEOUT,
+                    "Layers": [
+                        {"Ref": self.sentry_layer.logical_id}
+                    ],  # Add layer containing the Sentry SDK to function.
+                    "Environment": {
+                        "Variables": {
+                            "SENTRY_DSN": dsn,
+                            "SENTRY_INITIAL_HANDLER": "index.handler",
+                            "SENTRY_TRACES_SAMPLE_RATE": "1.0",
+                        }
+                    },
+                },
+            )
+            print(
+                "[LocalLambdaStack] - Created Lambda function: %s (%s)"
+                % (
+                    lambda_dir,
+                    os.path.join(LAMBDA_FUNCTION_DIR, lambda_dir),
+                )
+            )
+
+        print(
+            "[LocalLambdaStack] Add all Lambda functions defined in "
+            "/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/ to the SAM stack"
+        )
+        lambda_dirs = [
+            d
+            for d in os.listdir(LAMBDA_FUNCTION_WITH_EMBEDDED_SDK_DIR)
+            if os.path.isdir(os.path.join(LAMBDA_FUNCTION_WITH_EMBEDDED_SDK_DIR, d))
+        ]
+        for lambda_dir in lambda_dirs:
+            # Copy the Sentry SDK into the function directory
+            sdk_path = os.path.join(
+                LAMBDA_FUNCTION_WITH_EMBEDDED_SDK_DIR, lambda_dir, "sentry_sdk"
+            )
+            if not os.path.exists(sdk_path):
+                # Find the Sentry SDK in the current environment
+                import sentry_sdk as sdk_module
+
+                sdk_source = os.path.dirname(sdk_module.__file__)
+                shutil.copytree(sdk_source, sdk_path)
+
+            # Install the requirements of Sentry SDK into the function directory
+            requirements_file = os.path.join(
+                get_project_root(), "requirements-aws-lambda-layer.txt"
+            )
+
+            # Install the package using pip
+            subprocess.check_call(
+                [
+                    sys.executable,
+                    "-m",
+                    "pip",
+                    "install",
+                    "--upgrade",
+                    "--target",
+                    os.path.join(LAMBDA_FUNCTION_WITH_EMBEDDED_SDK_DIR, lambda_dir),
+                    "-r",
+                    requirements_file,
+                ]
+            )
+
+            CfnResource(
+                self,
+                lambda_dir,
+                type="AWS::Serverless::Function",
+                properties={
+                    "CodeUri": os.path.join(
+                        LAMBDA_FUNCTION_WITH_EMBEDDED_SDK_DIR, lambda_dir
+                    ),
+                    "Handler": "index.handler",
+                    "Runtime": PYTHON_VERSION,
+                    "Timeout": LAMBDA_FUNCTION_TIMEOUT,
+                    "Environment": {
+                        "Variables": {
+                            "SENTRY_DSN": dsn,
+                        }
+                    },
+                },
+            )
+            print(
+                "[LocalLambdaStack] - Created Lambda function: %s (%s)"
+                % (
+                    lambda_dir,
+                    os.path.join(LAMBDA_FUNCTION_DIR, lambda_dir),
+                )
+            )
+
+    @classmethod
+    def wait_for_stack(cls, timeout=60, port=SAM_PORT):
+        """
+        Wait for SAM to be ready, with timeout.
+        """
+        start_time = time.time()
+        while True:
+            if time.time() - start_time > timeout:
+                raise TimeoutError(
+                    "AWS SAM failed to start within %s seconds. (Maybe Docker is not running?)"
+                    % timeout
+                )
+
+            try:
+                # Try to connect to SAM
+                response = requests.get(f"http://127.0.0.1:{port}/")  # noqa: E231
+                if response.status_code == 200 or response.status_code == 404:
+                    return
+
+            except requests.exceptions.ConnectionError:
+                time.sleep(1)
+                continue
+
+
+class SentryServerForTesting:
+    """
+    A simple Sentry.io style server that accepts envelopes and stores them in a list.
+    """
+
+    def __init__(self, host="0.0.0.0", port=9999, log_level="warning"):
+        self.envelopes = []
+        self.host = host
+        self.port = port
+        self.log_level = log_level
+        self.app = FastAPI()
+
+        @self.app.post("/api/0/envelope/")
+        async def envelope(request: Request):
+            print("[SentryServerForTesting] Received envelope")
+            try:
+                raw_body = await request.body()
+            except Exception:
+                return {"status": "no body received"}
+
+            try:
+                body = gzip.decompress(raw_body).decode("utf-8")
+            except Exception:
+                # If decompression fails, assume it's plain text
+                body = raw_body.decode("utf-8")
+
+            lines = body.split("\n")
+
+            current_line = 1  # line 0 is envelope header
+            while current_line < len(lines):
+                # skip empty lines
+                if not lines[current_line].strip():
+                    current_line += 1
+                    continue
+
+                # skip envelope item header
+                current_line += 1
+
+                # add envelope item to store
+                envelope_item = lines[current_line]
+                if envelope_item.strip():
+                    self.envelopes.append(json.loads(envelope_item))
+
+            return {"status": "ok"}
+
+    def run_server(self):
+        uvicorn.run(self.app, host=self.host, port=self.port, log_level=self.log_level)
+
+    def start(self):
+        print(
+            "[SentryServerForTesting] Starting server on %s:%s" % (self.host, self.port)
+        )
+        server_thread = threading.Thread(target=self.run_server, daemon=True)
+        server_thread.start()
+
+    def clear_envelopes(self):
+        print("[SentryServerForTesting] Clearing envelopes")
+        self.envelopes = []
diff --git a/tests/integrations/beam/test_beam.py b/tests/integrations/beam/test_beam.py
index 570cd0ab1b..809c4122e4 100644
--- a/tests/integrations/beam/test_beam.py
+++ b/tests/integrations/beam/test_beam.py
@@ -12,9 +12,14 @@
 from apache_beam.typehints.trivial_inference import instance_to_type
 from apache_beam.typehints.decorators import getcallargs_forhints
 from apache_beam.transforms.core import DoFn, ParDo, _DoFnParam, CallableWrapperDoFn
-from apache_beam.runners.common import DoFnInvoker, OutputProcessor, DoFnContext
+from apache_beam.runners.common import DoFnInvoker, DoFnContext
 from apache_beam.utils.windowed_value import WindowedValue
 
+try:
+    from apache_beam.runners.common import OutputHandler
+except ImportError:
+    from apache_beam.runners.common import OutputProcessor as OutputHandler
+
 
 def foo():
     return True
@@ -40,7 +45,7 @@ def process(self):
         return self.fn()
 
 
-class B(A, object):
+class B(A):
     def fa(self, x, element=False, another_element=False):
         if x or (element and not another_element):
             # print(self.r)
@@ -50,7 +55,7 @@ def fa(self, x, element=False, another_element=False):
 
     def __init__(self):
         self.r = "We are in B"
-        super(B, self).__init__(self.fa)
+        super().__init__(self.fa)
 
 
 class SimpleFunc(DoFn):
@@ -139,19 +144,26 @@ def test_monkey_patch_signature(f, args, kwargs):
     try:
         expected_signature = inspect.signature(f)
         test_signature = inspect.signature(f_temp)
-        assert (
-            expected_signature == test_signature
-        ), "Failed on {}, signature {} does not match {}".format(
-            f, expected_signature, test_signature
+        assert expected_signature == test_signature, (
+            "Failed on {}, signature {} does not match {}".format(
+                f, expected_signature, test_signature
+            )
         )
     except Exception:
         # expected to pass for py2.7
         pass
 
 
-class _OutputProcessor(OutputProcessor):
+class _OutputHandler(OutputHandler):
     def process_outputs(
         self, windowed_input_element, results, watermark_estimator=None
+    ):
+        self.handle_process_outputs(
+            windowed_input_element, results, watermark_estimator
+        )
+
+    def handle_process_outputs(
+        self, windowed_input_element, results, watermark_estimator=None
     ):
         print(windowed_input_element)
         try:
@@ -168,9 +180,13 @@ def inner(fn):
         # Little hack to avoid having to run the whole pipeline.
         pardo = ParDo(fn)
         signature = pardo._signature
-        output_processor = _OutputProcessor()
+        output_processor = _OutputHandler()
         return DoFnInvoker.create_invoker(
-            signature, output_processor, DoFnContext("test")
+            signature,
+            output_processor,
+            DoFnContext("test"),
+            input_args=[],
+            input_kwargs={},
         )
 
     return inner
diff --git a/tests/integrations/boto3/aws_mock.py b/tests/integrations/boto3/aws_mock.py
index 84ff23f466..da97570e4c 100644
--- a/tests/integrations/boto3/aws_mock.py
+++ b/tests/integrations/boto3/aws_mock.py
@@ -10,7 +10,7 @@ def stream(self, **kwargs):
             contents = self.read()
 
 
-class MockResponse(object):
+class MockResponse:
     def __init__(self, client, status_code, headers, body):
         self._client = client
         self._status_code = status_code
diff --git a/tests/integrations/boto3/test_s3.py b/tests/integrations/boto3/test_s3.py
index 5812c2c1bb..97a1543b0f 100644
--- a/tests/integrations/boto3/test_s3.py
+++ b/tests/integrations/boto3/test_s3.py
@@ -1,16 +1,13 @@
-import pytest
+from unittest import mock
 
 import boto3
+import pytest
 
-from sentry_sdk import Hub
+import sentry_sdk
 from sentry_sdk.integrations.boto3 import Boto3Integration
-from tests.integrations.boto3.aws_mock import MockResponse
+from tests.conftest import ApproxDict
 from tests.integrations.boto3 import read_fixture
-
-try:
-    from unittest import mock  # python 3.3 and above
-except ImportError:
-    import mock  # python < 3.3
+from tests.integrations.boto3.aws_mock import MockResponse
 
 
 session = boto3.Session(
@@ -24,7 +21,7 @@ def test_basic(sentry_init, capture_events):
     events = capture_events()
 
     s3 = session.resource("s3")
-    with Hub.current.start_transaction() as transaction, MockResponse(
+    with sentry_sdk.start_transaction() as transaction, MockResponse(
         s3.meta.client, 200, {}, read_fixture("s3_list.xml")
     ):
         bucket = s3.Bucket("bucket")
@@ -47,7 +44,7 @@ def test_streaming(sentry_init, capture_events):
     events = capture_events()
 
     s3 = session.resource("s3")
-    with Hub.current.start_transaction() as transaction, MockResponse(
+    with sentry_sdk.start_transaction() as transaction, MockResponse(
         s3.meta.client, 200, {}, b"hello"
     ):
         obj = s3.Bucket("bucket").Object("foo.pdf")
@@ -65,12 +62,14 @@ def test_streaming(sentry_init, capture_events):
     span1 = event["spans"][0]
     assert span1["op"] == "http.client"
     assert span1["description"] == "aws.s3.GetObject"
-    assert span1["data"] == {
-        "http.method": "GET",
-        "aws.request.url": "https://bucket.s3.amazonaws.com/foo.pdf",
-        "http.fragment": "",
-        "http.query": "",
-    }
+    assert span1["data"] == ApproxDict(
+        {
+            "http.method": "GET",
+            "aws.request.url": "https://bucket.s3.amazonaws.com/foo.pdf",
+            "http.fragment": "",
+            "http.query": "",
+        }
+    )
 
     span2 = event["spans"][1]
     assert span2["op"] == "http.client.stream"
@@ -83,7 +82,7 @@ def test_streaming_close(sentry_init, capture_events):
     events = capture_events()
 
     s3 = session.resource("s3")
-    with Hub.current.start_transaction() as transaction, MockResponse(
+    with sentry_sdk.start_transaction() as transaction, MockResponse(
         s3.meta.client, 200, {}, b"hello"
     ):
         obj = s3.Bucket("bucket").Object("foo.pdf")
@@ -112,7 +111,7 @@ def test_omit_url_data_if_parsing_fails(sentry_init, capture_events):
         "sentry_sdk.integrations.boto3.parse_url",
         side_effect=ValueError,
     ):
-        with Hub.current.start_transaction() as transaction, MockResponse(
+        with sentry_sdk.start_transaction() as transaction, MockResponse(
             s3.meta.client, 200, {}, read_fixture("s3_list.xml")
         ):
             bucket = s3.Bucket("bucket")
@@ -123,7 +122,30 @@ def test_omit_url_data_if_parsing_fails(sentry_init, capture_events):
             transaction.finish()
 
     (event,) = events
-    assert event["spans"][0]["data"] == {
-        "http.method": "GET",
-        # no url data
-    }
+    assert event["spans"][0]["data"] == ApproxDict(
+        {
+            "http.method": "GET",
+            # no url data
+        }
+    )
+
+    assert "aws.request.url" not in event["spans"][0]["data"]
+    assert "http.fragment" not in event["spans"][0]["data"]
+    assert "http.query" not in event["spans"][0]["data"]
+
+
+def test_span_origin(sentry_init, capture_events):
+    sentry_init(traces_sample_rate=1.0, integrations=[Boto3Integration()])
+    events = capture_events()
+
+    s3 = session.resource("s3")
+    with sentry_sdk.start_transaction(), MockResponse(
+        s3.meta.client, 200, {}, read_fixture("s3_list.xml")
+    ):
+        bucket = s3.Bucket("bucket")
+        _ = [obj for obj in bucket.objects.all()]
+
+    (event,) = events
+
+    assert event["contexts"]["trace"]["origin"] == "manual"
+    assert event["spans"][0]["origin"] == "auto.http.boto3"
diff --git a/tests/integrations/bottle/test_bottle.py b/tests/integrations/bottle/test_bottle.py
index 660acb3902..1965691d6c 100644
--- a/tests/integrations/bottle/test_bottle.py
+++ b/tests/integrations/bottle/test_bottle.py
@@ -3,14 +3,15 @@
 import logging
 
 from io import BytesIO
-from bottle import Bottle, debug as set_debug, abort, redirect
+from bottle import Bottle, debug as set_debug, abort, redirect, HTTPResponse
 from sentry_sdk import capture_message
+from sentry_sdk.consts import DEFAULT_MAX_VALUE_LENGTH
+from sentry_sdk.integrations.bottle import BottleIntegration
 from sentry_sdk.serializer import MAX_DATABAG_BREADTH
 
 from sentry_sdk.integrations.logging import LoggingIntegration
 from werkzeug.test import Client
-
-import sentry_sdk.integrations.bottle as bottle_sentry
+from werkzeug.wrappers import Response
 
 
 @pytest.fixture(scope="function")
@@ -44,7 +45,7 @@ def inner():
 
 
 def test_has_context(sentry_init, app, capture_events, get_client):
-    sentry_init(integrations=[bottle_sentry.BottleIntegration()])
+    sentry_init(integrations=[BottleIntegration()])
     events = capture_events()
 
     client = get_client()
@@ -75,11 +76,7 @@ def test_transaction_style(
     capture_events,
     get_client,
 ):
-    sentry_init(
-        integrations=[
-            bottle_sentry.BottleIntegration(transaction_style=transaction_style)
-        ]
-    )
+    sentry_init(integrations=[BottleIntegration(transaction_style=transaction_style)])
     events = capture_events()
 
     client = get_client()
@@ -98,7 +95,7 @@ def test_transaction_style(
 def test_errors(
     sentry_init, capture_exceptions, capture_events, app, debug, catchall, get_client
 ):
-    sentry_init(integrations=[bottle_sentry.BottleIntegration()])
+    sentry_init(integrations=[BottleIntegration()])
 
     app.catchall = catchall
     set_debug(mode=debug)
@@ -125,9 +122,9 @@ def index():
 
 
 def test_large_json_request(sentry_init, capture_events, app, get_client):
-    sentry_init(integrations=[bottle_sentry.BottleIntegration()])
+    sentry_init(integrations=[BottleIntegration()], max_request_body_size="always")
 
-    data = {"foo": {"bar": "a" * 2000}}
+    data = {"foo": {"bar": "a" * (DEFAULT_MAX_VALUE_LENGTH + 10)}}
 
     @app.route("/", method="POST")
     def index():
@@ -148,14 +145,19 @@ def index():
 
     (event,) = events
     assert event["_meta"]["request"]["data"]["foo"]["bar"] == {
-        "": {"len": 2000, "rem": [["!limit", "x", 1021, 1024]]}
+        "": {
+            "len": DEFAULT_MAX_VALUE_LENGTH + 10,
+            "rem": [
+                ["!limit", "x", DEFAULT_MAX_VALUE_LENGTH - 3, DEFAULT_MAX_VALUE_LENGTH]
+            ],
+        }
     }
-    assert len(event["request"]["data"]["foo"]["bar"]) == 1024
+    assert len(event["request"]["data"]["foo"]["bar"]) == DEFAULT_MAX_VALUE_LENGTH
 
 
 @pytest.mark.parametrize("data", [{}, []], ids=["empty-dict", "empty-list"])
 def test_empty_json_request(sentry_init, capture_events, app, data, get_client):
-    sentry_init(integrations=[bottle_sentry.BottleIntegration()])
+    sentry_init(integrations=[BottleIntegration()])
 
     @app.route("/", method="POST")
     def index():
@@ -178,9 +180,9 @@ def index():
 
 
 def test_medium_formdata_request(sentry_init, capture_events, app, get_client):
-    sentry_init(integrations=[bottle_sentry.BottleIntegration()])
+    sentry_init(integrations=[BottleIntegration()], max_request_body_size="always")
 
-    data = {"foo": "a" * 2000}
+    data = {"foo": "a" * (DEFAULT_MAX_VALUE_LENGTH + 10)}
 
     @app.route("/", method="POST")
     def index():
@@ -198,18 +200,21 @@ def index():
 
     (event,) = events
     assert event["_meta"]["request"]["data"]["foo"] == {
-        "": {"len": 2000, "rem": [["!limit", "x", 1021, 1024]]}
+        "": {
+            "len": DEFAULT_MAX_VALUE_LENGTH + 10,
+            "rem": [
+                ["!limit", "x", DEFAULT_MAX_VALUE_LENGTH - 3, DEFAULT_MAX_VALUE_LENGTH]
+            ],
+        }
     }
-    assert len(event["request"]["data"]["foo"]) == 1024
+    assert len(event["request"]["data"]["foo"]) == DEFAULT_MAX_VALUE_LENGTH
 
 
 @pytest.mark.parametrize("input_char", ["a", b"a"])
 def test_too_large_raw_request(
     sentry_init, input_char, capture_events, app, get_client
 ):
-    sentry_init(
-        integrations=[bottle_sentry.BottleIntegration()], max_request_body_size="small"
-    )
+    sentry_init(integrations=[BottleIntegration()], max_request_body_size="small")
 
     data = input_char * 2000
 
@@ -237,11 +242,12 @@ def index():
 
 
 def test_files_and_form(sentry_init, capture_events, app, get_client):
-    sentry_init(
-        integrations=[bottle_sentry.BottleIntegration()], max_request_body_size="always"
-    )
+    sentry_init(integrations=[BottleIntegration()], max_request_body_size="always")
 
-    data = {"foo": "a" * 2000, "file": (BytesIO(b"hello"), "hello.txt")}
+    data = {
+        "foo": "a" * (DEFAULT_MAX_VALUE_LENGTH + 10),
+        "file": (BytesIO(b"hello"), "hello.txt"),
+    }
 
     @app.route("/", method="POST")
     def index():
@@ -261,9 +267,14 @@ def index():
 
     (event,) = events
     assert event["_meta"]["request"]["data"]["foo"] == {
-        "": {"len": 2000, "rem": [["!limit", "x", 1021, 1024]]}
+        "": {
+            "len": DEFAULT_MAX_VALUE_LENGTH + 10,
+            "rem": [
+                ["!limit", "x", DEFAULT_MAX_VALUE_LENGTH - 3, DEFAULT_MAX_VALUE_LENGTH]
+            ],
+        }
     }
-    assert len(event["request"]["data"]["foo"]) == 1024
+    assert len(event["request"]["data"]["foo"]) == DEFAULT_MAX_VALUE_LENGTH
 
     assert event["_meta"]["request"]["data"]["file"] == {
         "": {
@@ -276,9 +287,7 @@ def index():
 def test_json_not_truncated_if_max_request_body_size_is_always(
     sentry_init, capture_events, app, get_client
 ):
-    sentry_init(
-        integrations=[bottle_sentry.BottleIntegration()], max_request_body_size="always"
-    )
+    sentry_init(integrations=[BottleIntegration()], max_request_body_size="always")
 
     data = {
         "key{}".format(i): "value{}".format(i) for i in range(MAX_DATABAG_BREADTH + 10)
@@ -307,8 +316,8 @@ def index():
 @pytest.mark.parametrize(
     "integrations",
     [
-        [bottle_sentry.BottleIntegration()],
-        [bottle_sentry.BottleIntegration(), LoggingIntegration(event_level="ERROR")],
+        [BottleIntegration()],
+        [BottleIntegration(), LoggingIntegration(event_level="ERROR")],
     ],
 )
 def test_errors_not_reported_twice(
@@ -322,46 +331,24 @@ def test_errors_not_reported_twice(
 
     @app.route("/")
     def index():
-        try:
-            1 / 0
-        except Exception as e:
-            logger.exception(e)
-            raise e
+        1 / 0
 
     events = capture_events()
 
     client = get_client()
+
     with pytest.raises(ZeroDivisionError):
-        client.get("/")
+        try:
+            client.get("/")
+        except ZeroDivisionError as e:
+            logger.exception(e)
+            raise e
 
     assert len(events) == 1
 
 
-def test_logging(sentry_init, capture_events, app, get_client):
-    # ensure that Bottle's logger magic doesn't break ours
-    sentry_init(
-        integrations=[
-            bottle_sentry.BottleIntegration(),
-            LoggingIntegration(event_level="ERROR"),
-        ]
-    )
-
-    @app.route("/")
-    def index():
-        app.logger.error("hi")
-        return "ok"
-
-    events = capture_events()
-
-    client = get_client()
-    client.get("/")
-
-    (event,) = events
-    assert event["level"] == "error"
-
-
 def test_mount(app, capture_exceptions, capture_events, sentry_init, get_client):
-    sentry_init(integrations=[bottle_sentry.BottleIntegration()])
+    sentry_init(integrations=[BottleIntegration()])
 
     app.catchall = False
 
@@ -387,33 +374,8 @@ def crashing_app(environ, start_response):
     assert event["exception"]["values"][0]["mechanism"]["handled"] is False
 
 
-def test_500(sentry_init, capture_events, app, get_client):
-    sentry_init(integrations=[bottle_sentry.BottleIntegration()])
-
-    set_debug(False)
-    app.catchall = True
-
-    @app.route("/")
-    def index():
-        1 / 0
-
-    @app.error(500)
-    def error_handler(err):
-        capture_message("error_msg")
-        return "My error"
-
-    events = capture_events()
-
-    client = get_client()
-    response = client.get("/")
-    assert response[1] == "500 Internal Server Error"
-
-    _, event = events
-    assert event["message"] == "error_msg"
-
-
 def test_error_in_errorhandler(sentry_init, capture_events, app, get_client):
-    sentry_init(integrations=[bottle_sentry.BottleIntegration()])
+    sentry_init(integrations=[BottleIntegration()])
 
     set_debug(False)
     app.catchall = True
@@ -443,7 +405,7 @@ def error_handler(err):
 
 
 def test_bad_request_not_captured(sentry_init, capture_events, app, get_client):
-    sentry_init(integrations=[bottle_sentry.BottleIntegration()])
+    sentry_init(integrations=[BottleIntegration()])
     events = capture_events()
 
     @app.route("/")
@@ -458,7 +420,7 @@ def index():
 
 
 def test_no_exception_on_redirect(sentry_init, capture_events, app, get_client):
-    sentry_init(integrations=[bottle_sentry.BottleIntegration()])
+    sentry_init(integrations=[BottleIntegration()])
     events = capture_events()
 
     @app.route("/")
@@ -474,3 +436,99 @@ def here():
     client.get("/")
 
     assert not events
+
+
+def test_span_origin(
+    sentry_init,
+    get_client,
+    capture_events,
+):
+    sentry_init(
+        integrations=[BottleIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    client = get_client()
+    client.get("/message")
+
+    (_, event) = events
+
+    assert event["contexts"]["trace"]["origin"] == "auto.http.bottle"
+
+
+@pytest.mark.parametrize("raise_error", [True, False])
+@pytest.mark.parametrize(
+    ("integration_kwargs", "status_code", "should_capture"),
+    (
+        ({}, None, False),
+        ({}, 400, False),
+        ({}, 451, False),  # Highest 4xx status code
+        ({}, 500, True),
+        ({}, 511, True),  # Highest 5xx status code
+        ({"failed_request_status_codes": set()}, 500, False),
+        ({"failed_request_status_codes": set()}, 511, False),
+        ({"failed_request_status_codes": {404, *range(500, 600)}}, 404, True),
+        ({"failed_request_status_codes": {404, *range(500, 600)}}, 500, True),
+        ({"failed_request_status_codes": {404, *range(500, 600)}}, 400, False),
+    ),
+)
+def test_failed_request_status_codes(
+    sentry_init,
+    capture_events,
+    integration_kwargs,
+    status_code,
+    should_capture,
+    raise_error,
+):
+    sentry_init(integrations=[BottleIntegration(**integration_kwargs)])
+    events = capture_events()
+
+    app = Bottle()
+
+    @app.route("/")
+    def handle():
+        if status_code is not None:
+            response = HTTPResponse(status=status_code)
+            if raise_error:
+                raise response
+            else:
+                return response
+        return "OK"
+
+    client = Client(app, Response)
+    response = client.get("/")
+
+    expected_status = 200 if status_code is None else status_code
+    assert response.status_code == expected_status
+
+    if should_capture:
+        (event,) = events
+        assert event["exception"]["values"][0]["type"] == "HTTPResponse"
+    else:
+        assert not events
+
+
+def test_failed_request_status_codes_non_http_exception(sentry_init, capture_events):
+    """
+    If an exception, which is not an instance of HTTPResponse, is raised, it should be captured, even if
+    failed_request_status_codes is empty.
+    """
+    sentry_init(integrations=[BottleIntegration(failed_request_status_codes=set())])
+    events = capture_events()
+
+    app = Bottle()
+
+    @app.route("/")
+    def handle():
+        1 / 0
+
+    client = Client(app, Response)
+
+    try:
+        client.get("/")
+    except ZeroDivisionError:
+        pass
+
+    (event,) = events
+    assert event["exception"]["values"][0]["type"] == "ZeroDivisionError"
diff --git a/tests/integrations/celery/integration_tests/__init__.py b/tests/integrations/celery/integration_tests/__init__.py
new file mode 100644
index 0000000000..2dfe2ddcf7
--- /dev/null
+++ b/tests/integrations/celery/integration_tests/__init__.py
@@ -0,0 +1,58 @@
+import os
+import signal
+import tempfile
+import threading
+import time
+
+from celery.beat import Scheduler
+
+from sentry_sdk.utils import logger
+
+
+class ImmediateScheduler(Scheduler):
+    """
+    A custom scheduler that starts tasks immediately after starting Celery beat.
+    """
+
+    def setup_schedule(self):
+        super().setup_schedule()
+        for _, entry in self.schedule.items():
+            self.apply_entry(entry)
+
+    def tick(self):
+        # Override tick to prevent the normal schedule cycle
+        return 1
+
+
+def kill_beat(beat_pid_file, delay_seconds=1):
+    """
+    Terminates Celery Beat after the given `delay_seconds`.
+    """
+    logger.info("Starting Celery Beat killer...")
+    time.sleep(delay_seconds)
+    pid = int(open(beat_pid_file, "r").read())
+    logger.info("Terminating Celery Beat...")
+    os.kill(pid, signal.SIGTERM)
+
+
+def run_beat(celery_app, runtime_seconds=1, loglevel="warning", quiet=True):
+    """
+    Run Celery Beat that immediately starts tasks.
+    The Celery Beat instance is automatically terminated after `runtime_seconds`.
+    """
+    logger.info("Starting Celery Beat...")
+    pid_file = os.path.join(tempfile.mkdtemp(), f"celery-beat-{os.getpid()}.pid")
+
+    t = threading.Thread(
+        target=kill_beat,
+        args=(pid_file,),
+        kwargs={"delay_seconds": runtime_seconds},
+    )
+    t.start()
+
+    beat_instance = celery_app.Beat(
+        loglevel=loglevel,
+        quiet=quiet,
+        pidfile=pid_file,
+    )
+    beat_instance.run()
diff --git a/tests/integrations/celery/integration_tests/test_celery_beat_cron_monitoring.py b/tests/integrations/celery/integration_tests/test_celery_beat_cron_monitoring.py
new file mode 100644
index 0000000000..e7d8197439
--- /dev/null
+++ b/tests/integrations/celery/integration_tests/test_celery_beat_cron_monitoring.py
@@ -0,0 +1,157 @@
+import os
+import sys
+import pytest
+
+from celery.contrib.testing.worker import start_worker
+
+from sentry_sdk.utils import logger
+
+from tests.integrations.celery.integration_tests import run_beat
+
+
+REDIS_SERVER = "redis://127.0.0.1:6379"
+REDIS_DB = 15
+
+
+@pytest.fixture()
+def celery_config():
+    return {
+        "worker_concurrency": 1,
+        "broker_url": f"{REDIS_SERVER}/{REDIS_DB}",
+        "result_backend": f"{REDIS_SERVER}/{REDIS_DB}",
+        "beat_scheduler": "tests.integrations.celery.integration_tests:ImmediateScheduler",
+        "task_always_eager": False,
+        "task_create_missing_queues": True,
+        "task_default_queue": f"queue_{os.getpid()}",
+    }
+
+
+@pytest.fixture
+def celery_init(sentry_init, celery_config):
+    """
+    Create a Sentry instrumented Celery app.
+    """
+    from celery import Celery
+
+    from sentry_sdk.integrations.celery import CeleryIntegration
+
+    def inner(propagate_traces=True, monitor_beat_tasks=False, **kwargs):
+        sentry_init(
+            integrations=[
+                CeleryIntegration(
+                    propagate_traces=propagate_traces,
+                    monitor_beat_tasks=monitor_beat_tasks,
+                )
+            ],
+            **kwargs,
+        )
+        app = Celery("tasks")
+        app.conf.update(celery_config)
+
+        return app
+
+    return inner
+
+
+@pytest.mark.skipif(sys.version_info < (3, 7), reason="Requires Python 3.7+")
+@pytest.mark.forked
+def test_explanation(celery_init, capture_envelopes):
+    """
+    This is a dummy test for explaining how to test using Celery Beat
+    """
+
+    # First initialize a Celery app.
+    # You can give the options of CeleryIntegrations
+    # and the options for `sentry_dks.init` as keyword arguments.
+    # See the celery_init fixture for details.
+    app = celery_init(
+        monitor_beat_tasks=True,
+    )
+
+    # Capture envelopes.
+    envelopes = capture_envelopes()
+
+    # Define the task you want to run
+    @app.task
+    def test_task():
+        logger.info("Running test_task")
+
+    # Add the task to the beat schedule
+    app.add_periodic_task(60.0, test_task.s(), name="success_from_beat")
+
+    # Start a Celery worker
+    with start_worker(app, perform_ping_check=False):
+        # And start a Celery Beat instance
+        # This Celery Beat will start the task above immediately
+        # after start for the first time
+        # By default Celery Beat is terminated after 1 second.
+        # See `run_beat` function on how to change this.
+        run_beat(app)
+
+    # After the Celery Beat is terminated, you can check the envelopes
+    assert len(envelopes) >= 0
+
+
+@pytest.mark.skipif(sys.version_info < (3, 7), reason="Requires Python 3.7+")
+@pytest.mark.forked
+def test_beat_task_crons_success(celery_init, capture_envelopes):
+    app = celery_init(
+        monitor_beat_tasks=True,
+    )
+    envelopes = capture_envelopes()
+
+    @app.task
+    def test_task():
+        logger.info("Running test_task")
+
+    app.add_periodic_task(60.0, test_task.s(), name="success_from_beat")
+
+    with start_worker(app, perform_ping_check=False):
+        run_beat(app)
+
+    assert len(envelopes) == 2
+    (envelop_in_progress, envelope_ok) = envelopes
+
+    assert envelop_in_progress.items[0].headers["type"] == "check_in"
+    check_in = envelop_in_progress.items[0].payload.json
+    assert check_in["type"] == "check_in"
+    assert check_in["monitor_slug"] == "success_from_beat"
+    assert check_in["status"] == "in_progress"
+
+    assert envelope_ok.items[0].headers["type"] == "check_in"
+    check_in = envelope_ok.items[0].payload.json
+    assert check_in["type"] == "check_in"
+    assert check_in["monitor_slug"] == "success_from_beat"
+    assert check_in["status"] == "ok"
+
+
+@pytest.mark.skipif(sys.version_info < (3, 7), reason="Requires Python 3.7+")
+@pytest.mark.forked
+def test_beat_task_crons_error(celery_init, capture_envelopes):
+    app = celery_init(
+        monitor_beat_tasks=True,
+    )
+    envelopes = capture_envelopes()
+
+    @app.task
+    def test_task():
+        logger.info("Running test_task")
+        1 / 0
+
+    app.add_periodic_task(60.0, test_task.s(), name="failure_from_beat")
+
+    with start_worker(app, perform_ping_check=False):
+        run_beat(app)
+
+    envelop_in_progress = envelopes[0]
+    envelope_error = envelopes[-1]
+
+    check_in = envelop_in_progress.items[0].payload.json
+    assert check_in["type"] == "check_in"
+    assert check_in["monitor_slug"] == "failure_from_beat"
+    assert check_in["status"] == "in_progress"
+
+    check_in = envelope_error.items[0].payload.json
+    assert check_in["type"] == "check_in"
+    assert check_in["monitor_slug"] == "failure_from_beat"
+    assert check_in["status"] == "error"
diff --git a/tests/integrations/celery/test_celery.py b/tests/integrations/celery/test_celery.py
index ec5574b513..b8fc2bb3e8 100644
--- a/tests/integrations/celery/test_celery.py
+++ b/tests/integrations/celery/test_celery.py
@@ -1,19 +1,19 @@
 import threading
+import kombu
+from unittest import mock
 
 import pytest
-
-from sentry_sdk import Hub, configure_scope, start_transaction, get_current_span
-from sentry_sdk.integrations.celery import CeleryIntegration, _get_headers
-
-from sentry_sdk._compat import text_type
-
 from celery import Celery, VERSION
 from celery.bin import worker
 
-try:
-    from unittest import mock  # python 3.3 and above
-except ImportError:
-    import mock  # python < 3.3
+import sentry_sdk
+from sentry_sdk import start_transaction, get_current_span
+from sentry_sdk.integrations.celery import (
+    CeleryIntegration,
+    _wrap_task_run,
+)
+from sentry_sdk.integrations.celery.beat import _get_headers
+from tests.conftest import ApproxDict
 
 
 @pytest.fixture
@@ -27,10 +27,20 @@ def inner(signal, f):
 
 @pytest.fixture
 def init_celery(sentry_init, request):
-    def inner(propagate_traces=True, backend="always_eager", **kwargs):
+    def inner(
+        propagate_traces=True,
+        backend="always_eager",
+        monitor_beat_tasks=False,
+        **kwargs,
+    ):
         sentry_init(
-            integrations=[CeleryIntegration(propagate_traces=propagate_traces)],
-            **kwargs
+            integrations=[
+                CeleryIntegration(
+                    propagate_traces=propagate_traces,
+                    monitor_beat_tasks=monitor_beat_tasks,
+                )
+            ],
+            **kwargs,
         )
         celery = Celery(__name__)
 
@@ -51,9 +61,6 @@ def inner(propagate_traces=True, backend="always_eager", **kwargs):
             celery.conf.result_backend = "redis://127.0.0.1:6379"
             celery.conf.task_always_eager = False
 
-            Hub.main.bind_client(Hub.current.client)
-            request.addfinalizer(lambda: Hub.main.bind_client(None))
-
             # Once we drop celery 3 we can use the celery_worker fixture
             if VERSION < (5,):
                 worker_fn = worker.worker(app=celery).run
@@ -148,30 +155,31 @@ def dummy_task(x, y):
         foo = 42  # noqa
         return x / y
 
-    with configure_scope() as scope:
-        celery_invocation(dummy_task, 1, 2)
-        _, expected_context = celery_invocation(dummy_task, 1, 0)
+    scope = sentry_sdk.get_isolation_scope()
 
-        (error_event,) = events
+    celery_invocation(dummy_task, 1, 2)
+    _, expected_context = celery_invocation(dummy_task, 1, 0)
 
-        assert (
-            error_event["contexts"]["trace"]["trace_id"]
-            == scope._propagation_context["trace_id"]
-        )
-        assert (
-            error_event["contexts"]["trace"]["span_id"]
-            != scope._propagation_context["span_id"]
-        )
-        assert error_event["transaction"] == "dummy_task"
-        assert "celery_task_id" in error_event["tags"]
-        assert error_event["extra"]["celery-job"] == dict(
-            task_name="dummy_task", **expected_context
-        )
+    (error_event,) = events
 
-        (exception,) = error_event["exception"]["values"]
-        assert exception["type"] == "ZeroDivisionError"
-        assert exception["mechanism"]["type"] == "celery"
-        assert exception["stacktrace"]["frames"][0]["vars"]["foo"] == "42"
+    assert (
+        error_event["contexts"]["trace"]["trace_id"]
+        == scope._propagation_context.trace_id
+    )
+    assert (
+        error_event["contexts"]["trace"]["span_id"]
+        != scope._propagation_context.span_id
+    )
+    assert error_event["transaction"] == "dummy_task"
+    assert "celery_task_id" in error_event["tags"]
+    assert error_event["extra"]["celery-job"] == dict(
+        task_name="dummy_task", **expected_context
+    )
+
+    (exception,) = error_event["exception"]["values"]
+    assert exception["type"] == "ZeroDivisionError"
+    assert exception["mechanism"]["type"] == "celery"
+    assert exception["stacktrace"]["frames"][0]["vars"]["foo"] == "42"
 
 
 @pytest.mark.parametrize("task_fails", [True, False], ids=["error", "success"])
@@ -211,44 +219,61 @@ def dummy_task(x, y):
     else:
         assert execution_event["contexts"]["trace"]["status"] == "ok"
 
-    assert execution_event["spans"] == []
+    assert len(execution_event["spans"]) == 1
+    assert (
+        execution_event["spans"][0].items()
+        >= {
+            "trace_id": str(transaction.trace_id),
+            "same_process_as_parent": True,
+            "op": "queue.process",
+            "description": "dummy_task",
+            "data": ApproxDict(),
+        }.items()
+    )
     assert submission_event["spans"] == [
         {
+            "data": ApproxDict(),
             "description": "dummy_task",
             "op": "queue.submit.celery",
+            "origin": "auto.queue.celery",
             "parent_span_id": submission_event["contexts"]["trace"]["span_id"],
             "same_process_as_parent": True,
             "span_id": submission_event["spans"][0]["span_id"],
             "start_timestamp": submission_event["spans"][0]["start_timestamp"],
             "timestamp": submission_event["spans"][0]["timestamp"],
-            "trace_id": text_type(transaction.trace_id),
+            "trace_id": str(transaction.trace_id),
         }
     ]
 
 
-def test_no_stackoverflows(celery):
-    """We used to have a bug in the Celery integration where its monkeypatching
+def test_no_double_patching(celery):
+    """Ensure that Celery tasks are only patched once to prevent stack overflows.
+
+    We used to have a bug in the Celery integration where its monkeypatching
     was repeated for every task invocation, leading to stackoverflows.
 
     See https://github.com/getsentry/sentry-python/issues/265
     """
 
-    results = []
-
     @celery.task(name="dummy_task")
     def dummy_task():
-        with configure_scope() as scope:
-            scope.set_tag("foo", "bar")
+        return 42
 
-        results.append(42)
+    # Initially, the task should not be marked as patched
+    assert not hasattr(dummy_task, "_sentry_is_patched")
 
-    for _ in range(10000):
-        dummy_task.delay()
+    # First invocation should trigger patching
+    result1 = dummy_task.delay()
+    assert result1.get() == 42
+    assert getattr(dummy_task, "_sentry_is_patched", False) is True
 
-    assert results == [42] * 10000
+    patched_run = dummy_task.run
 
-    with configure_scope() as scope:
-        assert not scope._tags
+    # Second invocation should not re-patch
+    result2 = dummy_task.delay()
+    assert result2.get() == 42
+    assert dummy_task.run is patched_run
+    assert getattr(dummy_task, "_sentry_is_patched", False) is True
 
 
 def test_simple_no_propagation(capture_events, init_celery):
@@ -281,42 +306,6 @@ def dummy_task(x, y):
     assert not events
 
 
-def test_broken_prerun(init_celery, connect_signal):
-    from celery.signals import task_prerun
-
-    stack_lengths = []
-
-    def crash(*args, **kwargs):
-        # scope should exist in prerun
-        stack_lengths.append(len(Hub.current._stack))
-        1 / 0
-
-    # Order here is important to reproduce the bug: In Celery 3, a crashing
-    # prerun would prevent other preruns from running.
-
-    connect_signal(task_prerun, crash)
-    celery = init_celery()
-
-    assert len(Hub.current._stack) == 1
-
-    @celery.task(name="dummy_task")
-    def dummy_task(x, y):
-        stack_lengths.append(len(Hub.current._stack))
-        return x / y
-
-    if VERSION >= (4,):
-        dummy_task.delay(2, 2)
-    else:
-        with pytest.raises(ZeroDivisionError):
-            dummy_task.delay(2, 2)
-
-    assert len(Hub.current._stack) == 1
-    if VERSION < (4,):
-        assert stack_lengths == [2]
-    else:
-        assert stack_lengths == [2, 2]
-
-
 @pytest.mark.xfail(
     (4, 2, 0) <= VERSION < (4, 4, 3),
     strict=True,
@@ -354,11 +343,12 @@ def dummy_task(self):
         assert e["type"] == "ZeroDivisionError"
 
 
-# TODO: This test is hanging when running test with `tox --parallel auto`. Find out why and fix it!
-@pytest.mark.skip
+@pytest.mark.skip(
+    reason="This test is hanging when running test with `tox --parallel auto`. TODO: Figure out why and fix it!"
+)
 @pytest.mark.forked
 def test_redis_backend_trace_propagation(init_celery, capture_events_forksafe):
-    celery = init_celery(traces_sample_rate=1.0, backend="redis", debug=True)
+    celery = init_celery(traces_sample_rate=1.0, backend="redis")
 
     events = capture_events_forksafe()
 
@@ -384,9 +374,9 @@ def dummy_task(self):
     assert submit_transaction["type"] == "transaction"
     assert submit_transaction["transaction"] == "submit_celery"
 
-    assert len(
-        submit_transaction["spans"]
-    ), 4  # Because redis integration was auto enabled
+    assert len(submit_transaction["spans"]), (
+        4
+    )  # Because redis integration was auto enabled
     span = submit_transaction["spans"][0]
     assert span["op"] == "queue.submit.celery"
     assert span["description"] == "dummy_task"
@@ -412,11 +402,24 @@ def dummy_task(self):
 @pytest.mark.parametrize("newrelic_order", ["sentry_first", "sentry_last"])
 def test_newrelic_interference(init_celery, newrelic_order, celery_invocation):
     def instrument_newrelic():
-        import celery.app.trace as celery_mod
-        from newrelic.hooks.application_celery import instrument_celery_execute_trace
+        try:
+            # older newrelic versions
+            from newrelic.hooks.application_celery import (
+                instrument_celery_execute_trace,
+            )
+            import celery.app.trace as celery_trace_module
+
+            assert hasattr(celery_trace_module, "build_tracer")
+            instrument_celery_execute_trace(celery_trace_module)
 
-        assert hasattr(celery_mod, "build_tracer")
-        instrument_celery_execute_trace(celery_mod)
+        except ImportError:
+            # newer newrelic versions
+            from newrelic.hooks.application_celery import instrument_celery_app_base
+            import celery.app as celery_app_module
+
+            assert hasattr(celery_app_module, "Celery")
+            assert hasattr(celery_app_module.Celery, "send_task")
+            instrument_celery_app_base(celery_app_module)
 
     if newrelic_order == "sentry_first":
         celery = init_celery()
@@ -436,7 +439,9 @@ def dummy_task(self, x, y):
 
 
 def test_traces_sampler_gets_task_info_in_sampling_context(
-    init_celery, celery_invocation, DictionaryContaining  # noqa:N803
+    init_celery,
+    celery_invocation,
+    DictionaryContaining,  # noqa:N803
 ):
     traces_sampler = mock.Mock()
     celery = init_celery(traces_sampler=traces_sampler)
@@ -498,7 +503,14 @@ def dummy_task(self, x, y):
     # in the monkey patched version of `apply_async`
     # in `sentry_sdk/integrations/celery.py::_wrap_apply_async()`
     result = dummy_task.apply_async(args=(1, 0), headers=sentry_crons_setup)
-    assert result.get() == sentry_crons_setup
+
+    expected_headers = sentry_crons_setup.copy()
+    # Newly added headers
+    expected_headers["sentry-trace"] = mock.ANY
+    expected_headers["baggage"] = mock.ANY
+    expected_headers["sentry-task-enqueued-time"] = mock.ANY
+
+    assert result.get() == expected_headers
 
 
 def test_baggage_propagation(init_celery):
@@ -508,22 +520,25 @@ def test_baggage_propagation(init_celery):
     def dummy_task(self, x, y):
         return _get_headers(self)
 
-    with start_transaction() as transaction:
-        result = dummy_task.apply_async(
-            args=(1, 0),
-            headers={"baggage": "custom=value"},
-        ).get()
-
-        assert sorted(result["baggage"].split(",")) == sorted(
-            [
-                "sentry-release=abcdef",
-                "sentry-trace_id={}".format(transaction.trace_id),
-                "sentry-environment=production",
-                "sentry-sample_rate=1.0",
-                "sentry-sampled=true",
-                "custom=value",
-            ]
-        )
+    # patch random.randrange to return a predictable sample_rand value
+    with mock.patch("sentry_sdk.tracing_utils.Random.randrange", return_value=500000):
+        with start_transaction() as transaction:
+            result = dummy_task.apply_async(
+                args=(1, 0),
+                headers={"baggage": "custom=value"},
+            ).get()
+
+            assert sorted(result["baggage"].split(",")) == sorted(
+                [
+                    "sentry-release=abcdef",
+                    "sentry-trace_id={}".format(transaction.trace_id),
+                    "sentry-environment=production",
+                    "sentry-sample_rand=0.500000",
+                    "sentry-sample_rate=1.0",
+                    "sentry-sampled=true",
+                    "custom=value",
+                ]
+            )
 
 
 def test_sentry_propagate_traces_override(init_celery):
@@ -555,3 +570,286 @@ def dummy_task(self, message):
             headers={"sentry-propagate-traces": False},
         ).get()
         assert transaction_trace_id != task_transaction_id
+
+
+def test_apply_async_manually_span(sentry_init):
+    sentry_init(
+        integrations=[CeleryIntegration()],
+    )
+
+    def dummy_function(*args, **kwargs):
+        headers = kwargs.get("headers")
+        assert "sentry-trace" in headers
+        assert "baggage" in headers
+
+    wrapped = _wrap_task_run(dummy_function)
+    wrapped(mock.MagicMock(), (), headers={})
+
+
+def test_apply_async_no_args(init_celery):
+    celery = init_celery()
+
+    @celery.task
+    def example_task():
+        return "success"
+
+    try:
+        result = example_task.apply_async(None, {})
+    except TypeError:
+        pytest.fail("Calling `apply_async` without arguments raised a TypeError")
+
+    assert result.get() == "success"
+
+
+@pytest.mark.parametrize("routing_key", ("celery", "custom"))
+@mock.patch("celery.app.task.Task.request")
+def test_messaging_destination_name_default_exchange(
+    mock_request, routing_key, init_celery, capture_events
+):
+    celery_app = init_celery(enable_tracing=True)
+    events = capture_events()
+    mock_request.delivery_info = {"routing_key": routing_key, "exchange": ""}
+
+    @celery_app.task()
+    def task(): ...
+
+    task.apply_async()
+
+    (event,) = events
+    (span,) = event["spans"]
+    assert span["data"]["messaging.destination.name"] == routing_key
+
+
+@mock.patch("celery.app.task.Task.request")
+def test_messaging_destination_name_nondefault_exchange(
+    mock_request, init_celery, capture_events
+):
+    """
+    Currently, we only capture the routing key as the messaging.destination.name when
+    we are using the default exchange (""). This is because the default exchange ensures
+    that the routing key is the queue name. Other exchanges may not guarantee this
+    behavior.
+    """
+    celery_app = init_celery(enable_tracing=True)
+    events = capture_events()
+    mock_request.delivery_info = {"routing_key": "celery", "exchange": "custom"}
+
+    @celery_app.task()
+    def task(): ...
+
+    task.apply_async()
+
+    (event,) = events
+    (span,) = event["spans"]
+    assert "messaging.destination.name" not in span["data"]
+
+
+def test_messaging_id(init_celery, capture_events):
+    celery = init_celery(enable_tracing=True)
+    events = capture_events()
+
+    @celery.task
+    def example_task(): ...
+
+    example_task.apply_async()
+
+    (event,) = events
+    (span,) = event["spans"]
+    assert "messaging.message.id" in span["data"]
+
+
+def test_retry_count_zero(init_celery, capture_events):
+    celery = init_celery(enable_tracing=True)
+    events = capture_events()
+
+    @celery.task()
+    def task(): ...
+
+    task.apply_async()
+
+    (event,) = events
+    (span,) = event["spans"]
+    assert span["data"]["messaging.message.retry.count"] == 0
+
+
+@mock.patch("celery.app.task.Task.request")
+def test_retry_count_nonzero(mock_request, init_celery, capture_events):
+    mock_request.retries = 3
+
+    celery = init_celery(enable_tracing=True)
+    events = capture_events()
+
+    @celery.task()
+    def task(): ...
+
+    task.apply_async()
+
+    (event,) = events
+    (span,) = event["spans"]
+    assert span["data"]["messaging.message.retry.count"] == 3
+
+
+@pytest.mark.parametrize("system", ("redis", "amqp"))
+def test_messaging_system(system, init_celery, capture_events):
+    celery = init_celery(enable_tracing=True)
+    events = capture_events()
+
+    # Does not need to be a real URL, since we use always eager
+    celery.conf.broker_url = f"{system}://example.com"  # noqa: E231
+
+    @celery.task()
+    def task(): ...
+
+    task.apply_async()
+
+    (event,) = events
+    (span,) = event["spans"]
+    assert span["data"]["messaging.system"] == system
+
+
+@pytest.mark.parametrize("system", ("amqp", "redis"))
+def test_producer_span_data(system, monkeypatch, sentry_init, capture_events):
+    old_publish = kombu.messaging.Producer._publish
+
+    def publish(*args, **kwargs):
+        pass
+
+    monkeypatch.setattr(kombu.messaging.Producer, "_publish", publish)
+
+    sentry_init(integrations=[CeleryIntegration()], enable_tracing=True)
+    celery = Celery(__name__, broker=f"{system}://example.com")  # noqa: E231
+    events = capture_events()
+
+    @celery.task()
+    def task(): ...
+
+    with start_transaction():
+        task.apply_async()
+
+    (event,) = events
+    span = next(span for span in event["spans"] if span["op"] == "queue.publish")
+
+    assert span["data"]["messaging.system"] == system
+
+    assert span["data"]["messaging.destination.name"] == "celery"
+    assert "messaging.message.id" in span["data"]
+    assert span["data"]["messaging.message.retry.count"] == 0
+
+    monkeypatch.setattr(kombu.messaging.Producer, "_publish", old_publish)
+
+
+def test_receive_latency(init_celery, capture_events):
+    celery = init_celery(traces_sample_rate=1.0)
+    events = capture_events()
+
+    @celery.task()
+    def task(): ...
+
+    task.apply_async()
+
+    (event,) = events
+    (span,) = event["spans"]
+    assert "messaging.message.receive.latency" in span["data"]
+    assert span["data"]["messaging.message.receive.latency"] > 0
+
+
+def tests_span_origin_consumer(init_celery, capture_events):
+    celery = init_celery(enable_tracing=True)
+    celery.conf.broker_url = "redis://example.com"  # noqa: E231
+
+    events = capture_events()
+
+    @celery.task()
+    def task(): ...
+
+    task.apply_async()
+
+    (event,) = events
+
+    assert event["contexts"]["trace"]["origin"] == "auto.queue.celery"
+    assert event["spans"][0]["origin"] == "auto.queue.celery"
+
+
+def tests_span_origin_producer(monkeypatch, sentry_init, capture_events):
+    old_publish = kombu.messaging.Producer._publish
+
+    def publish(*args, **kwargs):
+        pass
+
+    monkeypatch.setattr(kombu.messaging.Producer, "_publish", publish)
+
+    sentry_init(integrations=[CeleryIntegration()], enable_tracing=True)
+    celery = Celery(__name__, broker="redis://example.com")  # noqa: E231
+
+    events = capture_events()
+
+    @celery.task()
+    def task(): ...
+
+    with start_transaction(name="custom_transaction"):
+        task.apply_async()
+
+    (event,) = events
+
+    assert event["contexts"]["trace"]["origin"] == "manual"
+
+    for span in event["spans"]:
+        assert span["origin"] == "auto.queue.celery"
+
+    monkeypatch.setattr(kombu.messaging.Producer, "_publish", old_publish)
+
+
+@pytest.mark.forked
+@mock.patch("celery.Celery.send_task")
+def test_send_task_wrapped(
+    patched_send_task,
+    sentry_init,
+    capture_events,
+    reset_integrations,
+):
+    sentry_init(integrations=[CeleryIntegration()], enable_tracing=True)
+    celery = Celery(__name__, broker="redis://example.com")  # noqa: E231
+
+    events = capture_events()
+
+    with sentry_sdk.start_transaction(name="custom_transaction"):
+        celery.send_task("very_creative_task_name", args=(1, 2), kwargs={"foo": "bar"})
+
+    (call,) = patched_send_task.call_args_list  # We should have exactly one call
+    (args, kwargs) = call
+
+    assert args == (celery, "very_creative_task_name")
+    assert kwargs["args"] == (1, 2)
+    assert kwargs["kwargs"] == {"foo": "bar"}
+    assert set(kwargs["headers"].keys()) == {
+        "sentry-task-enqueued-time",
+        "sentry-trace",
+        "baggage",
+        "headers",
+    }
+    assert set(kwargs["headers"]["headers"].keys()) == {
+        "sentry-trace",
+        "baggage",
+        "sentry-task-enqueued-time",
+    }
+    assert (
+        kwargs["headers"]["sentry-trace"]
+        == kwargs["headers"]["headers"]["sentry-trace"]
+    )
+
+    (event,) = events  # We should have exactly one event (the transaction)
+    assert event["type"] == "transaction"
+    assert event["transaction"] == "custom_transaction"
+
+    (span,) = event["spans"]  # We should have exactly one span
+    assert span["description"] == "very_creative_task_name"
+    assert span["op"] == "queue.submit.celery"
+    assert span["trace_id"] == kwargs["headers"]["sentry-trace"].split("-")[0]
+
+
+@pytest.mark.skip(reason="placeholder so that forked test does not come last")
+def test_placeholder():
+    """Forked tests must not come last in the module.
+    See https://github.com/pytest-dev/pytest-forked/issues/67#issuecomment-1964718720.
+    """
+    pass
diff --git a/tests/integrations/celery/test_celery_beat_crons.py b/tests/integrations/celery/test_celery_beat_crons.py
index e42ccdbdee..17b4a5e73d 100644
--- a/tests/integrations/celery/test_celery_beat_crons.py
+++ b/tests/integrations/celery/test_celery_beat_crons.py
@@ -1,23 +1,21 @@
+import datetime
+from unittest import mock
+from unittest.mock import MagicMock
+
 import pytest
+from celery.schedules import crontab, schedule
 
-from sentry_sdk.integrations.celery import (
+from sentry_sdk.crons import MonitorStatus
+from sentry_sdk.integrations.celery.beat import (
     _get_headers,
-    _get_humanized_interval,
     _get_monitor_config,
     _patch_beat_apply_entry,
-    crons_task_success,
+    _patch_redbeat_apply_async,
     crons_task_failure,
     crons_task_retry,
+    crons_task_success,
 )
-from sentry_sdk.crons import MonitorStatus
-from celery.schedules import crontab, schedule
-
-try:
-    from unittest import mock  # python 3.3 and above
-    from unittest.mock import MagicMock
-except ImportError:
-    import mock  # python < 3.3
-    from mock import MagicMock
+from sentry_sdk.integrations.celery.utils import _get_humanized_interval
 
 
 def test_get_headers():
@@ -93,10 +91,10 @@ def test_crons_task_success():
     }
 
     with mock.patch(
-        "sentry_sdk.integrations.celery.capture_checkin"
+        "sentry_sdk.integrations.celery.beat.capture_checkin"
     ) as mock_capture_checkin:
         with mock.patch(
-            "sentry_sdk.integrations.celery._now_seconds_since_epoch",
+            "sentry_sdk.integrations.celery.beat._now_seconds_since_epoch",
             return_value=500.5,
         ):
             crons_task_success(fake_task)
@@ -137,10 +135,10 @@ def test_crons_task_failure():
     }
 
     with mock.patch(
-        "sentry_sdk.integrations.celery.capture_checkin"
+        "sentry_sdk.integrations.celery.beat.capture_checkin"
     ) as mock_capture_checkin:
         with mock.patch(
-            "sentry_sdk.integrations.celery._now_seconds_since_epoch",
+            "sentry_sdk.integrations.celery.beat._now_seconds_since_epoch",
             return_value=500.5,
         ):
             crons_task_failure(fake_task)
@@ -181,10 +179,10 @@ def test_crons_task_retry():
     }
 
     with mock.patch(
-        "sentry_sdk.integrations.celery.capture_checkin"
+        "sentry_sdk.integrations.celery.beat.capture_checkin"
     ) as mock_capture_checkin:
         with mock.patch(
-            "sentry_sdk.integrations.celery._now_seconds_since_epoch",
+            "sentry_sdk.integrations.celery.beat._now_seconds_since_epoch",
             return_value=500.5,
         ):
             crons_task_retry(fake_task)
@@ -207,31 +205,69 @@ def test_crons_task_retry():
 
 def test_get_monitor_config_crontab():
     app = MagicMock()
-    app.conf = MagicMock()
-    app.conf.timezone = "Europe/Vienna"
+    app.timezone = "Europe/Vienna"
 
+    # schedule with the default timezone
     celery_schedule = crontab(day_of_month="3", hour="12", minute="*/10")
+
     monitor_config = _get_monitor_config(celery_schedule, app, "foo")
     assert monitor_config == {
         "schedule": {
             "type": "crontab",
             "value": "*/10 12 3 * *",
         },
-        "timezone": "Europe/Vienna",
+        "timezone": "UTC",  # the default because `crontab` does not know about the app
     }
     assert "unit" not in monitor_config["schedule"]
 
+    # schedule with the timezone from the app
+    celery_schedule = crontab(day_of_month="3", hour="12", minute="*/10", app=app)
+
+    monitor_config = _get_monitor_config(celery_schedule, app, "foo")
+    assert monitor_config == {
+        "schedule": {
+            "type": "crontab",
+            "value": "*/10 12 3 * *",
+        },
+        "timezone": "Europe/Vienna",  # the timezone from the app
+    }
+
+    # schedule without a timezone, the celery integration will read the config from the app
+    celery_schedule = crontab(day_of_month="3", hour="12", minute="*/10")
+    celery_schedule.tz = None
+
+    monitor_config = _get_monitor_config(celery_schedule, app, "foo")
+    assert monitor_config == {
+        "schedule": {
+            "type": "crontab",
+            "value": "*/10 12 3 * *",
+        },
+        "timezone": "Europe/Vienna",  # the timezone from the app
+    }
+
+    # schedule without a timezone, and an app without timezone, the celery integration will fall back to UTC
+    app = MagicMock()
+    app.timezone = None
+
+    celery_schedule = crontab(day_of_month="3", hour="12", minute="*/10")
+    celery_schedule.tz = None
+    monitor_config = _get_monitor_config(celery_schedule, app, "foo")
+    assert monitor_config == {
+        "schedule": {
+            "type": "crontab",
+            "value": "*/10 12 3 * *",
+        },
+        "timezone": "UTC",  # default timezone from celery integration
+    }
+
 
 def test_get_monitor_config_seconds():
     app = MagicMock()
-    app.conf = MagicMock()
-    app.conf.timezone = "Europe/Vienna"
+    app.timezone = "Europe/Vienna"
 
     celery_schedule = schedule(run_every=3)  # seconds
 
-    with mock.patch(
-        "sentry_sdk.integrations.celery.logger.warning"
-    ) as mock_logger_warning:
+    with mock.patch("sentry_sdk.integrations.logger.warning") as mock_logger_warning:
         monitor_config = _get_monitor_config(celery_schedule, app, "foo")
         mock_logger_warning.assert_called_with(
             "Intervals shorter than one minute are not supported by Sentry Crons. Monitor '%s' has an interval of %s seconds. Use the `exclude_beat_tasks` option in the celery integration to exclude it.",
@@ -243,10 +279,55 @@ def test_get_monitor_config_seconds():
 
 def test_get_monitor_config_minutes():
     app = MagicMock()
-    app.conf = MagicMock()
-    app.conf.timezone = "Europe/Vienna"
+    app.timezone = "Europe/Vienna"
+
+    # schedule with the default timezone
+    celery_schedule = schedule(run_every=60)  # seconds
+
+    monitor_config = _get_monitor_config(celery_schedule, app, "foo")
+    assert monitor_config == {
+        "schedule": {
+            "type": "interval",
+            "value": 1,
+            "unit": "minute",
+        },
+        "timezone": "UTC",
+    }
+
+    # schedule with the timezone from the app
+    celery_schedule = schedule(run_every=60, app=app)  # seconds
+
+    monitor_config = _get_monitor_config(celery_schedule, app, "foo")
+    assert monitor_config == {
+        "schedule": {
+            "type": "interval",
+            "value": 1,
+            "unit": "minute",
+        },
+        "timezone": "Europe/Vienna",  # the timezone from the app
+    }
+
+    # schedule without a timezone, the celery integration will read the config from the app
+    celery_schedule = schedule(run_every=60)  # seconds
+    celery_schedule.tz = None
+
+    monitor_config = _get_monitor_config(celery_schedule, app, "foo")
+    assert monitor_config == {
+        "schedule": {
+            "type": "interval",
+            "value": 1,
+            "unit": "minute",
+        },
+        "timezone": "Europe/Vienna",  # the timezone from the app
+    }
+
+    # schedule without a timezone, and an app without timezone, the celery integration will fall back to UTC
+    app = MagicMock()
+    app.timezone = None
 
     celery_schedule = schedule(run_every=60)  # seconds
+    celery_schedule.tz = None
+
     monitor_config = _get_monitor_config(celery_schedule, app, "foo")
     assert monitor_config == {
         "schedule": {
@@ -254,14 +335,13 @@ def test_get_monitor_config_minutes():
             "value": 1,
             "unit": "minute",
         },
-        "timezone": "Europe/Vienna",
+        "timezone": "UTC",  # default timezone from celery integration
     }
 
 
 def test_get_monitor_config_unknown():
     app = MagicMock()
-    app.conf = MagicMock()
-    app.conf.timezone = "Europe/Vienna"
+    app.timezone = "Europe/Vienna"
 
     unknown_celery_schedule = MagicMock()
     monitor_config = _get_monitor_config(unknown_celery_schedule, app, "foo")
@@ -270,16 +350,41 @@ def test_get_monitor_config_unknown():
 
 def test_get_monitor_config_default_timezone():
     app = MagicMock()
-    app.conf = MagicMock()
-    app.conf.timezone = None
+    app.timezone = None
 
     celery_schedule = crontab(day_of_month="3", hour="12", minute="*/10")
 
-    monitor_config = _get_monitor_config(celery_schedule, app, "foo")
+    monitor_config = _get_monitor_config(celery_schedule, app, "dummy_monitor_name")
 
     assert monitor_config["timezone"] == "UTC"
 
 
+def test_get_monitor_config_timezone_in_app_conf():
+    app = MagicMock()
+    app.timezone = "Asia/Karachi"
+
+    celery_schedule = crontab(day_of_month="3", hour="12", minute="*/10")
+    celery_schedule.tz = None
+
+    monitor_config = _get_monitor_config(celery_schedule, app, "dummy_monitor_name")
+
+    assert monitor_config["timezone"] == "Asia/Karachi"
+
+
+def test_get_monitor_config_timezone_in_celery_schedule():
+    app = MagicMock()
+    app.timezone = "Asia/Karachi"
+
+    panama_tz = datetime.timezone(datetime.timedelta(hours=-5), name="America/Panama")
+
+    celery_schedule = crontab(day_of_month="3", hour="12", minute="*/10")
+    celery_schedule.tz = panama_tz
+
+    monitor_config = _get_monitor_config(celery_schedule, app, "dummy_monitor_name")
+
+    assert monitor_config["timezone"] == str(panama_tz)
+
+
 @pytest.mark.parametrize(
     "task_name,exclude_beat_tasks,task_in_excluded_beat_tasks",
     [
@@ -301,20 +406,23 @@ def test_exclude_beat_tasks_option(
     fake_integration = MagicMock()
     fake_integration.exclude_beat_tasks = exclude_beat_tasks
 
+    fake_client = MagicMock()
+    fake_client.get_integration.return_value = fake_integration
+
     fake_schedule_entry = MagicMock()
     fake_schedule_entry.name = task_name
 
     fake_get_monitor_config = MagicMock()
 
     with mock.patch(
-        "sentry_sdk.integrations.celery.Scheduler", fake_scheduler
+        "sentry_sdk.integrations.celery.beat.Scheduler", fake_scheduler
     ) as Scheduler:  # noqa: N806
         with mock.patch(
-            "sentry_sdk.integrations.celery.Hub.current.get_integration",
-            return_value=fake_integration,
+            "sentry_sdk.integrations.celery.sentry_sdk.get_client",
+            return_value=fake_client,
         ):
             with mock.patch(
-                "sentry_sdk.integrations.celery._get_monitor_config",
+                "sentry_sdk.integrations.celery.beat._get_monitor_config",
                 fake_get_monitor_config,
             ) as _get_monitor_config:
                 # Mimic CeleryIntegration patching of Scheduler.apply_entry()
@@ -331,3 +439,61 @@ def test_exclude_beat_tasks_option(
                     # The original Scheduler.apply_entry() is called, AND _get_monitor_config is called.
                     assert fake_apply_entry.call_count == 1
                     assert _get_monitor_config.call_count == 1
+
+
+@pytest.mark.parametrize(
+    "task_name,exclude_beat_tasks,task_in_excluded_beat_tasks",
+    [
+        ["some_task_name", ["xxx", "some_task.*"], True],
+        ["some_task_name", ["xxx", "some_other_task.*"], False],
+    ],
+)
+def test_exclude_redbeat_tasks_option(
+    task_name, exclude_beat_tasks, task_in_excluded_beat_tasks
+):
+    """
+    Test excluding Celery RedBeat tasks from automatic instrumentation.
+    """
+    fake_apply_async = MagicMock()
+
+    fake_redbeat_scheduler = MagicMock()
+    fake_redbeat_scheduler.apply_async = fake_apply_async
+
+    fake_integration = MagicMock()
+    fake_integration.exclude_beat_tasks = exclude_beat_tasks
+
+    fake_client = MagicMock()
+    fake_client.get_integration.return_value = fake_integration
+
+    fake_schedule_entry = MagicMock()
+    fake_schedule_entry.name = task_name
+
+    fake_get_monitor_config = MagicMock()
+
+    with mock.patch(
+        "sentry_sdk.integrations.celery.beat.RedBeatScheduler", fake_redbeat_scheduler
+    ) as RedBeatScheduler:  # noqa: N806
+        with mock.patch(
+            "sentry_sdk.integrations.celery.sentry_sdk.get_client",
+            return_value=fake_client,
+        ):
+            with mock.patch(
+                "sentry_sdk.integrations.celery.beat._get_monitor_config",
+                fake_get_monitor_config,
+            ) as _get_monitor_config:
+                # Mimic CeleryIntegration patching of RedBeatScheduler.apply_async()
+                _patch_redbeat_apply_async()
+                # Mimic Celery RedBeat calling a task from the RedBeat schedule
+                RedBeatScheduler.apply_async(
+                    fake_redbeat_scheduler, fake_schedule_entry
+                )
+
+                if task_in_excluded_beat_tasks:
+                    # Only the original RedBeatScheduler.maybe_due() is called, _get_monitor_config is NOT called.
+                    assert fake_apply_async.call_count == 1
+                    _get_monitor_config.assert_not_called()
+
+                else:
+                    # The original RedBeatScheduler.maybe_due() is called, AND _get_monitor_config is called.
+                    assert fake_apply_async.call_count == 1
+                    assert _get_monitor_config.call_count == 1
diff --git a/tests/integrations/celery/test_update_celery_task_headers.py b/tests/integrations/celery/test_update_celery_task_headers.py
new file mode 100644
index 0000000000..705c00de58
--- /dev/null
+++ b/tests/integrations/celery/test_update_celery_task_headers.py
@@ -0,0 +1,228 @@
+from copy import copy
+import itertools
+import pytest
+
+from unittest import mock
+
+from sentry_sdk.integrations.celery import _update_celery_task_headers
+import sentry_sdk
+from sentry_sdk.tracing_utils import Baggage
+
+
+BAGGAGE_VALUE = (
+    "sentry-trace_id=771a43a4192642f0b136d5159a501700,"
+    "sentry-public_key=49d0f7386ad645858ae85020e393bef3,"
+    "sentry-sample_rate=0.1337,"
+    "custom=value"
+)
+
+SENTRY_TRACE_VALUE = "771a43a4192642f0b136d5159a501700-1234567890abcdef-1"
+
+
+@pytest.mark.parametrize("monitor_beat_tasks", [True, False, None, "", "bla", 1, 0])
+def test_monitor_beat_tasks(monitor_beat_tasks):
+    headers = {}
+    span = None
+
+    outgoing_headers = _update_celery_task_headers(headers, span, monitor_beat_tasks)
+
+    assert headers == {}  # left unchanged
+
+    if monitor_beat_tasks:
+        assert outgoing_headers["sentry-monitor-start-timestamp-s"] == mock.ANY
+        assert (
+            outgoing_headers["headers"]["sentry-monitor-start-timestamp-s"] == mock.ANY
+        )
+    else:
+        assert "sentry-monitor-start-timestamp-s" not in outgoing_headers
+        assert "sentry-monitor-start-timestamp-s" not in outgoing_headers["headers"]
+
+
+@pytest.mark.parametrize("monitor_beat_tasks", [True, False, None, "", "bla", 1, 0])
+def test_monitor_beat_tasks_with_headers(monitor_beat_tasks):
+    headers = {
+        "blub": "foo",
+        "sentry-something": "bar",
+        "sentry-task-enqueued-time": mock.ANY,
+    }
+    span = None
+
+    outgoing_headers = _update_celery_task_headers(headers, span, monitor_beat_tasks)
+
+    assert headers == {
+        "blub": "foo",
+        "sentry-something": "bar",
+        "sentry-task-enqueued-time": mock.ANY,
+    }  # left unchanged
+
+    if monitor_beat_tasks:
+        assert outgoing_headers["blub"] == "foo"
+        assert outgoing_headers["sentry-something"] == "bar"
+        assert outgoing_headers["sentry-monitor-start-timestamp-s"] == mock.ANY
+        assert outgoing_headers["headers"]["sentry-something"] == "bar"
+        assert (
+            outgoing_headers["headers"]["sentry-monitor-start-timestamp-s"] == mock.ANY
+        )
+    else:
+        assert outgoing_headers["blub"] == "foo"
+        assert outgoing_headers["sentry-something"] == "bar"
+        assert "sentry-monitor-start-timestamp-s" not in outgoing_headers
+        assert "sentry-monitor-start-timestamp-s" not in outgoing_headers["headers"]
+
+
+def test_span_with_transaction(sentry_init):
+    sentry_init(enable_tracing=True)
+    headers = {}
+    monitor_beat_tasks = False
+
+    with sentry_sdk.start_transaction(name="test_transaction") as transaction:
+        with sentry_sdk.start_span(op="test_span") as span:
+            outgoing_headers = _update_celery_task_headers(
+                headers, span, monitor_beat_tasks
+            )
+
+            assert outgoing_headers["sentry-trace"] == span.to_traceparent()
+            assert outgoing_headers["headers"]["sentry-trace"] == span.to_traceparent()
+            assert outgoing_headers["baggage"] == transaction.get_baggage().serialize()
+            assert (
+                outgoing_headers["headers"]["baggage"]
+                == transaction.get_baggage().serialize()
+            )
+
+
+def test_span_with_transaction_custom_headers(sentry_init):
+    sentry_init(enable_tracing=True)
+    headers = {
+        "baggage": BAGGAGE_VALUE,
+        "sentry-trace": SENTRY_TRACE_VALUE,
+    }
+
+    with sentry_sdk.start_transaction(name="test_transaction") as transaction:
+        with sentry_sdk.start_span(op="test_span") as span:
+            outgoing_headers = _update_celery_task_headers(headers, span, False)
+
+            assert outgoing_headers["sentry-trace"] == span.to_traceparent()
+            assert outgoing_headers["headers"]["sentry-trace"] == span.to_traceparent()
+
+            incoming_baggage = Baggage.from_incoming_header(headers["baggage"])
+            combined_baggage = copy(transaction.get_baggage())
+            combined_baggage.sentry_items.update(incoming_baggage.sentry_items)
+            combined_baggage.third_party_items = ",".join(
+                [
+                    x
+                    for x in [
+                        combined_baggage.third_party_items,
+                        incoming_baggage.third_party_items,
+                    ]
+                    if x is not None and x != ""
+                ]
+            )
+            assert outgoing_headers["baggage"] == combined_baggage.serialize(
+                include_third_party=True
+            )
+            assert outgoing_headers["headers"]["baggage"] == combined_baggage.serialize(
+                include_third_party=True
+            )
+
+
+@pytest.mark.parametrize("monitor_beat_tasks", [True, False])
+def test_celery_trace_propagation_default(sentry_init, monitor_beat_tasks):
+    """
+    The celery integration does not check the traces_sample_rate.
+    By default traces_sample_rate is None which means "do not propagate traces".
+    But the celery integration does not check this value.
+    The Celery integration has its own mechanism to propagate traces:
+    https://docs.sentry.io/platforms/python/integrations/celery/#distributed-traces
+    """
+    sentry_init()
+
+    headers = {}
+    span = None
+
+    scope = sentry_sdk.get_isolation_scope()
+
+    outgoing_headers = _update_celery_task_headers(headers, span, monitor_beat_tasks)
+
+    assert outgoing_headers["sentry-trace"] == scope.get_traceparent()
+    assert outgoing_headers["headers"]["sentry-trace"] == scope.get_traceparent()
+    assert outgoing_headers["baggage"] == scope.get_baggage().serialize()
+    assert outgoing_headers["headers"]["baggage"] == scope.get_baggage().serialize()
+
+    if monitor_beat_tasks:
+        assert "sentry-monitor-start-timestamp-s" in outgoing_headers
+        assert "sentry-monitor-start-timestamp-s" in outgoing_headers["headers"]
+    else:
+        assert "sentry-monitor-start-timestamp-s" not in outgoing_headers
+        assert "sentry-monitor-start-timestamp-s" not in outgoing_headers["headers"]
+
+
+@pytest.mark.parametrize(
+    "traces_sample_rate,monitor_beat_tasks",
+    list(itertools.product([None, 0, 0.0, 0.5, 1.0, 1, 2], [True, False])),
+)
+def test_celery_trace_propagation_traces_sample_rate(
+    sentry_init, traces_sample_rate, monitor_beat_tasks
+):
+    """
+    The celery integration does not check the traces_sample_rate.
+    By default traces_sample_rate is None which means "do not propagate traces".
+    But the celery integration does not check this value.
+    The Celery integration has its own mechanism to propagate traces:
+    https://docs.sentry.io/platforms/python/integrations/celery/#distributed-traces
+    """
+    sentry_init(traces_sample_rate=traces_sample_rate)
+
+    headers = {}
+    span = None
+
+    scope = sentry_sdk.get_isolation_scope()
+
+    outgoing_headers = _update_celery_task_headers(headers, span, monitor_beat_tasks)
+
+    assert outgoing_headers["sentry-trace"] == scope.get_traceparent()
+    assert outgoing_headers["headers"]["sentry-trace"] == scope.get_traceparent()
+    assert outgoing_headers["baggage"] == scope.get_baggage().serialize()
+    assert outgoing_headers["headers"]["baggage"] == scope.get_baggage().serialize()
+
+    if monitor_beat_tasks:
+        assert "sentry-monitor-start-timestamp-s" in outgoing_headers
+        assert "sentry-monitor-start-timestamp-s" in outgoing_headers["headers"]
+    else:
+        assert "sentry-monitor-start-timestamp-s" not in outgoing_headers
+        assert "sentry-monitor-start-timestamp-s" not in outgoing_headers["headers"]
+
+
+@pytest.mark.parametrize(
+    "enable_tracing,monitor_beat_tasks",
+    list(itertools.product([None, True, False], [True, False])),
+)
+def test_celery_trace_propagation_enable_tracing(
+    sentry_init, enable_tracing, monitor_beat_tasks
+):
+    """
+    The celery integration does not check the traces_sample_rate.
+    By default traces_sample_rate is None which means "do not propagate traces".
+    But the celery integration does not check this value.
+    The Celery integration has its own mechanism to propagate traces:
+    https://docs.sentry.io/platforms/python/integrations/celery/#distributed-traces
+    """
+    sentry_init(enable_tracing=enable_tracing)
+
+    headers = {}
+    span = None
+
+    scope = sentry_sdk.get_isolation_scope()
+
+    outgoing_headers = _update_celery_task_headers(headers, span, monitor_beat_tasks)
+
+    assert outgoing_headers["sentry-trace"] == scope.get_traceparent()
+    assert outgoing_headers["headers"]["sentry-trace"] == scope.get_traceparent()
+    assert outgoing_headers["baggage"] == scope.get_baggage().serialize()
+    assert outgoing_headers["headers"]["baggage"] == scope.get_baggage().serialize()
+
+    if monitor_beat_tasks:
+        assert "sentry-monitor-start-timestamp-s" in outgoing_headers
+        assert "sentry-monitor-start-timestamp-s" in outgoing_headers["headers"]
+    else:
+        assert "sentry-monitor-start-timestamp-s" not in outgoing_headers
+        assert "sentry-monitor-start-timestamp-s" not in outgoing_headers["headers"]
diff --git a/tests/integrations/chalice/test_chalice.py b/tests/integrations/chalice/test_chalice.py
index 4162a55623..ec8106eb5f 100644
--- a/tests/integrations/chalice/test_chalice.py
+++ b/tests/integrations/chalice/test_chalice.py
@@ -3,8 +3,9 @@
 from chalice import Chalice, BadRequestError
 from chalice.local import LambdaContext, LocalGateway
 
-from sentry_sdk.integrations.chalice import ChaliceIntegration
 from sentry_sdk import capture_message
+from sentry_sdk.integrations.chalice import CHALICE_VERSION, ChaliceIntegration
+from sentry_sdk.utils import parse_version
 
 from pytest_chalice.handlers import RequestHandler
 
@@ -65,12 +66,10 @@ def lambda_context_args():
 def test_exception_boom(app, client: RequestHandler) -> None:
     response = client.get("/boom")
     assert response.status_code == 500
-    assert response.json == dict(
-        [
-            ("Code", "InternalServerError"),
-            ("Message", "An internal server error occurred."),
-        ]
-    )
+    assert response.json == {
+        "Code": "InternalServerError",
+        "Message": "An internal server error occurred.",
+    }
 
 
 def test_has_request(app, capture_events, client: RequestHandler):
@@ -110,16 +109,32 @@ def every_hour(event):
     assert str(exc_info.value) == "schedule event!"
 
 
-def test_bad_reques(client: RequestHandler) -> None:
+@pytest.mark.skipif(
+    parse_version(CHALICE_VERSION) >= (1, 26, 0),
+    reason="different behavior based on chalice version",
+)
+def test_bad_request_old(client: RequestHandler) -> None:
     response = client.get("/badrequest")
 
     assert response.status_code == 400
-    assert response.json == dict(
-        [
-            ("Code", "BadRequestError"),
-            ("Message", "BadRequestError: bad-request"),
-        ]
-    )
+    assert response.json == {
+        "Code": "BadRequestError",
+        "Message": "BadRequestError: bad-request",
+    }
+
+
+@pytest.mark.skipif(
+    parse_version(CHALICE_VERSION) < (1, 26, 0),
+    reason="different behavior based on chalice version",
+)
+def test_bad_request(client: RequestHandler) -> None:
+    response = client.get("/badrequest")
+
+    assert response.status_code == 400
+    assert response.json == {
+        "Code": "BadRequestError",
+        "Message": "bad-request",
+    }
 
 
 @pytest.mark.parametrize(
diff --git a/tests/integrations/clickhouse_driver/test_clickhouse_driver.py b/tests/integrations/clickhouse_driver/test_clickhouse_driver.py
index 6b0fa566d4..635f9334c4 100644
--- a/tests/integrations/clickhouse_driver/test_clickhouse_driver.py
+++ b/tests/integrations/clickhouse_driver/test_clickhouse_driver.py
@@ -4,11 +4,13 @@
 docker run -d -p 18123:8123 -p9000:9000 --name clickhouse-test --ulimit nofile=262144:262144 --rm clickhouse/clickhouse-server
 ```
 """
+
 import clickhouse_driver
 from clickhouse_driver import Client, connect
 
 from sentry_sdk import start_transaction, capture_message
 from sentry_sdk.integrations.clickhouse_driver import ClickhouseDriverIntegration
+from tests.conftest import ApproxDict
 
 EXPECT_PARAMS_IN_SELECT = True
 if clickhouse_driver.VERSION < (0, 2, 6):
@@ -101,10 +103,19 @@ def test_clickhouse_client_breadcrumbs(sentry_init, capture_events) -> None:
     if not EXPECT_PARAMS_IN_SELECT:
         expected_breadcrumbs[-1]["data"].pop("db.params", None)
 
+    for crumb in expected_breadcrumbs:
+        crumb["data"] = ApproxDict(crumb["data"])
+
     for crumb in event["breadcrumbs"]["values"]:
         crumb.pop("timestamp", None)
 
-    assert event["breadcrumbs"]["values"] == expected_breadcrumbs
+    actual_query_breadcrumbs = [
+        breadcrumb
+        for breadcrumb in event["breadcrumbs"]["values"]
+        if breadcrumb["category"] == "query"
+    ]
+
+    assert actual_query_breadcrumbs == expected_breadcrumbs
 
 
 def test_clickhouse_client_breadcrumbs_with_pii(sentry_init, capture_events) -> None:
@@ -200,6 +211,9 @@ def test_clickhouse_client_breadcrumbs_with_pii(sentry_init, capture_events) ->
     if not EXPECT_PARAMS_IN_SELECT:
         expected_breadcrumbs[-1]["data"].pop("db.params", None)
 
+    for crumb in expected_breadcrumbs:
+        crumb["data"] = ApproxDict(crumb["data"])
+
     for crumb in event["breadcrumbs"]["values"]:
         crumb.pop("timestamp", None)
 
@@ -239,6 +253,7 @@ def test_clickhouse_client_spans(
     expected_spans = [
         {
             "op": "db",
+            "origin": "auto.db.clickhouse_driver",
             "description": "DROP TABLE IF EXISTS test",
             "data": {
                 "db.system": "clickhouse",
@@ -253,6 +268,7 @@ def test_clickhouse_client_spans(
         },
         {
             "op": "db",
+            "origin": "auto.db.clickhouse_driver",
             "description": "CREATE TABLE test (x Int32) ENGINE = Memory",
             "data": {
                 "db.system": "clickhouse",
@@ -267,6 +283,7 @@ def test_clickhouse_client_spans(
         },
         {
             "op": "db",
+            "origin": "auto.db.clickhouse_driver",
             "description": "INSERT INTO test (x) VALUES",
             "data": {
                 "db.system": "clickhouse",
@@ -281,6 +298,7 @@ def test_clickhouse_client_spans(
         },
         {
             "op": "db",
+            "origin": "auto.db.clickhouse_driver",
             "description": "INSERT INTO test (x) VALUES",
             "data": {
                 "db.system": "clickhouse",
@@ -295,6 +313,7 @@ def test_clickhouse_client_spans(
         },
         {
             "op": "db",
+            "origin": "auto.db.clickhouse_driver",
             "description": "SELECT sum(x) FROM test WHERE x > 150",
             "data": {
                 "db.system": "clickhouse",
@@ -312,6 +331,9 @@ def test_clickhouse_client_spans(
     if not EXPECT_PARAMS_IN_SELECT:
         expected_spans[-1]["data"].pop("db.params", None)
 
+    for span in expected_spans:
+        span["data"] = ApproxDict(span["data"])
+
     for span in event["spans"]:
         span.pop("span_id", None)
         span.pop("start_timestamp", None)
@@ -320,6 +342,38 @@ def test_clickhouse_client_spans(
     assert event["spans"] == expected_spans
 
 
+def test_clickhouse_spans_with_generator(sentry_init, capture_events):
+    sentry_init(
+        integrations=[ClickhouseDriverIntegration()],
+        send_default_pii=True,
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    # Use a generator to test that the integration obtains values from the generator,
+    # without consuming the generator.
+    values = ({"x": i} for i in range(3))
+
+    with start_transaction(name="test_clickhouse_transaction"):
+        client = Client("localhost")
+        client.execute("DROP TABLE IF EXISTS test")
+        client.execute("CREATE TABLE test (x Int32) ENGINE = Memory")
+        client.execute("INSERT INTO test (x) VALUES", values)
+        res = client.execute("SELECT x FROM test")
+
+    # Verify that the integration did not consume the generator
+    assert res == [(0,), (1,), (2,)]
+
+    (event,) = events
+    spans = event["spans"]
+
+    [span] = [
+        span for span in spans if span["description"] == "INSERT INTO test (x) VALUES"
+    ]
+
+    assert span["data"]["db.params"] == [{"x": 0}, {"x": 1}, {"x": 2}]
+
+
 def test_clickhouse_client_spans_with_pii(
     sentry_init, capture_events, capture_envelopes
 ) -> None:
@@ -354,6 +408,7 @@ def test_clickhouse_client_spans_with_pii(
     expected_spans = [
         {
             "op": "db",
+            "origin": "auto.db.clickhouse_driver",
             "description": "DROP TABLE IF EXISTS test",
             "data": {
                 "db.system": "clickhouse",
@@ -369,6 +424,7 @@ def test_clickhouse_client_spans_with_pii(
         },
         {
             "op": "db",
+            "origin": "auto.db.clickhouse_driver",
             "description": "CREATE TABLE test (x Int32) ENGINE = Memory",
             "data": {
                 "db.system": "clickhouse",
@@ -384,6 +440,7 @@ def test_clickhouse_client_spans_with_pii(
         },
         {
             "op": "db",
+            "origin": "auto.db.clickhouse_driver",
             "description": "INSERT INTO test (x) VALUES",
             "data": {
                 "db.system": "clickhouse",
@@ -399,6 +456,7 @@ def test_clickhouse_client_spans_with_pii(
         },
         {
             "op": "db",
+            "origin": "auto.db.clickhouse_driver",
             "description": "INSERT INTO test (x) VALUES",
             "data": {
                 "db.system": "clickhouse",
@@ -414,6 +472,7 @@ def test_clickhouse_client_spans_with_pii(
         },
         {
             "op": "db",
+            "origin": "auto.db.clickhouse_driver",
             "description": "SELECT sum(x) FROM test WHERE x > 150",
             "data": {
                 "db.system": "clickhouse",
@@ -433,6 +492,9 @@ def test_clickhouse_client_spans_with_pii(
     if not EXPECT_PARAMS_IN_SELECT:
         expected_spans[-1]["data"].pop("db.params", None)
 
+    for span in expected_spans:
+        span["data"] = ApproxDict(span["data"])
+
     for span in event["spans"]:
         span.pop("span_id", None)
         span.pop("start_timestamp", None)
@@ -528,6 +590,9 @@ def test_clickhouse_dbapi_breadcrumbs(sentry_init, capture_events) -> None:
     if not EXPECT_PARAMS_IN_SELECT:
         expected_breadcrumbs[-1]["data"].pop("db.params", None)
 
+    for crumb in expected_breadcrumbs:
+        crumb["data"] = ApproxDict(crumb["data"])
+
     for crumb in event["breadcrumbs"]["values"]:
         crumb.pop("timestamp", None)
 
@@ -628,6 +693,9 @@ def test_clickhouse_dbapi_breadcrumbs_with_pii(sentry_init, capture_events) -> N
     if not EXPECT_PARAMS_IN_SELECT:
         expected_breadcrumbs[-1]["data"].pop("db.params", None)
 
+    for crumb in expected_breadcrumbs:
+        crumb["data"] = ApproxDict(crumb["data"])
+
     for crumb in event["breadcrumbs"]["values"]:
         crumb.pop("timestamp", None)
 
@@ -665,6 +733,7 @@ def test_clickhouse_dbapi_spans(sentry_init, capture_events, capture_envelopes)
     expected_spans = [
         {
             "op": "db",
+            "origin": "auto.db.clickhouse_driver",
             "description": "DROP TABLE IF EXISTS test",
             "data": {
                 "db.system": "clickhouse",
@@ -679,6 +748,7 @@ def test_clickhouse_dbapi_spans(sentry_init, capture_events, capture_envelopes)
         },
         {
             "op": "db",
+            "origin": "auto.db.clickhouse_driver",
             "description": "CREATE TABLE test (x Int32) ENGINE = Memory",
             "data": {
                 "db.system": "clickhouse",
@@ -693,6 +763,7 @@ def test_clickhouse_dbapi_spans(sentry_init, capture_events, capture_envelopes)
         },
         {
             "op": "db",
+            "origin": "auto.db.clickhouse_driver",
             "description": "INSERT INTO test (x) VALUES",
             "data": {
                 "db.system": "clickhouse",
@@ -707,6 +778,7 @@ def test_clickhouse_dbapi_spans(sentry_init, capture_events, capture_envelopes)
         },
         {
             "op": "db",
+            "origin": "auto.db.clickhouse_driver",
             "description": "INSERT INTO test (x) VALUES",
             "data": {
                 "db.system": "clickhouse",
@@ -721,6 +793,7 @@ def test_clickhouse_dbapi_spans(sentry_init, capture_events, capture_envelopes)
         },
         {
             "op": "db",
+            "origin": "auto.db.clickhouse_driver",
             "description": "SELECT sum(x) FROM test WHERE x > 150",
             "data": {
                 "db.system": "clickhouse",
@@ -738,6 +811,9 @@ def test_clickhouse_dbapi_spans(sentry_init, capture_events, capture_envelopes)
     if not EXPECT_PARAMS_IN_SELECT:
         expected_spans[-1]["data"].pop("db.params", None)
 
+    for span in expected_spans:
+        span["data"] = ApproxDict(span["data"])
+
     for span in event["spans"]:
         span.pop("span_id", None)
         span.pop("start_timestamp", None)
@@ -780,6 +856,7 @@ def test_clickhouse_dbapi_spans_with_pii(
     expected_spans = [
         {
             "op": "db",
+            "origin": "auto.db.clickhouse_driver",
             "description": "DROP TABLE IF EXISTS test",
             "data": {
                 "db.system": "clickhouse",
@@ -795,6 +872,7 @@ def test_clickhouse_dbapi_spans_with_pii(
         },
         {
             "op": "db",
+            "origin": "auto.db.clickhouse_driver",
             "description": "CREATE TABLE test (x Int32) ENGINE = Memory",
             "data": {
                 "db.system": "clickhouse",
@@ -810,6 +888,7 @@ def test_clickhouse_dbapi_spans_with_pii(
         },
         {
             "op": "db",
+            "origin": "auto.db.clickhouse_driver",
             "description": "INSERT INTO test (x) VALUES",
             "data": {
                 "db.system": "clickhouse",
@@ -825,6 +904,7 @@ def test_clickhouse_dbapi_spans_with_pii(
         },
         {
             "op": "db",
+            "origin": "auto.db.clickhouse_driver",
             "description": "INSERT INTO test (x) VALUES",
             "data": {
                 "db.system": "clickhouse",
@@ -840,6 +920,7 @@ def test_clickhouse_dbapi_spans_with_pii(
         },
         {
             "op": "db",
+            "origin": "auto.db.clickhouse_driver",
             "description": "SELECT sum(x) FROM test WHERE x > 150",
             "data": {
                 "db.system": "clickhouse",
@@ -859,9 +940,31 @@ def test_clickhouse_dbapi_spans_with_pii(
     if not EXPECT_PARAMS_IN_SELECT:
         expected_spans[-1]["data"].pop("db.params", None)
 
+    for span in expected_spans:
+        span["data"] = ApproxDict(span["data"])
+
     for span in event["spans"]:
         span.pop("span_id", None)
         span.pop("start_timestamp", None)
         span.pop("timestamp", None)
 
     assert event["spans"] == expected_spans
+
+
+def test_span_origin(sentry_init, capture_events, capture_envelopes) -> None:
+    sentry_init(
+        integrations=[ClickhouseDriverIntegration()],
+        traces_sample_rate=1.0,
+    )
+
+    events = capture_events()
+
+    with start_transaction(name="test_clickhouse_transaction"):
+        conn = connect("clickhouse://localhost")
+        cursor = conn.cursor()
+        cursor.execute("SELECT 1")
+
+    (event,) = events
+
+    assert event["contexts"]["trace"]["origin"] == "manual"
+    assert event["spans"][0]["origin"] == "auto.db.clickhouse_driver"
diff --git a/tests/integrations/cloud_resource_context/test_cloud_resource_context.py b/tests/integrations/cloud_resource_context/test_cloud_resource_context.py
index b36f795a2b..49732b00a5 100644
--- a/tests/integrations/cloud_resource_context/test_cloud_resource_context.py
+++ b/tests/integrations/cloud_resource_context/test_cloud_resource_context.py
@@ -1,14 +1,9 @@
 import json
+from unittest import mock
+from unittest.mock import MagicMock
 
 import pytest
 
-try:
-    from unittest import mock  # python 3.3 and above
-    from unittest.mock import MagicMock
-except ImportError:
-    import mock  # python < 3.3
-    from mock import MagicMock
-
 from sentry_sdk.integrations.cloud_resource_context import (
     CLOUD_PLATFORM,
     CLOUD_PROVIDER,
@@ -32,16 +27,11 @@
     "version": "2017-09-30",
 }
 
-try:
-    # Python 3
-    AWS_EC2_EXAMPLE_IMDSv2_PAYLOAD_BYTES = bytes(
-        json.dumps(AWS_EC2_EXAMPLE_IMDSv2_PAYLOAD), "utf-8"
-    )
-except TypeError:
-    # Python 2
-    AWS_EC2_EXAMPLE_IMDSv2_PAYLOAD_BYTES = bytes(
-        json.dumps(AWS_EC2_EXAMPLE_IMDSv2_PAYLOAD)
-    ).encode("utf-8")
+
+AWS_EC2_EXAMPLE_IMDSv2_PAYLOAD_BYTES = bytes(
+    json.dumps(AWS_EC2_EXAMPLE_IMDSv2_PAYLOAD), "utf-8"
+)
+
 
 GCP_GCE_EXAMPLE_METADATA_PLAYLOAD = {
     "instance": {
@@ -404,7 +394,17 @@ def test_setup_once(
             else:
                 fake_set_context.assert_not_called()
 
-            if warning_called:
-                assert fake_warning.call_count == 1
-            else:
-                fake_warning.assert_not_called()
+            def invalid_value_warning_calls():
+                """
+                Iterator that yields True if the warning was called with the expected message.
+                Written as a generator function, rather than a list comprehension, to allow
+                us to handle exceptions that might be raised during the iteration if the
+                warning call was not as expected.
+                """
+                for call in fake_warning.call_args_list:
+                    try:
+                        yield call[0][0].startswith("Invalid value for cloud_provider:")
+                    except (IndexError, KeyError, TypeError, AttributeError):
+                        ...
+
+            assert warning_called == any(invalid_value_warning_calls())
diff --git a/tests/integrations/cohere/__init__.py b/tests/integrations/cohere/__init__.py
new file mode 100644
index 0000000000..3484a6dc41
--- /dev/null
+++ b/tests/integrations/cohere/__init__.py
@@ -0,0 +1,3 @@
+import pytest
+
+pytest.importorskip("cohere")
diff --git a/tests/integrations/cohere/test_cohere.py b/tests/integrations/cohere/test_cohere.py
new file mode 100644
index 0000000000..9ff56ed697
--- /dev/null
+++ b/tests/integrations/cohere/test_cohere.py
@@ -0,0 +1,304 @@
+import json
+
+import httpx
+import pytest
+from cohere import Client, ChatMessage
+
+from sentry_sdk import start_transaction
+from sentry_sdk.consts import SPANDATA
+from sentry_sdk.integrations.cohere import CohereIntegration
+
+from unittest import mock  # python 3.3 and above
+from httpx import Client as HTTPXClient
+
+
+@pytest.mark.parametrize(
+    "send_default_pii, include_prompts",
+    [(True, True), (True, False), (False, True), (False, False)],
+)
+def test_nonstreaming_chat(
+    sentry_init, capture_events, send_default_pii, include_prompts
+):
+    sentry_init(
+        integrations=[CohereIntegration(include_prompts=include_prompts)],
+        traces_sample_rate=1.0,
+        send_default_pii=send_default_pii,
+    )
+    events = capture_events()
+
+    client = Client(api_key="z")
+    HTTPXClient.request = mock.Mock(
+        return_value=httpx.Response(
+            200,
+            json={
+                "text": "the model response",
+                "meta": {
+                    "billed_units": {
+                        "output_tokens": 10,
+                        "input_tokens": 20,
+                    }
+                },
+            },
+        )
+    )
+
+    with start_transaction(name="cohere tx"):
+        response = client.chat(
+            model="some-model",
+            chat_history=[ChatMessage(role="SYSTEM", message="some context")],
+            message="hello",
+        ).text
+
+    assert response == "the model response"
+    tx = events[0]
+    assert tx["type"] == "transaction"
+    span = tx["spans"][0]
+    assert span["op"] == "ai.chat_completions.create.cohere"
+    assert span["data"][SPANDATA.AI_MODEL_ID] == "some-model"
+
+    if send_default_pii and include_prompts:
+        assert (
+            '{"role": "system", "content": "some context"}'
+            in span["data"][SPANDATA.AI_INPUT_MESSAGES]
+        )
+        assert (
+            '{"role": "user", "content": "hello"}'
+            in span["data"][SPANDATA.AI_INPUT_MESSAGES]
+        )
+        assert "the model response" in span["data"][SPANDATA.AI_RESPONSES]
+    else:
+        assert SPANDATA.AI_INPUT_MESSAGES not in span["data"]
+        assert SPANDATA.AI_RESPONSES not in span["data"]
+
+    assert span["data"]["gen_ai.usage.output_tokens"] == 10
+    assert span["data"]["gen_ai.usage.input_tokens"] == 20
+    assert span["data"]["gen_ai.usage.total_tokens"] == 30
+
+
+# noinspection PyTypeChecker
+@pytest.mark.parametrize(
+    "send_default_pii, include_prompts",
+    [(True, True), (True, False), (False, True), (False, False)],
+)
+def test_streaming_chat(sentry_init, capture_events, send_default_pii, include_prompts):
+    sentry_init(
+        integrations=[CohereIntegration(include_prompts=include_prompts)],
+        traces_sample_rate=1.0,
+        send_default_pii=send_default_pii,
+    )
+    events = capture_events()
+
+    client = Client(api_key="z")
+    HTTPXClient.send = mock.Mock(
+        return_value=httpx.Response(
+            200,
+            content="\n".join(
+                [
+                    json.dumps({"event_type": "text-generation", "text": "the model "}),
+                    json.dumps({"event_type": "text-generation", "text": "response"}),
+                    json.dumps(
+                        {
+                            "event_type": "stream-end",
+                            "finish_reason": "COMPLETE",
+                            "response": {
+                                "text": "the model response",
+                                "meta": {
+                                    "billed_units": {
+                                        "output_tokens": 10,
+                                        "input_tokens": 20,
+                                    }
+                                },
+                            },
+                        }
+                    ),
+                ]
+            ),
+        )
+    )
+
+    with start_transaction(name="cohere tx"):
+        responses = list(
+            client.chat_stream(
+                model="some-model",
+                chat_history=[ChatMessage(role="SYSTEM", message="some context")],
+                message="hello",
+            )
+        )
+        response_string = responses[-1].response.text
+
+    assert response_string == "the model response"
+    tx = events[0]
+    assert tx["type"] == "transaction"
+    span = tx["spans"][0]
+    assert span["op"] == "ai.chat_completions.create.cohere"
+    assert span["data"][SPANDATA.AI_MODEL_ID] == "some-model"
+
+    if send_default_pii and include_prompts:
+        assert (
+            '{"role": "system", "content": "some context"}'
+            in span["data"][SPANDATA.AI_INPUT_MESSAGES]
+        )
+        assert (
+            '{"role": "user", "content": "hello"}'
+            in span["data"][SPANDATA.AI_INPUT_MESSAGES]
+        )
+        assert "the model response" in span["data"][SPANDATA.AI_RESPONSES]
+    else:
+        assert SPANDATA.AI_INPUT_MESSAGES not in span["data"]
+        assert SPANDATA.AI_RESPONSES not in span["data"]
+
+    assert span["data"]["gen_ai.usage.output_tokens"] == 10
+    assert span["data"]["gen_ai.usage.input_tokens"] == 20
+    assert span["data"]["gen_ai.usage.total_tokens"] == 30
+
+
+def test_bad_chat(sentry_init, capture_events):
+    sentry_init(integrations=[CohereIntegration()], traces_sample_rate=1.0)
+    events = capture_events()
+
+    client = Client(api_key="z")
+    HTTPXClient.request = mock.Mock(
+        side_effect=httpx.HTTPError("API rate limit reached")
+    )
+    with pytest.raises(httpx.HTTPError):
+        client.chat(model="some-model", message="hello")
+
+    (event,) = events
+    assert event["level"] == "error"
+
+
+def test_span_status_error(sentry_init, capture_events):
+    sentry_init(integrations=[CohereIntegration()], traces_sample_rate=1.0)
+    events = capture_events()
+
+    with start_transaction(name="test"):
+        client = Client(api_key="z")
+        HTTPXClient.request = mock.Mock(
+            side_effect=httpx.HTTPError("API rate limit reached")
+        )
+        with pytest.raises(httpx.HTTPError):
+            client.chat(model="some-model", message="hello")
+
+    (error, transaction) = events
+    assert error["level"] == "error"
+    assert transaction["spans"][0]["status"] == "internal_error"
+    assert transaction["spans"][0]["tags"]["status"] == "internal_error"
+    assert transaction["contexts"]["trace"]["status"] == "internal_error"
+
+
+@pytest.mark.parametrize(
+    "send_default_pii, include_prompts",
+    [(True, True), (True, False), (False, True), (False, False)],
+)
+def test_embed(sentry_init, capture_events, send_default_pii, include_prompts):
+    sentry_init(
+        integrations=[CohereIntegration(include_prompts=include_prompts)],
+        traces_sample_rate=1.0,
+        send_default_pii=send_default_pii,
+    )
+    events = capture_events()
+
+    client = Client(api_key="z")
+    HTTPXClient.request = mock.Mock(
+        return_value=httpx.Response(
+            200,
+            json={
+                "response_type": "embeddings_floats",
+                "id": "1",
+                "texts": ["hello"],
+                "embeddings": [[1.0, 2.0, 3.0]],
+                "meta": {
+                    "billed_units": {
+                        "input_tokens": 10,
+                    }
+                },
+            },
+        )
+    )
+
+    with start_transaction(name="cohere tx"):
+        response = client.embed(texts=["hello"], model="text-embedding-3-large")
+
+    assert len(response.embeddings[0]) == 3
+
+    tx = events[0]
+    assert tx["type"] == "transaction"
+    span = tx["spans"][0]
+    assert span["op"] == "ai.embeddings.create.cohere"
+    if send_default_pii and include_prompts:
+        assert "hello" in span["data"][SPANDATA.AI_INPUT_MESSAGES]
+    else:
+        assert SPANDATA.AI_INPUT_MESSAGES not in span["data"]
+
+    assert span["data"]["gen_ai.usage.input_tokens"] == 10
+    assert span["data"]["gen_ai.usage.total_tokens"] == 10
+
+
+def test_span_origin_chat(sentry_init, capture_events):
+    sentry_init(
+        integrations=[CohereIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    client = Client(api_key="z")
+    HTTPXClient.request = mock.Mock(
+        return_value=httpx.Response(
+            200,
+            json={
+                "text": "the model response",
+                "meta": {
+                    "billed_units": {
+                        "output_tokens": 10,
+                        "input_tokens": 20,
+                    }
+                },
+            },
+        )
+    )
+
+    with start_transaction(name="cohere tx"):
+        client.chat(
+            model="some-model",
+            chat_history=[ChatMessage(role="SYSTEM", message="some context")],
+            message="hello",
+        ).text
+
+    (event,) = events
+
+    assert event["contexts"]["trace"]["origin"] == "manual"
+    assert event["spans"][0]["origin"] == "auto.ai.cohere"
+
+
+def test_span_origin_embed(sentry_init, capture_events):
+    sentry_init(
+        integrations=[CohereIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    client = Client(api_key="z")
+    HTTPXClient.request = mock.Mock(
+        return_value=httpx.Response(
+            200,
+            json={
+                "response_type": "embeddings_floats",
+                "id": "1",
+                "texts": ["hello"],
+                "embeddings": [[1.0, 2.0, 3.0]],
+                "meta": {
+                    "billed_units": {
+                        "input_tokens": 10,
+                    }
+                },
+            },
+        )
+    )
+
+    with start_transaction(name="cohere tx"):
+        client.embed(texts=["hello"], model="text-embedding-3-large")
+
+    (event,) = events
+
+    assert event["contexts"]["trace"]["origin"] == "manual"
+    assert event["spans"][0]["origin"] == "auto.ai.cohere"
diff --git a/tests/integrations/conftest.py b/tests/integrations/conftest.py
index cffb278d70..7ac43b0efe 100644
--- a/tests/integrations/conftest.py
+++ b/tests/integrations/conftest.py
@@ -6,16 +6,50 @@
 def capture_exceptions(monkeypatch):
     def inner():
         errors = set()
-        old_capture_event = sentry_sdk.Hub.capture_event
+        old_capture_event_hub = sentry_sdk.Hub.capture_event
+        old_capture_event_scope = sentry_sdk.Scope.capture_event
 
-        def capture_event(self, event, hint=None):
+        def capture_event_hub(self, event, hint=None, scope=None):
+            """
+            Can be removed when we remove push_scope and the Hub from the SDK.
+            """
             if hint:
                 if "exc_info" in hint:
                     error = hint["exc_info"][1]
                     errors.add(error)
-            return old_capture_event(self, event, hint=hint)
+            return old_capture_event_hub(self, event, hint=hint, scope=scope)
+
+        def capture_event_scope(self, event, hint=None, scope=None):
+            if hint:
+                if "exc_info" in hint:
+                    error = hint["exc_info"][1]
+                    errors.add(error)
+            return old_capture_event_scope(self, event, hint=hint, scope=scope)
+
+        monkeypatch.setattr(sentry_sdk.Hub, "capture_event", capture_event_hub)
+        monkeypatch.setattr(sentry_sdk.Scope, "capture_event", capture_event_scope)
 
-        monkeypatch.setattr(sentry_sdk.Hub, "capture_event", capture_event)
         return errors
 
     return inner
+
+
+parametrize_test_configurable_status_codes = pytest.mark.parametrize(
+    ("failed_request_status_codes", "status_code", "expected_error"),
+    (
+        (None, 500, True),
+        (None, 400, False),
+        ({500, 501}, 500, True),
+        ({500, 501}, 401, False),
+        ({*range(400, 500)}, 401, True),
+        ({*range(400, 500)}, 500, False),
+        ({*range(400, 600)}, 300, False),
+        ({*range(400, 600)}, 403, True),
+        ({*range(400, 600)}, 503, True),
+        ({*range(400, 403), 500, 501}, 401, True),
+        ({*range(400, 403), 500, 501}, 405, False),
+        ({*range(400, 403), 500, 501}, 501, True),
+        ({*range(400, 403), 500, 501}, 503, False),
+        (set(), 500, False),
+    ),
+)
diff --git a/tests/integrations/django/__init__.py b/tests/integrations/django/__init__.py
index 70cc4776d5..41d72f92a5 100644
--- a/tests/integrations/django/__init__.py
+++ b/tests/integrations/django/__init__.py
@@ -1,3 +1,9 @@
+import os
+import sys
 import pytest
 
 pytest.importorskip("django")
+
+# Load `django_helpers` into the module search path to test query source path names relative to module. See
+# `test_query_source_with_module_in_search_path`
+sys.path.insert(0, os.path.join(os.path.dirname(__file__)))
diff --git a/tests/integrations/django/asgi/image.png b/tests/integrations/django/asgi/image.png
new file mode 100644
index 0000000000..8db277a9fc
Binary files /dev/null and b/tests/integrations/django/asgi/image.png differ
diff --git a/tests/integrations/django/asgi/test_asgi.py b/tests/integrations/django/asgi/test_asgi.py
index 85921cf364..f956d12f82 100644
--- a/tests/integrations/django/asgi/test_asgi.py
+++ b/tests/integrations/django/asgi/test_asgi.py
@@ -1,16 +1,24 @@
+import base64
+import sys
 import json
+import inspect
+import asyncio
+import os
+from unittest import mock
 
 import django
 import pytest
 from channels.testing import HttpCommunicator
 from sentry_sdk import capture_message
 from sentry_sdk.integrations.django import DjangoIntegration
+from sentry_sdk.integrations.django.asgi import _asgi_middleware_mixin_factory
 from tests.integrations.django.myapp.asgi import channels_application
 
 try:
-    from unittest import mock  # python 3.3 and above
+    from django.urls import reverse
 except ImportError:
-    import mock  # python < 3.3
+    from django.core.urlresolvers import reverse
+
 
 APPS = [channels_application]
 if django.VERSION >= (3, 0):
@@ -21,13 +29,38 @@
 
 @pytest.mark.parametrize("application", APPS)
 @pytest.mark.asyncio
+@pytest.mark.forked
+@pytest.mark.skipif(
+    django.VERSION < (3, 0), reason="Django ASGI support shipped in 3.0"
+)
 async def test_basic(sentry_init, capture_events, application):
-    sentry_init(integrations=[DjangoIntegration()], send_default_pii=True)
+    sentry_init(
+        integrations=[DjangoIntegration()],
+        send_default_pii=True,
+    )
 
     events = capture_events()
 
-    comm = HttpCommunicator(application, "GET", "/view-exc?test=query")
-    response = await comm.get_response()
+    import channels  # type: ignore[import-not-found]
+
+    if (
+        sys.version_info < (3, 9)
+        and channels.__version__ < "4.0.0"
+        and django.VERSION >= (3, 0)
+        and django.VERSION < (4, 0)
+    ):
+        # We emit a UserWarning for channels 2.x and 3.x on Python 3.8 and older
+        # because the async support was not really good back then and there is a known issue.
+        # See the TreadingIntegration for details.
+        with pytest.warns(UserWarning):
+            comm = HttpCommunicator(application, "GET", "/view-exc?test=query")
+            response = await comm.get_response()
+            await comm.wait()
+    else:
+        comm = HttpCommunicator(application, "GET", "/view-exc?test=query")
+        response = await comm.get_response()
+        await comm.wait()
+
     assert response["status"] == 500
 
     (event,) = events
@@ -53,16 +86,22 @@ async def test_basic(sentry_init, capture_events, application):
 
 @pytest.mark.parametrize("application", APPS)
 @pytest.mark.asyncio
+@pytest.mark.forked
 @pytest.mark.skipif(
     django.VERSION < (3, 1), reason="async views have been introduced in Django 3.1"
 )
 async def test_async_views(sentry_init, capture_events, application):
-    sentry_init(integrations=[DjangoIntegration()], send_default_pii=True)
+    sentry_init(
+        integrations=[DjangoIntegration()],
+        send_default_pii=True,
+    )
 
     events = capture_events()
 
     comm = HttpCommunicator(application, "GET", "/async_message")
     response = await comm.get_response()
+    await comm.wait()
+
     assert response["status"] == 200
 
     (event,) = events
@@ -79,41 +118,60 @@ async def test_async_views(sentry_init, capture_events, application):
 
 @pytest.mark.parametrize("application", APPS)
 @pytest.mark.parametrize("endpoint", ["/sync/thread_ids", "/async/thread_ids"])
+@pytest.mark.parametrize("middleware_spans", [False, True])
 @pytest.mark.asyncio
+@pytest.mark.forked
 @pytest.mark.skipif(
     django.VERSION < (3, 1), reason="async views have been introduced in Django 3.1"
 )
-async def test_active_thread_id(sentry_init, capture_envelopes, endpoint, application):
-    with mock.patch("sentry_sdk.profiler.PROFILE_MINIMUM_SAMPLES", 0):
+async def test_active_thread_id(
+    sentry_init,
+    capture_envelopes,
+    teardown_profiling,
+    endpoint,
+    application,
+    middleware_spans,
+):
+    with mock.patch(
+        "sentry_sdk.profiler.transaction_profiler.PROFILE_MINIMUM_SAMPLES", 0
+    ):
         sentry_init(
-            integrations=[DjangoIntegration()],
+            integrations=[DjangoIntegration(middleware_spans=middleware_spans)],
             traces_sample_rate=1.0,
-            _experiments={"profiles_sample_rate": 1.0},
+            profiles_sample_rate=1.0,
         )
 
         envelopes = capture_envelopes()
 
         comm = HttpCommunicator(application, "GET", endpoint)
         response = await comm.get_response()
+        await comm.wait()
+
         assert response["status"] == 200, response["body"]
 
-        await comm.wait()
+    assert len(envelopes) == 1
 
-        data = json.loads(response["body"])
+    profiles = [item for item in envelopes[0].items if item.type == "profile"]
+    assert len(profiles) == 1
 
-        envelopes = [envelope for envelope in envelopes]
-        assert len(envelopes) == 1
+    data = json.loads(response["body"])
 
-        profiles = [item for item in envelopes[0].items if item.type == "profile"]
-        assert len(profiles) == 1
+    for item in profiles:
+        transactions = item.payload.json["transactions"]
+        assert len(transactions) == 1
+        assert str(data["active"]) == transactions[0]["active_thread_id"]
 
-        for profile in profiles:
-            transactions = profile.payload.json["transactions"]
-            assert len(transactions) == 1
-            assert str(data["active"]) == transactions[0]["active_thread_id"]
+    transactions = [item for item in envelopes[0].items if item.type == "transaction"]
+    assert len(transactions) == 1
+
+    for item in transactions:
+        transaction = item.payload.json
+        trace_context = transaction["contexts"]["trace"]
+        assert str(data["active"]) == trace_context["data"]["thread.id"]
 
 
 @pytest.mark.asyncio
+@pytest.mark.forked
 @pytest.mark.skipif(
     django.VERSION < (3, 1), reason="async views have been introduced in Django 3.1"
 )
@@ -124,10 +182,17 @@ async def test_async_views_concurrent_execution(sentry_init, settings):
     settings.MIDDLEWARE = []
     asgi_application.load_middleware(is_async=True)
 
-    sentry_init(integrations=[DjangoIntegration()], send_default_pii=True)
+    sentry_init(
+        integrations=[DjangoIntegration()],
+        send_default_pii=True,
+    )
 
-    comm = HttpCommunicator(asgi_application, "GET", "/my_async_view")
-    comm2 = HttpCommunicator(asgi_application, "GET", "/my_async_view")
+    comm = HttpCommunicator(
+        asgi_application, "GET", "/my_async_view"
+    )  # sleeps for 1 second
+    comm2 = HttpCommunicator(
+        asgi_application, "GET", "/my_async_view"
+    )  # sleeps for 1 second
 
     loop = asyncio.get_event_loop()
 
@@ -143,10 +208,13 @@ async def test_async_views_concurrent_execution(sentry_init, settings):
     assert resp1.result()["status"] == 200
     assert resp2.result()["status"] == 200
 
-    assert end - start < 1.5
+    assert (
+        end - start < 2
+    )  # it takes less than 2 seconds so it was ececuting concurrently
 
 
 @pytest.mark.asyncio
+@pytest.mark.forked
 @pytest.mark.skipif(
     django.VERSION < (3, 1), reason="async views have been introduced in Django 3.1"
 )
@@ -161,10 +229,17 @@ async def test_async_middleware_that_is_function_concurrent_execution(
     ]
     asgi_application.load_middleware(is_async=True)
 
-    sentry_init(integrations=[DjangoIntegration()], send_default_pii=True)
+    sentry_init(
+        integrations=[DjangoIntegration()],
+        send_default_pii=True,
+    )
 
-    comm = HttpCommunicator(asgi_application, "GET", "/my_async_view")
-    comm2 = HttpCommunicator(asgi_application, "GET", "/my_async_view")
+    comm = HttpCommunicator(
+        asgi_application, "GET", "/my_async_view"
+    )  # sleeps for 1 second
+    comm2 = HttpCommunicator(
+        asgi_application, "GET", "/my_async_view"
+    )  # sleeps for 1 second
 
     loop = asyncio.get_event_loop()
 
@@ -180,10 +255,13 @@ async def test_async_middleware_that_is_function_concurrent_execution(
     assert resp1.result()["status"] == 200
     assert resp2.result()["status"] == 200
 
-    assert end - start < 1.5
+    assert (
+        end - start < 2
+    )  # it takes less than 2 seconds so it was ececuting concurrently
 
 
 @pytest.mark.asyncio
+@pytest.mark.forked
 @pytest.mark.skipif(
     django.VERSION < (3, 1), reason="async views have been introduced in Django 3.1"
 )
@@ -206,13 +284,13 @@ async def test_async_middleware_spans(
 
     events = capture_events()
 
-    comm = HttpCommunicator(asgi_application, "GET", "/async_message")
+    comm = HttpCommunicator(asgi_application, "GET", "/simple_async_view")
     response = await comm.get_response()
-    assert response["status"] == 200
-
     await comm.wait()
 
-    message, transaction = events
+    assert response["status"] == 200
+
+    (transaction,) = events
 
     assert (
         render_span_tree(transaction)
@@ -225,7 +303,7 @@ async def test_async_middleware_spans(
       - op="middleware.django": description="django.middleware.csrf.CsrfViewMiddleware.__acall__"
         - op="middleware.django": description="tests.integrations.django.myapp.settings.TestMiddleware.__acall__"
           - op="middleware.django": description="django.middleware.csrf.CsrfViewMiddleware.process_view"
-          - op="view.render": description="async_message"
+          - op="view.render": description="simple_async_view"
   - op="event.django": description="django.db.close_old_connections"
   - op="event.django": description="django.core.cache.close_caches"
   - op="event.django": description="django.core.handlers.base.reset_urlconf\""""
@@ -233,45 +311,49 @@ async def test_async_middleware_spans(
 
 
 @pytest.mark.asyncio
+@pytest.mark.forked
 @pytest.mark.skipif(
     django.VERSION < (3, 1), reason="async views have been introduced in Django 3.1"
 )
 async def test_has_trace_if_performance_enabled(sentry_init, capture_events):
-    sentry_init(integrations=[DjangoIntegration()], traces_sample_rate=1.0)
+    sentry_init(
+        integrations=[DjangoIntegration()],
+        traces_sample_rate=1.0,
+    )
 
     events = capture_events()
 
     comm = HttpCommunicator(asgi_application, "GET", "/view-exc-with-msg")
     response = await comm.get_response()
-    assert response["status"] == 500
-
-    # ASGI Django does not create transactions per default,
-    # so we do not have a transaction_event here.
-    (msg_event, error_event) = events
+    await comm.wait()
 
-    assert msg_event["contexts"]["trace"]
-    assert "trace_id" in msg_event["contexts"]["trace"]
+    assert response["status"] == 500
 
-    assert error_event["contexts"]["trace"]
-    assert "trace_id" in error_event["contexts"]["trace"]
+    (msg_event, error_event, transaction_event) = events
 
     assert (
         msg_event["contexts"]["trace"]["trace_id"]
         == error_event["contexts"]["trace"]["trace_id"]
+        == transaction_event["contexts"]["trace"]["trace_id"]
     )
 
 
 @pytest.mark.asyncio
+@pytest.mark.forked
 @pytest.mark.skipif(
     django.VERSION < (3, 1), reason="async views have been introduced in Django 3.1"
 )
 async def test_has_trace_if_performance_disabled(sentry_init, capture_events):
-    sentry_init(integrations=[DjangoIntegration()])
+    sentry_init(
+        integrations=[DjangoIntegration()],
+    )
 
     events = capture_events()
 
     comm = HttpCommunicator(asgi_application, "GET", "/view-exc-with-msg")
     response = await comm.get_response()
+    await comm.wait()
+
     assert response["status"] == 500
 
     (msg_event, error_event) = events
@@ -288,11 +370,15 @@ async def test_has_trace_if_performance_disabled(sentry_init, capture_events):
 
 
 @pytest.mark.asyncio
+@pytest.mark.forked
 @pytest.mark.skipif(
     django.VERSION < (3, 1), reason="async views have been introduced in Django 3.1"
 )
 async def test_trace_from_headers_if_performance_enabled(sentry_init, capture_events):
-    sentry_init(integrations=[DjangoIntegration()], traces_sample_rate=1.0)
+    sentry_init(
+        integrations=[DjangoIntegration()],
+        traces_sample_rate=1.0,
+    )
 
     events = capture_events()
 
@@ -306,28 +392,26 @@ async def test_trace_from_headers_if_performance_enabled(sentry_init, capture_ev
         headers=[(b"sentry-trace", sentry_trace_header.encode())],
     )
     response = await comm.get_response()
-    assert response["status"] == 500
-
-    # ASGI Django does not create transactions per default,
-    # so we do not have a transaction_event here.
-    (msg_event, error_event) = events
+    await comm.wait()
 
-    assert msg_event["contexts"]["trace"]
-    assert "trace_id" in msg_event["contexts"]["trace"]
+    assert response["status"] == 500
 
-    assert error_event["contexts"]["trace"]
-    assert "trace_id" in error_event["contexts"]["trace"]
+    (msg_event, error_event, transaction_event) = events
 
     assert msg_event["contexts"]["trace"]["trace_id"] == trace_id
     assert error_event["contexts"]["trace"]["trace_id"] == trace_id
+    assert transaction_event["contexts"]["trace"]["trace_id"] == trace_id
 
 
 @pytest.mark.asyncio
+@pytest.mark.forked
 @pytest.mark.skipif(
     django.VERSION < (3, 1), reason="async views have been introduced in Django 3.1"
 )
 async def test_trace_from_headers_if_performance_disabled(sentry_init, capture_events):
-    sentry_init(integrations=[DjangoIntegration()])
+    sentry_init(
+        integrations=[DjangoIntegration()],
+    )
 
     events = capture_events()
 
@@ -341,15 +425,315 @@ async def test_trace_from_headers_if_performance_disabled(sentry_init, capture_e
         headers=[(b"sentry-trace", sentry_trace_header.encode())],
     )
     response = await comm.get_response()
+    await comm.wait()
+
     assert response["status"] == 500
 
     (msg_event, error_event) = events
 
-    assert msg_event["contexts"]["trace"]
-    assert "trace_id" in msg_event["contexts"]["trace"]
-
-    assert error_event["contexts"]["trace"]
-    assert "trace_id" in error_event["contexts"]["trace"]
-
     assert msg_event["contexts"]["trace"]["trace_id"] == trace_id
     assert error_event["contexts"]["trace"]["trace_id"] == trace_id
+
+
+PICTURE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "image.png")
+BODY_FORM = """--fd721ef49ea403a6\r\nContent-Disposition: form-data; name="username"\r\n\r\nJane\r\n--fd721ef49ea403a6\r\nContent-Disposition: form-data; name="password"\r\n\r\nhello123\r\n--fd721ef49ea403a6\r\nContent-Disposition: form-data; name="photo"; filename="image.png"\r\nContent-Type: image/png\r\nContent-Transfer-Encoding: base64\r\n\r\n{{image_data}}\r\n--fd721ef49ea403a6--\r\n""".replace(
+    "{{image_data}}", base64.b64encode(open(PICTURE, "rb").read()).decode("utf-8")
+).encode("utf-8")
+BODY_FORM_CONTENT_LENGTH = str(len(BODY_FORM)).encode("utf-8")
+
+
+@pytest.mark.parametrize("application", APPS)
+@pytest.mark.parametrize(
+    "send_default_pii,method,headers,url_name,body,expected_data",
+    [
+        (
+            True,
+            "POST",
+            [(b"content-type", b"text/plain")],
+            "post_echo_async",
+            b"",
+            None,
+        ),
+        (
+            True,
+            "POST",
+            [(b"content-type", b"text/plain")],
+            "post_echo_async",
+            b"some raw text body",
+            "",
+        ),
+        (
+            True,
+            "POST",
+            [(b"content-type", b"application/json")],
+            "post_echo_async",
+            b'{"username":"xyz","password":"xyz"}',
+            {"username": "xyz", "password": "[Filtered]"},
+        ),
+        (
+            True,
+            "POST",
+            [(b"content-type", b"application/xml")],
+            "post_echo_async",
+            b'',
+            "",
+        ),
+        (
+            True,
+            "POST",
+            [
+                (b"content-type", b"multipart/form-data; boundary=fd721ef49ea403a6"),
+                (b"content-length", BODY_FORM_CONTENT_LENGTH),
+            ],
+            "post_echo_async",
+            BODY_FORM,
+            {"password": "[Filtered]", "photo": "", "username": "Jane"},
+        ),
+        (
+            False,
+            "POST",
+            [(b"content-type", b"text/plain")],
+            "post_echo_async",
+            b"",
+            None,
+        ),
+        (
+            False,
+            "POST",
+            [(b"content-type", b"text/plain")],
+            "post_echo_async",
+            b"some raw text body",
+            "",
+        ),
+        (
+            False,
+            "POST",
+            [(b"content-type", b"application/json")],
+            "post_echo_async",
+            b'{"username":"xyz","password":"xyz"}',
+            {"username": "xyz", "password": "[Filtered]"},
+        ),
+        (
+            False,
+            "POST",
+            [(b"content-type", b"application/xml")],
+            "post_echo_async",
+            b'',
+            "",
+        ),
+        (
+            False,
+            "POST",
+            [
+                (b"content-type", b"multipart/form-data; boundary=fd721ef49ea403a6"),
+                (b"content-length", BODY_FORM_CONTENT_LENGTH),
+            ],
+            "post_echo_async",
+            BODY_FORM,
+            {"password": "[Filtered]", "photo": "", "username": "Jane"},
+        ),
+    ],
+)
+@pytest.mark.asyncio
+@pytest.mark.forked
+@pytest.mark.skipif(
+    django.VERSION < (3, 1), reason="async views have been introduced in Django 3.1"
+)
+async def test_asgi_request_body(
+    sentry_init,
+    capture_envelopes,
+    application,
+    send_default_pii,
+    method,
+    headers,
+    url_name,
+    body,
+    expected_data,
+):
+    sentry_init(
+        integrations=[DjangoIntegration()],
+        send_default_pii=send_default_pii,
+    )
+
+    envelopes = capture_envelopes()
+
+    comm = HttpCommunicator(
+        application,
+        method=method,
+        headers=headers,
+        path=reverse(url_name),
+        body=body,
+    )
+    response = await comm.get_response()
+    await comm.wait()
+
+    assert response["status"] == 200
+    assert response["body"] == body
+
+    (envelope,) = envelopes
+    event = envelope.get_event()
+
+    if expected_data is not None:
+        assert event["request"]["data"] == expected_data
+    else:
+        assert "data" not in event["request"]
+
+
+@pytest.mark.asyncio
+@pytest.mark.skipif(
+    sys.version_info >= (3, 12),
+    reason=(
+        "asyncio.iscoroutinefunction has been replaced in 3.12 by inspect.iscoroutinefunction"
+    ),
+)
+@pytest.mark.skipif(
+    django.VERSION < (3, 0), reason="Django ASGI support shipped in 3.0"
+)
+async def test_asgi_mixin_iscoroutinefunction_before_3_12():
+    sentry_asgi_mixin = _asgi_middleware_mixin_factory(lambda: None)
+
+    async def get_response(): ...
+
+    instance = sentry_asgi_mixin(get_response)
+    assert asyncio.iscoroutinefunction(instance)
+
+
+@pytest.mark.skipif(
+    sys.version_info >= (3, 12),
+    reason=(
+        "asyncio.iscoroutinefunction has been replaced in 3.12 by inspect.iscoroutinefunction"
+    ),
+)
+def test_asgi_mixin_iscoroutinefunction_when_not_async_before_3_12():
+    sentry_asgi_mixin = _asgi_middleware_mixin_factory(lambda: None)
+
+    def get_response(): ...
+
+    instance = sentry_asgi_mixin(get_response)
+    assert not asyncio.iscoroutinefunction(instance)
+
+
+@pytest.mark.asyncio
+@pytest.mark.skipif(
+    sys.version_info < (3, 12),
+    reason=(
+        "asyncio.iscoroutinefunction has been replaced in 3.12 by inspect.iscoroutinefunction"
+    ),
+)
+async def test_asgi_mixin_iscoroutinefunction_after_3_12():
+    sentry_asgi_mixin = _asgi_middleware_mixin_factory(lambda: None)
+
+    async def get_response(): ...
+
+    instance = sentry_asgi_mixin(get_response)
+    assert inspect.iscoroutinefunction(instance)
+
+
+@pytest.mark.skipif(
+    sys.version_info < (3, 12),
+    reason=(
+        "asyncio.iscoroutinefunction has been replaced in 3.12 by inspect.iscoroutinefunction"
+    ),
+)
+def test_asgi_mixin_iscoroutinefunction_when_not_async_after_3_12():
+    sentry_asgi_mixin = _asgi_middleware_mixin_factory(lambda: None)
+
+    def get_response(): ...
+
+    instance = sentry_asgi_mixin(get_response)
+    assert not inspect.iscoroutinefunction(instance)
+
+
+@pytest.mark.parametrize("application", APPS)
+@pytest.mark.asyncio
+@pytest.mark.skipif(
+    django.VERSION < (3, 1), reason="async views have been introduced in Django 3.1"
+)
+async def test_async_view(sentry_init, capture_events, application):
+    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()
+
+    (event,) = events
+    assert event["type"] == "transaction"
+    assert event["transaction"] == "/simple_async_view"
+
+
+@pytest.mark.parametrize("application", APPS)
+@pytest.mark.asyncio
+@pytest.mark.skipif(
+    django.VERSION < (3, 0), reason="Django ASGI support shipped in 3.0"
+)
+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
+@pytest.mark.skipif(
+    django.VERSION < (3, 0), reason="Django ASGI support shipped in 3.0"
+)
+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"
diff --git a/tests/integrations/django/django_helpers/__init__.py b/tests/integrations/django/django_helpers/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/integrations/django/django_helpers/views.py b/tests/integrations/django/django_helpers/views.py
new file mode 100644
index 0000000000..a5759a5199
--- /dev/null
+++ b/tests/integrations/django/django_helpers/views.py
@@ -0,0 +1,9 @@
+from django.contrib.auth.models import User
+from django.http import HttpResponse
+from django.views.decorators.csrf import csrf_exempt
+
+
+@csrf_exempt
+def postgres_select_orm(request, *args, **kwargs):
+    user = User.objects.using("postgres").all().first()
+    return HttpResponse("ok {}".format(user))
diff --git a/tests/integrations/django/myapp/custom_urls.py b/tests/integrations/django/myapp/custom_urls.py
index 6dfa2ed2f1..5b2a1e428b 100644
--- a/tests/integrations/django/myapp/custom_urls.py
+++ b/tests/integrations/django/myapp/custom_urls.py
@@ -13,7 +13,6 @@
     1. Import the include() function: from django.urls import include, path
     2. Add a URL to urlpatterns:  path('blog/', include('blog.urls'))
 """
-from __future__ import absolute_import
 
 try:
     from django.urls import path
diff --git a/tests/integrations/django/myapp/settings.py b/tests/integrations/django/myapp/settings.py
index b8b083eb81..d70adf63ec 100644
--- a/tests/integrations/django/myapp/settings.py
+++ b/tests/integrations/django/myapp/settings.py
@@ -10,7 +10,6 @@
 https://docs.djangoproject.com/en/2.0/ref/settings/
 """
 
-
 # We shouldn't access settings while setting up integrations. Initialize SDK
 # here to provoke any errors that might occur.
 import sentry_sdk
@@ -18,16 +17,9 @@
 
 sentry_sdk.init(integrations=[DjangoIntegration()])
 
-
 import os
 
-try:
-    # Django >= 1.10
-    from django.utils.deprecation import MiddlewareMixin
-except ImportError:
-    # Not required for Django <= 1.9, see:
-    # https://docs.djangoproject.com/en/1.10/topics/http/middleware/#upgrading-pre-django-1-10-style-middleware
-    MiddlewareMixin = object
+from django.utils.deprecation import MiddlewareMixin
 
 # Build paths inside the project like this: os.path.join(BASE_DIR, ...)
 BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -129,16 +121,18 @@ def middleware(request):
 
     DATABASES["postgres"] = {
         "ENGINE": db_engine,
-        "NAME": os.environ["SENTRY_PYTHON_TEST_POSTGRES_NAME"],
-        "USER": os.environ["SENTRY_PYTHON_TEST_POSTGRES_USER"],
-        "PASSWORD": os.environ["SENTRY_PYTHON_TEST_POSTGRES_PASSWORD"],
         "HOST": os.environ.get("SENTRY_PYTHON_TEST_POSTGRES_HOST", "localhost"),
-        "PORT": 5432,
+        "PORT": int(os.environ.get("SENTRY_PYTHON_TEST_POSTGRES_PORT", "5432")),
+        "USER": os.environ.get("SENTRY_PYTHON_TEST_POSTGRES_USER", "postgres"),
+        "PASSWORD": os.environ.get("SENTRY_PYTHON_TEST_POSTGRES_PASSWORD", "sentry"),
+        "NAME": os.environ.get(
+            "SENTRY_PYTHON_TEST_POSTGRES_NAME", f"myapp_db_{os.getpid()}"
+        ),
     }
 except (ImportError, KeyError):
     from sentry_sdk.utils import logger
 
-    logger.warn("No psycopg2 found, testing with SQLite.")
+    logger.warning("No psycopg2 found, testing with SQLite.")
 
 
 # Password validation
diff --git a/tests/integrations/django/myapp/signals.py b/tests/integrations/django/myapp/signals.py
new file mode 100644
index 0000000000..3dab92b8d9
--- /dev/null
+++ b/tests/integrations/django/myapp/signals.py
@@ -0,0 +1,15 @@
+from django.core import signals
+from django.dispatch import receiver
+
+myapp_custom_signal = signals.Signal()
+myapp_custom_signal_silenced = signals.Signal()
+
+
+@receiver(myapp_custom_signal)
+def signal_handler(sender, **kwargs):
+    assert sender == "hello"
+
+
+@receiver(myapp_custom_signal_silenced)
+def signal_handler_silenced(sender, **kwargs):
+    assert sender == "hello"
diff --git a/tests/integrations/django/myapp/urls.py b/tests/integrations/django/myapp/urls.py
index 2a4535e588..26d5a1bf2c 100644
--- a/tests/integrations/django/myapp/urls.py
+++ b/tests/integrations/django/myapp/urls.py
@@ -13,7 +13,6 @@
     1. Import the include() function: from django.urls import include, path
     2. Add a URL to urlpatterns:  path('blog/', include('blog.urls'))
 """
-from __future__ import absolute_import
 
 try:
     from django.urls import path
@@ -25,6 +24,7 @@ def path(path, *args, **kwargs):
 
 
 from . import views
+from django_helpers import views as helper_views
 
 urlpatterns = [
     path("view-exc", views.view_exc, name="view_exc"),
@@ -43,6 +43,8 @@ def path(path, *args, **kwargs):
     ),
     path("middleware-exc", views.message, name="middleware_exc"),
     path("message", views.message, name="message"),
+    path("nomessage", views.nomessage, name="nomessage"),
+    path("view-with-signal", views.view_with_signal, name="view_with_signal"),
     path("mylogin", views.mylogin, name="mylogin"),
     path("classbased", views.ClassBasedView.as_view(), name="classbased"),
     path("sentryclass", views.SentryClassBasedView(), name="sentryclass"),
@@ -56,7 +58,39 @@ def path(path, *args, **kwargs):
     path("template-test", views.template_test, name="template_test"),
     path("template-test2", views.template_test2, name="template_test2"),
     path("template-test3", views.template_test3, name="template_test3"),
+    path("template-test4", views.template_test4, name="template_test4"),
     path("postgres-select", views.postgres_select, name="postgres_select"),
+    path("postgres-select-slow", views.postgres_select_orm, name="postgres_select_orm"),
+    path(
+        "postgres-insert-no-autocommit",
+        views.postgres_insert_orm_no_autocommit,
+        name="postgres_insert_orm_no_autocommit",
+    ),
+    path(
+        "postgres-insert-no-autocommit-rollback",
+        views.postgres_insert_orm_no_autocommit_rollback,
+        name="postgres_insert_orm_no_autocommit_rollback",
+    ),
+    path(
+        "postgres-insert-atomic",
+        views.postgres_insert_orm_atomic,
+        name="postgres_insert_orm_atomic",
+    ),
+    path(
+        "postgres-insert-atomic-rollback",
+        views.postgres_insert_orm_atomic_rollback,
+        name="postgres_insert_orm_atomic_rollback",
+    ),
+    path(
+        "postgres-insert-atomic-exception",
+        views.postgres_insert_orm_atomic_exception,
+        name="postgres_insert_orm_atomic_exception",
+    ),
+    path(
+        "postgres-select-slow-from-supplement",
+        helper_views.postgres_select_orm,
+        name="postgres_select_slow_from_supplement",
+    ),
     path(
         "permission-denied-exc",
         views.permission_denied_exc,
@@ -68,6 +102,11 @@ def path(path, *args, **kwargs):
         name="csrf_hello_not_exempt",
     ),
     path("sync/thread_ids", views.thread_ids_sync, name="thread_ids_sync"),
+    path(
+        "send-myapp-custom-signal",
+        views.send_myapp_custom_signal,
+        name="send_myapp_custom_signal",
+    ),
 ]
 
 # async views
@@ -77,11 +116,21 @@ def path(path, *args, **kwargs):
 if views.my_async_view is not None:
     urlpatterns.append(path("my_async_view", views.my_async_view, name="my_async_view"))
 
+if views.my_async_view is not None:
+    urlpatterns.append(
+        path("simple_async_view", views.simple_async_view, name="simple_async_view")
+    )
+
 if views.thread_ids_async is not None:
     urlpatterns.append(
         path("async/thread_ids", views.thread_ids_async, name="thread_ids_async")
     )
 
+if views.post_echo_async is not None:
+    urlpatterns.append(
+        path("post_echo_async", views.post_echo_async, name="post_echo_async")
+    )
+
 # rest framework
 try:
     urlpatterns.append(
diff --git a/tests/integrations/django/myapp/views.py b/tests/integrations/django/myapp/views.py
index 1e909f2b38..6d199a3740 100644
--- a/tests/integrations/django/myapp/views.py
+++ b/tests/integrations/django/myapp/views.py
@@ -1,10 +1,12 @@
+import asyncio
 import json
 import threading
 
-from django import VERSION
+from django.db import transaction
 from django.contrib.auth import login
 from django.contrib.auth.models import User
 from django.core.exceptions import PermissionDenied
+from django.dispatch import Signal
 from django.http import HttpResponse, HttpResponseNotFound, HttpResponseServerError
 from django.shortcuts import render
 from django.template import Context, Template
@@ -14,6 +16,12 @@
 from django.views.decorators.csrf import csrf_exempt
 from django.views.generic import ListView
 
+
+from tests.integrations.django.myapp.signals import (
+    myapp_custom_signal,
+    myapp_custom_signal_silenced,
+)
+
 try:
     from rest_framework.decorators import api_view
     from rest_framework.response import Response
@@ -84,14 +92,14 @@ def view_with_cached_template_fragment(request):
 # interesting property of this one is that csrf_exempt, as a class attribute,
 # is not in __dict__, so regular use of functools.wraps will not forward the
 # attribute.
-class SentryClassBasedView(object):
+class SentryClassBasedView:
     csrf_exempt = True
 
     def __call__(self, request):
         return HttpResponse("ok")
 
 
-class SentryClassBasedViewWithCsrf(object):
+class SentryClassBasedViewWithCsrf:
     def __call__(self, request):
         return HttpResponse("ok")
 
@@ -108,6 +116,18 @@ def message(request):
     return HttpResponse("ok")
 
 
+@csrf_exempt
+def nomessage(request):
+    return HttpResponse("ok")
+
+
+@csrf_exempt
+def view_with_signal(request):
+    custom_signal = Signal()
+    custom_signal.send(sender="hello")
+    return HttpResponse("ok")
+
+
 @csrf_exempt
 def mylogin(request):
     user = User.objects.create_user("john", "lennon@thebeatles.com", "johnpassword")
@@ -118,7 +138,7 @@ def mylogin(request):
 
 @csrf_exempt
 def handler500(request):
-    return HttpResponseServerError("Sentry error: %s" % sentry_sdk.last_event_id())
+    return HttpResponseServerError("Sentry error.")
 
 
 class ClassBasedView(ListView):
@@ -126,7 +146,7 @@ class ClassBasedView(ListView):
 
     @method_decorator(csrf_exempt)
     def dispatch(self, request, *args, **kwargs):
-        return super(ClassBasedView, self).dispatch(request, *args, **kwargs)
+        return super().dispatch(request, *args, **kwargs)
 
     def head(self, *args, **kwargs):
         sentry_sdk.capture_message("hi")
@@ -177,13 +197,41 @@ def template_test2(request, *args, **kwargs):
 
 @csrf_exempt
 def template_test3(request, *args, **kwargs):
-    from sentry_sdk import Hub
+    traceparent = sentry_sdk.get_current_scope().get_traceparent()
+    if traceparent is None:
+        traceparent = sentry_sdk.get_isolation_scope().get_traceparent()
 
-    hub = Hub.current
-    capture_message(hub.get_traceparent() + "\n" + hub.get_baggage())
+    baggage = sentry_sdk.get_current_scope().get_baggage()
+    if baggage is None:
+        baggage = sentry_sdk.get_isolation_scope().get_baggage()
+
+    capture_message(traceparent + "\n" + baggage.serialize())
     return render(request, "trace_meta.html", {})
 
 
+@csrf_exempt
+def template_test4(request, *args, **kwargs):
+    User.objects.create_user("john", "lennon@thebeatles.com", "johnpassword")
+    my_queryset = User.objects.all()  # noqa
+
+    template_context = {
+        "user_age": 25,
+        "complex_context": my_queryset,
+        "complex_list": [1, 2, 3, my_queryset],
+        "complex_dict": {
+            "a": 1,
+            "d": my_queryset,
+        },
+        "none_context": None,
+    }
+
+    return TemplateResponse(
+        request,
+        "user_name.html",
+        template_context,
+    )
+
+
 @csrf_exempt
 def postgres_select(request, *args, **kwargs):
     from django.db import connections
@@ -193,6 +241,79 @@ def postgres_select(request, *args, **kwargs):
     return HttpResponse("ok")
 
 
+@csrf_exempt
+def postgres_select_orm(request, *args, **kwargs):
+    user = User.objects.using("postgres").all().first()
+    return HttpResponse("ok {}".format(user))
+
+
+@csrf_exempt
+def postgres_insert_orm_no_autocommit(request, *args, **kwargs):
+    transaction.set_autocommit(False, using="postgres")
+    try:
+        user = User.objects.db_manager("postgres").create_user(
+            username="user1",
+        )
+        transaction.commit(using="postgres")
+    except Exception:
+        transaction.rollback(using="postgres")
+        transaction.set_autocommit(True, using="postgres")
+        raise
+
+    transaction.set_autocommit(True, using="postgres")
+    return HttpResponse("ok {}".format(user))
+
+
+@csrf_exempt
+def postgres_insert_orm_no_autocommit_rollback(request, *args, **kwargs):
+    transaction.set_autocommit(False, using="postgres")
+    try:
+        user = User.objects.db_manager("postgres").create_user(
+            username="user1",
+        )
+        transaction.rollback(using="postgres")
+    except Exception:
+        transaction.rollback(using="postgres")
+        transaction.set_autocommit(True, using="postgres")
+        raise
+
+    transaction.set_autocommit(True, using="postgres")
+    return HttpResponse("ok {}".format(user))
+
+
+@csrf_exempt
+def postgres_insert_orm_atomic(request, *args, **kwargs):
+    with transaction.atomic(using="postgres"):
+        user = User.objects.db_manager("postgres").create_user(
+            username="user1",
+        )
+    return HttpResponse("ok {}".format(user))
+
+
+@csrf_exempt
+def postgres_insert_orm_atomic_rollback(request, *args, **kwargs):
+    with transaction.atomic(using="postgres"):
+        user = User.objects.db_manager("postgres").create_user(
+            username="user1",
+        )
+        transaction.set_rollback(True, using="postgres")
+    return HttpResponse("ok {}".format(user))
+
+
+@csrf_exempt
+def postgres_insert_orm_atomic_exception(request, *args, **kwargs):
+    try:
+        with transaction.atomic(using="postgres"):
+            user = User.objects.db_manager("postgres").create_user(
+                username="user1",
+            )
+            transaction.set_rollback(True, using="postgres")
+            1 / 0
+    except ZeroDivisionError:
+        pass
+    return HttpResponse("ok {}".format(user))
+
+
 @csrf_exempt
 def permission_denied_exc(*args, **kwargs):
     raise PermissionDenied("bye")
@@ -212,30 +333,40 @@ def thread_ids_sync(*args, **kwargs):
     return HttpResponse(response)
 
 
-if VERSION >= (3, 1):
-    # Use exec to produce valid Python 2
-    exec(
-        """async def async_message(request):
+async def async_message(request):
     sentry_sdk.capture_message("hi")
-    return HttpResponse("ok")"""
-    )
+    return HttpResponse("ok")
 
-    exec(
-        """async def my_async_view(request):
-    import asyncio
+
+async def my_async_view(request):
     await asyncio.sleep(1)
-    return HttpResponse('Hello World')"""
-    )
+    return HttpResponse("Hello World")
+
+
+async def simple_async_view(request):
+    return HttpResponse("Simple Hello World")
 
-    exec(
-        """async def thread_ids_async(request):
-    response = json.dumps({
-        "main": threading.main_thread().ident,
-        "active": threading.current_thread().ident,
-    })
-    return HttpResponse(response)"""
+
+async def thread_ids_async(request):
+    response = json.dumps(
+        {
+            "main": threading.main_thread().ident,
+            "active": threading.current_thread().ident,
+        }
     )
-else:
-    async_message = None
-    my_async_view = None
-    thread_ids_async = None
+    return HttpResponse(response)
+
+
+async def post_echo_async(request):
+    sentry_sdk.capture_message("hi")
+    return HttpResponse(request.body)
+
+
+post_echo_async.csrf_exempt = True
+
+
+@csrf_exempt
+def send_myapp_custom_signal(request):
+    myapp_custom_signal.send(sender="hello")
+    myapp_custom_signal_silenced.send(sender="hello")
+    return HttpResponse("ok")
diff --git a/tests/integrations/django/test_basic.py b/tests/integrations/django/test_basic.py
index 379c4d9614..1c6bb141bd 100644
--- a/tests/integrations/django/test_basic.py
+++ b/tests/integrations/django/test_basic.py
@@ -1,32 +1,45 @@
-from __future__ import absolute_import
-
+import inspect
 import json
 import os
-import random
-import re
 import pytest
+import re
+import sys
+
 from functools import partial
+from unittest.mock import patch
 
 from werkzeug.test import Client
 
 from django import VERSION as DJANGO_VERSION
+
 from django.contrib.auth.models import User
 from django.core.management import execute_from_command_line
 from django.db.utils import OperationalError, ProgrammingError, DataError
+from django.http.request import RawPostDataException
+from django.template.context import make_context
+from django.utils.functional import SimpleLazyObject
 
 try:
     from django.urls import reverse
 except ImportError:
     from django.core.urlresolvers import reverse
 
-from sentry_sdk._compat import PY2, PY310
-from sentry_sdk import capture_message, capture_exception, configure_scope
+import sentry_sdk
+from sentry_sdk._compat import PY310
+from sentry_sdk import capture_message, capture_exception
 from sentry_sdk.consts import SPANDATA
-from sentry_sdk.integrations.django import DjangoIntegration
+from sentry_sdk.integrations.django import (
+    DjangoIntegration,
+    DjangoRequestExtractor,
+    _set_db_data,
+)
 from sentry_sdk.integrations.django.signals_handlers import _get_receiver_name
-from sentry_sdk.integrations.django.caching import _get_span_description
 from sentry_sdk.integrations.executing import ExecutingIntegration
+from sentry_sdk.profiler.utils import get_frame_name
+from sentry_sdk.tracing import Span
+from tests.conftest import unpack_werkzeug_response
 from tests.integrations.django.myapp.wsgi import application
+from tests.integrations.django.myapp.signals import myapp_custom_signal_silenced
 from tests.integrations.django.utils import pytest_mark_django_db_decorator
 
 DJANGO_VERSION = DJANGO_VERSION[:2]
@@ -37,36 +50,6 @@ def client():
     return Client(application)
 
 
-@pytest.fixture
-def use_django_caching(settings):
-    settings.CACHES = {
-        "default": {
-            "BACKEND": "django.core.cache.backends.locmem.LocMemCache",
-            "LOCATION": "unique-snowflake-%s" % random.randint(1, 1000000),
-        }
-    }
-
-
-@pytest.fixture
-def use_django_caching_with_middlewares(settings):
-    settings.CACHES = {
-        "default": {
-            "BACKEND": "django.core.cache.backends.locmem.LocMemCache",
-            "LOCATION": "unique-snowflake-%s" % random.randint(1, 1000000),
-        }
-    }
-    if hasattr(settings, "MIDDLEWARE"):
-        middleware = settings.MIDDLEWARE
-    elif hasattr(settings, "MIDDLEWARE_CLASSES"):
-        middleware = settings.MIDDLEWARE_CLASSES
-    else:
-        middleware = None
-
-    if middleware is not None:
-        middleware.insert(0, "django.middleware.cache.UpdateCacheMiddleware")
-        middleware.append("django.middleware.cache.FetchFromCacheMiddleware")
-
-
 def test_view_exceptions(sentry_init, client, capture_exceptions, capture_events):
     sentry_init(integrations=[DjangoIntegration()], send_default_pii=True)
     exceptions = capture_exceptions()
@@ -132,8 +115,9 @@ def test_middleware_exceptions(sentry_init, client, capture_exceptions):
 def test_request_captured(sentry_init, client, capture_events):
     sentry_init(integrations=[DjangoIntegration()], send_default_pii=True)
     events = capture_events()
-    content, status, headers = client.get(reverse("message"))
-    assert b"".join(content) == b"ok"
+    content, status, headers = unpack_werkzeug_response(client.get(reverse("message")))
+
+    assert content == b"ok"
 
     (event,) = events
     assert event["transaction"] == "/message"
@@ -153,7 +137,9 @@ def test_transaction_with_class_view(sentry_init, client, capture_events):
         send_default_pii=True,
     )
     events = capture_events()
-    content, status, headers = client.head(reverse("classbased"))
+    content, status, headers = unpack_werkzeug_response(
+        client.head(reverse("classbased"))
+    )
     assert status.lower() == "200 ok"
 
     (event,) = events
@@ -166,7 +152,11 @@ def test_transaction_with_class_view(sentry_init, client, capture_events):
 
 def test_has_trace_if_performance_enabled(sentry_init, client, capture_events):
     sentry_init(
-        integrations=[DjangoIntegration()],
+        integrations=[
+            DjangoIntegration(
+                http_methods_to_capture=("HEAD",),
+            )
+        ],
         traces_sample_rate=1.0,
     )
     events = capture_events()
@@ -213,7 +203,11 @@ def test_has_trace_if_performance_disabled(sentry_init, client, capture_events):
 
 def test_trace_from_headers_if_performance_enabled(sentry_init, client, capture_events):
     sentry_init(
-        integrations=[DjangoIntegration()],
+        integrations=[
+            DjangoIntegration(
+                http_methods_to_capture=("HEAD",),
+            )
+        ],
         traces_sample_rate=1.0,
     )
 
@@ -246,7 +240,11 @@ def test_trace_from_headers_if_performance_disabled(
     sentry_init, client, capture_events
 ):
     sentry_init(
-        integrations=[DjangoIntegration()],
+        integrations=[
+            DjangoIntegration(
+                http_methods_to_capture=("HEAD",),
+            )
+        ],
     )
 
     events = capture_events()
@@ -271,17 +269,17 @@ def test_trace_from_headers_if_performance_disabled(
 
 
 @pytest.mark.forked
-@pytest.mark.django_db
+@pytest_mark_django_db_decorator()
 def test_user_captured(sentry_init, client, capture_events):
     sentry_init(integrations=[DjangoIntegration()], send_default_pii=True)
     events = capture_events()
-    content, status, headers = client.get(reverse("mylogin"))
-    assert b"".join(content) == b"ok"
+    content, status, headers = unpack_werkzeug_response(client.get(reverse("mylogin")))
+    assert content == b"ok"
 
     assert not events
 
-    content, status, headers = client.get(reverse("message"))
-    assert b"".join(content) == b"ok"
+    content, status, headers = unpack_werkzeug_response(client.get(reverse("message")))
+    assert content == b"ok"
 
     (event,) = events
 
@@ -293,7 +291,7 @@ def test_user_captured(sentry_init, client, capture_events):
 
 
 @pytest.mark.forked
-@pytest.mark.django_db
+@pytest_mark_django_db_decorator()
 def test_queryset_repr(sentry_init, capture_events):
     sentry_init(integrations=[DjangoIntegration()])
     events = capture_events()
@@ -315,10 +313,31 @@ def test_queryset_repr(sentry_init, capture_events):
     )
 
 
+@pytest.mark.forked
+@pytest_mark_django_db_decorator()
+def test_context_nested_queryset_repr(sentry_init, capture_events):
+    sentry_init(integrations=[DjangoIntegration()])
+    events = capture_events()
+    User.objects.create_user("john", "lennon@thebeatles.com", "johnpassword")
+
+    try:
+        context = make_context({"entries": User.objects.all()})  # noqa
+        1 / 0
+    except Exception:
+        capture_exception()
+
+    (event,) = events
+
+    (exception,) = event["exception"]["values"]
+    assert exception["type"] == "ZeroDivisionError"
+    (frame,) = exception["stacktrace"]["frames"]
+    assert "= (1, 10):
     EXPECTED_MIDDLEWARE_SPANS = """\
 - op="http.server": description=null
@@ -921,7 +1023,7 @@ def test_render_spans(sentry_init, client, capture_events, render_span_tree):
 def test_middleware_spans(sentry_init, client, capture_events, render_span_tree):
     sentry_init(
         integrations=[
-            DjangoIntegration(signals_spans=False),
+            DjangoIntegration(middleware_spans=True, signals_spans=False),
         ],
         traces_sample_rate=1.0,
     )
@@ -938,7 +1040,7 @@ def test_middleware_spans(sentry_init, client, capture_events, render_span_tree)
 def test_middleware_spans_disabled(sentry_init, client, capture_events):
     sentry_init(
         integrations=[
-            DjangoIntegration(middleware_spans=False, signals_spans=False),
+            DjangoIntegration(signals_spans=False),
         ],
         traces_sample_rate=1.0,
     )
@@ -952,14 +1054,7 @@ def test_middleware_spans_disabled(sentry_init, client, capture_events):
     assert not len(transaction["spans"])
 
 
-if DJANGO_VERSION >= (1, 10):
-    EXPECTED_SIGNALS_SPANS = """\
-- op="http.server": description=null
-  - op="event.django": description="django.db.reset_queries"
-  - op="event.django": description="django.db.close_old_connections"\
-"""
-else:
-    EXPECTED_SIGNALS_SPANS = """\
+EXPECTED_SIGNALS_SPANS = """\
 - op="http.server": description=null
   - op="event.django": description="django.db.reset_queries"
   - op="event.django": description="django.db.close_old_connections"\
@@ -1006,6 +1101,47 @@ def test_signals_spans_disabled(sentry_init, client, capture_events):
     assert not transaction["spans"]
 
 
+EXPECTED_SIGNALS_SPANS_FILTERED = """\
+- op="http.server": description=null
+  - op="event.django": description="django.db.reset_queries"
+  - op="event.django": description="django.db.close_old_connections"
+  - op="event.django": description="tests.integrations.django.myapp.signals.signal_handler"\
+"""
+
+
+def test_signals_spans_filtering(sentry_init, client, capture_events, render_span_tree):
+    sentry_init(
+        integrations=[
+            DjangoIntegration(
+                middleware_spans=False,
+                signals_denylist=[
+                    myapp_custom_signal_silenced,
+                ],
+            ),
+        ],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    client.get(reverse("send_myapp_custom_signal"))
+
+    (transaction,) = events
+
+    assert render_span_tree(transaction) == EXPECTED_SIGNALS_SPANS_FILTERED
+
+    assert transaction["spans"][0]["op"] == "event.django"
+    assert transaction["spans"][0]["description"] == "django.db.reset_queries"
+
+    assert transaction["spans"][1]["op"] == "event.django"
+    assert transaction["spans"][1]["description"] == "django.db.close_old_connections"
+
+    assert transaction["spans"][2]["op"] == "event.django"
+    assert (
+        transaction["spans"][2]["description"]
+        == "tests.integrations.django.myapp.signals.signal_handler"
+    )
+
+
 def test_csrf(sentry_init, client):
     """
     Assert that CSRF view decorator works even with the view wrapped in our own
@@ -1014,28 +1150,39 @@ def test_csrf(sentry_init, client):
 
     sentry_init(integrations=[DjangoIntegration()])
 
-    content, status, _headers = client.post(reverse("csrf_hello_not_exempt"))
+    content, status, _headers = unpack_werkzeug_response(
+        client.post(reverse("csrf_hello_not_exempt"))
+    )
     assert status.lower() == "403 forbidden"
 
-    content, status, _headers = client.post(reverse("sentryclass_csrf"))
+    content, status, _headers = unpack_werkzeug_response(
+        client.post(reverse("sentryclass_csrf"))
+    )
     assert status.lower() == "403 forbidden"
 
-    content, status, _headers = client.post(reverse("sentryclass"))
+    content, status, _headers = unpack_werkzeug_response(
+        client.post(reverse("sentryclass"))
+    )
     assert status.lower() == "200 ok"
-    assert b"".join(content) == b"ok"
+    assert content == b"ok"
 
-    content, status, _headers = client.post(reverse("classbased"))
+    content, status, _headers = unpack_werkzeug_response(
+        client.post(reverse("classbased"))
+    )
     assert status.lower() == "200 ok"
-    assert b"".join(content) == b"ok"
+    assert content == b"ok"
 
-    content, status, _headers = client.post(reverse("message"))
+    content, status, _headers = unpack_werkzeug_response(
+        client.post(reverse("message"))
+    )
     assert status.lower() == "200 ok"
-    assert b"".join(content) == b"ok"
+    assert content == b"ok"
 
 
 @pytest.mark.skipif(DJANGO_VERSION < (2, 0), reason="Requires Django > 2.0")
+@pytest.mark.parametrize("middleware_spans", [False, True])
 def test_custom_urlconf_middleware(
-    settings, sentry_init, client, capture_events, render_span_tree
+    settings, sentry_init, client, capture_events, render_span_tree, middleware_spans
 ):
     """
     Some middlewares (for instance in django-tenants) overwrite request.urlconf.
@@ -1046,25 +1193,30 @@ def test_custom_urlconf_middleware(
     settings.MIDDLEWARE.insert(0, urlconf)
     client.application.load_middleware()
 
-    sentry_init(integrations=[DjangoIntegration()], traces_sample_rate=1.0)
+    sentry_init(
+        integrations=[DjangoIntegration(middleware_spans=middleware_spans)],
+        traces_sample_rate=1.0,
+    )
     events = capture_events()
 
-    content, status, _headers = client.get("/custom/ok")
+    content, status, _headers = unpack_werkzeug_response(client.get("/custom/ok"))
     assert status.lower() == "200 ok"
-    assert b"".join(content) == b"custom ok"
+    assert content == b"custom ok"
 
     event = events.pop(0)
     assert event["transaction"] == "/custom/ok"
-    assert "custom_urlconf_middleware" in render_span_tree(event)
+    if middleware_spans:
+        assert "custom_urlconf_middleware" in render_span_tree(event)
 
-    _content, status, _headers = client.get("/custom/exc")
+    _content, status, _headers = unpack_werkzeug_response(client.get("/custom/exc"))
     assert status.lower() == "500 internal server error"
 
     error_event, transaction_event = events
     assert error_event["transaction"] == "/custom/exc"
     assert error_event["exception"]["values"][-1]["mechanism"]["type"] == "django"
     assert transaction_event["transaction"] == "/custom/exc"
-    assert "custom_urlconf_middleware" in render_span_tree(transaction_event)
+    if middleware_spans:
+        assert "custom_urlconf_middleware" in render_span_tree(transaction_event)
 
     settings.MIDDLEWARE.pop(0)
 
@@ -1075,13 +1227,10 @@ def dummy(a, b):
 
     name = _get_receiver_name(dummy)
 
-    if PY2:
-        assert name == "tests.integrations.django.test_basic.dummy"
-    else:
-        assert (
-            name
-            == "tests.integrations.django.test_basic.test_get_receiver_name..dummy"
-        )
+    assert (
+        name
+        == "tests.integrations.django.test_basic.test_get_receiver_name..dummy"
+    )
 
     a_partial = partial(dummy)
     name = _get_receiver_name(a_partial)
@@ -1091,238 +1240,174 @@ def dummy(a, b):
         assert name == "partial()"
 
 
-@pytest.mark.forked
-@pytest_mark_django_db_decorator()
-@pytest.mark.skipif(DJANGO_VERSION < (1, 9), reason="Requires Django >= 1.9")
-def test_cache_spans_disabled_middleware(
-    sentry_init, client, capture_events, use_django_caching_with_middlewares
-):
+@pytest.mark.skipif(DJANGO_VERSION <= (1, 11), reason="Requires Django > 1.11")
+def test_span_origin(sentry_init, client, capture_events):
     sentry_init(
         integrations=[
             DjangoIntegration(
-                cache_spans=False,
-                middleware_spans=False,
-                signals_spans=False,
+                middleware_spans=True,
+                signals_spans=True,
+                cache_spans=True,
             )
         ],
         traces_sample_rate=1.0,
     )
     events = capture_events()
 
-    client.get(reverse("not_cached_view"))
-    client.get(reverse("not_cached_view"))
+    client.get(reverse("view_with_signal"))
 
-    (first_event, second_event) = events
-    assert len(first_event["spans"]) == 0
-    assert len(second_event["spans"]) == 0
+    (transaction,) = events
 
+    assert transaction["contexts"]["trace"]["origin"] == "auto.http.django"
 
-@pytest.mark.forked
-@pytest_mark_django_db_decorator()
-@pytest.mark.skipif(DJANGO_VERSION < (1, 9), reason="Requires Django >= 1.9")
-def test_cache_spans_disabled_decorator(
-    sentry_init, client, capture_events, use_django_caching
-):
+    signal_span_found = False
+    for span in transaction["spans"]:
+        assert span["origin"] == "auto.http.django"
+        if span["op"] == "event.django":
+            signal_span_found = True
+
+    assert signal_span_found
+
+
+def test_transaction_http_method_default(sentry_init, client, capture_events):
+    """
+    By default OPTIONS and HEAD requests do not create a transaction.
+    """
     sentry_init(
-        integrations=[
-            DjangoIntegration(
-                cache_spans=False,
-                middleware_spans=False,
-                signals_spans=False,
-            )
-        ],
+        integrations=[DjangoIntegration()],
         traces_sample_rate=1.0,
     )
     events = capture_events()
 
-    client.get(reverse("cached_view"))
-    client.get(reverse("cached_view"))
+    client.get("/nomessage")
+    client.options("/nomessage")
+    client.head("/nomessage")
 
-    (first_event, second_event) = events
-    assert len(first_event["spans"]) == 0
-    assert len(second_event["spans"]) == 0
+    (event,) = events
 
+    assert len(events) == 1
+    assert event["request"]["method"] == "GET"
 
-@pytest.mark.forked
-@pytest_mark_django_db_decorator()
-@pytest.mark.skipif(DJANGO_VERSION < (1, 9), reason="Requires Django >= 1.9")
-def test_cache_spans_disabled_templatetag(
-    sentry_init, client, capture_events, use_django_caching
-):
+
+def test_transaction_http_method_custom(sentry_init, client, capture_events):
     sentry_init(
         integrations=[
             DjangoIntegration(
-                cache_spans=False,
-                middleware_spans=False,
-                signals_spans=False,
+                http_methods_to_capture=(
+                    "OPTIONS",
+                    "head",
+                ),  # capitalization does not matter
             )
         ],
         traces_sample_rate=1.0,
     )
     events = capture_events()
 
-    client.get(reverse("view_with_cached_template_fragment"))
-    client.get(reverse("view_with_cached_template_fragment"))
+    client.get("/nomessage")
+    client.options("/nomessage")
+    client.head("/nomessage")
 
-    (first_event, second_event) = events
-    assert len(first_event["spans"]) == 0
-    assert len(second_event["spans"]) == 0
+    assert len(events) == 2
 
+    (event1, event2) = events
+    assert event1["request"]["method"] == "OPTIONS"
+    assert event2["request"]["method"] == "HEAD"
 
-@pytest.mark.forked
-@pytest_mark_django_db_decorator()
-@pytest.mark.skipif(DJANGO_VERSION < (1, 9), reason="Requires Django >= 1.9")
-def test_cache_spans_middleware(
-    sentry_init, client, capture_events, use_django_caching_with_middlewares
+
+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.
+    """
+    settings.DEBUG = True
+    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
 ):
-    sentry_init(
-        integrations=[
-            DjangoIntegration(
-                cache_spans=True,
-                middleware_spans=False,
-                signals_spans=False,
-            )
-        ],
-        traces_sample_rate=1.0,
-    )
+    """
+    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.
+    """
+    settings.DEBUG = True
+    monkeypatch.setenv("SENTRY_SPOTLIGHT_ON_ERROR", "no")
 
-    client.application.load_middleware()
-    events = capture_events()
+    original_middleware = frozenset(settings.MIDDLEWARE)
 
-    client.get(reverse("not_cached_view"))
-    client.get(reverse("not_cached_view"))
+    sentry_init(integrations=[DjangoIntegration()], spotlight=True)
 
-    (first_event, second_event) = events
-    assert len(first_event["spans"]) == 1
-    assert first_event["spans"][0]["op"] == "cache.get_item"
-    assert first_event["spans"][0]["description"].startswith(
-        "get views.decorators.cache.cache_header."
-    )
-    assert first_event["spans"][0]["data"] == {"cache.hit": False}
+    added = frozenset(settings.MIDDLEWARE) ^ original_middleware
 
-    assert len(second_event["spans"]) == 2
-    assert second_event["spans"][0]["op"] == "cache.get_item"
-    assert second_event["spans"][0]["description"].startswith(
-        "get views.decorators.cache.cache_header."
-    )
-    assert second_event["spans"][0]["data"] == {"cache.hit": False}
+    assert "sentry_sdk.spotlight.SpotlightMiddleware" not in added
 
-    assert second_event["spans"][1]["op"] == "cache.get_item"
-    assert second_event["spans"][1]["description"].startswith(
-        "get views.decorators.cache.cache_page."
-    )
-    assert second_event["spans"][1]["data"]["cache.hit"]
-    assert "cache.item_size" in second_event["spans"][1]["data"]
 
+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.
+    """
+    settings.DEBUG = True
 
-@pytest.mark.forked
-@pytest_mark_django_db_decorator()
-@pytest.mark.skipif(DJANGO_VERSION < (1, 9), reason="Requires Django >= 1.9")
-def test_cache_spans_decorator(sentry_init, client, capture_events, use_django_caching):
-    sentry_init(
-        integrations=[
-            DjangoIntegration(
-                cache_spans=True,
-                middleware_spans=False,
-                signals_spans=False,
-            )
-        ],
-        traces_sample_rate=1.0,
-    )
-    events = capture_events()
+    # We should NOT have the middleware even if the env var is truthy if Spotlight is off
+    monkeypatch.setenv("SENTRY_SPOTLIGHT_ON_ERROR", "1")
 
-    client.get(reverse("cached_view"))
-    client.get(reverse("cached_view"))
+    original_middleware = frozenset(settings.MIDDLEWARE)
 
-    (first_event, second_event) = events
-    assert len(first_event["spans"]) == 1
-    assert first_event["spans"][0]["op"] == "cache.get_item"
-    assert first_event["spans"][0]["description"].startswith(
-        "get views.decorators.cache.cache_header."
-    )
-    assert first_event["spans"][0]["data"] == {"cache.hit": False}
+    sentry_init(integrations=[DjangoIntegration()], spotlight=False)
 
-    assert len(second_event["spans"]) == 2
-    assert second_event["spans"][0]["op"] == "cache.get_item"
-    assert second_event["spans"][0]["description"].startswith(
-        "get views.decorators.cache.cache_header."
-    )
-    assert second_event["spans"][0]["data"] == {"cache.hit": False}
+    added = frozenset(settings.MIDDLEWARE) ^ original_middleware
 
-    assert second_event["spans"][1]["op"] == "cache.get_item"
-    assert second_event["spans"][1]["description"].startswith(
-        "get views.decorators.cache.cache_page."
-    )
-    assert second_event["spans"][1]["data"]["cache.hit"]
-    assert "cache.item_size" in second_event["spans"][1]["data"]
+    assert "sentry_sdk.spotlight.SpotlightMiddleware" not in added
 
 
-@pytest.mark.forked
-@pytest_mark_django_db_decorator()
-@pytest.mark.skipif(DJANGO_VERSION < (1, 9), reason="Requires Django >= 1.9")
-def test_cache_spans_templatetag(
-    sentry_init, client, capture_events, use_django_caching
-):
-    sentry_init(
-        integrations=[
-            DjangoIntegration(
-                cache_spans=True,
-                middleware_spans=False,
-                signals_spans=False,
-            )
-        ],
-        traces_sample_rate=1.0,
-    )
-    events = capture_events()
+def test_get_frame_name_when_in_lazy_object():
+    allowed_to_init = False
 
-    client.get(reverse("view_with_cached_template_fragment"))
-    client.get(reverse("view_with_cached_template_fragment"))
+    class SimpleLazyObjectWrapper(SimpleLazyObject):
+        def unproxied_method(self):
+            """
+            For testing purposes. We inject a method on the SimpleLazyObject
+            class so if python is executing this method, we should get
+            this class instead of the wrapped class and avoid evaluating
+            the wrapped object too early.
+            """
+            return inspect.currentframe()
 
-    (first_event, second_event) = events
-    assert len(first_event["spans"]) == 1
-    assert first_event["spans"][0]["op"] == "cache.get_item"
-    assert first_event["spans"][0]["description"].startswith(
-        "get template.cache.some_identifier."
-    )
-    assert first_event["spans"][0]["data"] == {"cache.hit": False}
+    class GetFrame:
+        def __init__(self):
+            assert allowed_to_init, "GetFrame not permitted to initialize yet"
+
+        def proxied_method(self):
+            """
+            For testing purposes. We add an proxied method on the instance
+            class so if python is executing this method, we should get
+            this class instead of the wrapper class.
+            """
+            return inspect.currentframe()
 
-    assert len(second_event["spans"]) == 1
-    assert second_event["spans"][0]["op"] == "cache.get_item"
-    assert second_event["spans"][0]["description"].startswith(
-        "get template.cache.some_identifier."
+    instance = SimpleLazyObjectWrapper(lambda: GetFrame())
+
+    assert get_frame_name(instance.unproxied_method()) == (
+        "SimpleLazyObjectWrapper.unproxied_method"
+        if sys.version_info < (3, 11)
+        else "test_get_frame_name_when_in_lazy_object..SimpleLazyObjectWrapper.unproxied_method"
     )
-    assert second_event["spans"][0]["data"]["cache.hit"]
-    assert "cache.item_size" in second_event["spans"][0]["data"]
 
+    # Now that we're about to access an instance method on the wrapped class,
+    # we should permit initializing it
+    allowed_to_init = True
 
-@pytest.mark.parametrize(
-    "method_name, args, kwargs, expected_description",
-    [
-        ("get", None, None, "get "),
-        ("get", [], {}, "get "),
-        ("get", ["bla", "blub", "foo"], {}, "get bla"),
-        (
-            "get_many",
-            [["bla 1", "bla 2", "bla 3"], "blub", "foo"],
-            {},
-            "get_many ['bla 1', 'bla 2', 'bla 3']",
-        ),
-        (
-            "get_many",
-            [["bla 1", "bla 2", "bla 3"], "blub", "foo"],
-            {"key": "bar"},
-            "get_many ['bla 1', 'bla 2', 'bla 3']",
-        ),
-        ("get", [], {"key": "bar"}, "get bar"),
-        (
-            "get",
-            "something",
-            {},
-            "get s",
-        ),  # this should never happen, just making sure that we are not raising an exception in that case.
-    ],
-)
-def test_cache_spans_get_span_description(
-    method_name, args, kwargs, expected_description
-):
-    assert _get_span_description(method_name, args, kwargs) == expected_description
+    assert get_frame_name(instance.proxied_method()) == (
+        "GetFrame.proxied_method"
+        if sys.version_info < (3, 11)
+        else "test_get_frame_name_when_in_lazy_object..GetFrame.proxied_method"
+    )
diff --git a/tests/integrations/django/test_cache_module.py b/tests/integrations/django/test_cache_module.py
new file mode 100644
index 0000000000..01b97c1302
--- /dev/null
+++ b/tests/integrations/django/test_cache_module.py
@@ -0,0 +1,696 @@
+import os
+import random
+import uuid
+
+import pytest
+from django import VERSION as DJANGO_VERSION
+from werkzeug.test import Client
+
+try:
+    from django.urls import reverse
+except ImportError:
+    from django.core.urlresolvers import reverse
+
+import sentry_sdk
+from sentry_sdk.integrations.django import DjangoIntegration
+from sentry_sdk.integrations.django.caching import _get_span_description
+from tests.integrations.django.myapp.wsgi import application
+from tests.integrations.django.utils import pytest_mark_django_db_decorator
+
+
+DJANGO_VERSION = DJANGO_VERSION[:2]
+
+
+@pytest.fixture
+def client():
+    return Client(application)
+
+
+@pytest.fixture
+def use_django_caching(settings):
+    settings.CACHES = {
+        "default": {
+            "BACKEND": "django.core.cache.backends.locmem.LocMemCache",
+            "LOCATION": "unique-snowflake-%s" % random.randint(1, 1000000),
+        }
+    }
+
+
+@pytest.fixture
+def use_django_caching_with_middlewares(settings):
+    settings.CACHES = {
+        "default": {
+            "BACKEND": "django.core.cache.backends.locmem.LocMemCache",
+            "LOCATION": "unique-snowflake-%s" % random.randint(1, 1000000),
+        }
+    }
+    if hasattr(settings, "MIDDLEWARE"):
+        middleware = settings.MIDDLEWARE
+    elif hasattr(settings, "MIDDLEWARE_CLASSES"):
+        middleware = settings.MIDDLEWARE_CLASSES
+    else:
+        middleware = None
+
+    if middleware is not None:
+        middleware.insert(0, "django.middleware.cache.UpdateCacheMiddleware")
+        middleware.append("django.middleware.cache.FetchFromCacheMiddleware")
+
+
+@pytest.fixture
+def use_django_caching_with_port(settings):
+    settings.CACHES = {
+        "default": {
+            "BACKEND": "django.core.cache.backends.dummy.DummyCache",
+            "LOCATION": "redis://username:password@127.0.0.1:6379",
+        }
+    }
+
+
+@pytest.fixture
+def use_django_caching_without_port(settings):
+    settings.CACHES = {
+        "default": {
+            "BACKEND": "django.core.cache.backends.dummy.DummyCache",
+            "LOCATION": "redis://example.com",
+        }
+    }
+
+
+@pytest.fixture
+def use_django_caching_with_cluster(settings):
+    settings.CACHES = {
+        "default": {
+            "BACKEND": "django.core.cache.backends.dummy.DummyCache",
+            "LOCATION": [
+                "redis://127.0.0.1:6379",
+                "redis://127.0.0.2:6378",
+                "redis://127.0.0.3:6377",
+            ],
+        }
+    }
+
+
+@pytest.mark.forked
+@pytest_mark_django_db_decorator()
+@pytest.mark.skipif(DJANGO_VERSION < (1, 9), reason="Requires Django >= 1.9")
+def test_cache_spans_disabled_middleware(
+    sentry_init, client, capture_events, use_django_caching_with_middlewares
+):
+    sentry_init(
+        integrations=[
+            DjangoIntegration(
+                cache_spans=False,
+                middleware_spans=False,
+                signals_spans=False,
+            )
+        ],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    client.get(reverse("not_cached_view"))
+    client.get(reverse("not_cached_view"))
+
+    (first_event, second_event) = events
+    assert len(first_event["spans"]) == 0
+    assert len(second_event["spans"]) == 0
+
+
+@pytest.mark.forked
+@pytest_mark_django_db_decorator()
+@pytest.mark.skipif(DJANGO_VERSION < (1, 9), reason="Requires Django >= 1.9")
+def test_cache_spans_disabled_decorator(
+    sentry_init, client, capture_events, use_django_caching
+):
+    sentry_init(
+        integrations=[
+            DjangoIntegration(
+                cache_spans=False,
+                middleware_spans=False,
+                signals_spans=False,
+            )
+        ],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    client.get(reverse("cached_view"))
+    client.get(reverse("cached_view"))
+
+    (first_event, second_event) = events
+    assert len(first_event["spans"]) == 0
+    assert len(second_event["spans"]) == 0
+
+
+@pytest.mark.forked
+@pytest_mark_django_db_decorator()
+@pytest.mark.skipif(DJANGO_VERSION < (1, 9), reason="Requires Django >= 1.9")
+def test_cache_spans_disabled_templatetag(
+    sentry_init, client, capture_events, use_django_caching
+):
+    sentry_init(
+        integrations=[
+            DjangoIntegration(
+                cache_spans=False,
+                middleware_spans=False,
+                signals_spans=False,
+            )
+        ],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    client.get(reverse("view_with_cached_template_fragment"))
+    client.get(reverse("view_with_cached_template_fragment"))
+
+    (first_event, second_event) = events
+    assert len(first_event["spans"]) == 0
+    assert len(second_event["spans"]) == 0
+
+
+@pytest.mark.forked
+@pytest_mark_django_db_decorator()
+@pytest.mark.skipif(DJANGO_VERSION < (1, 9), reason="Requires Django >= 1.9")
+def test_cache_spans_middleware(
+    sentry_init, client, capture_events, use_django_caching_with_middlewares
+):
+    sentry_init(
+        integrations=[
+            DjangoIntegration(
+                cache_spans=True,
+                middleware_spans=False,
+                signals_spans=False,
+            )
+        ],
+        traces_sample_rate=1.0,
+    )
+
+    client.application.load_middleware()
+    events = capture_events()
+
+    client.get(reverse("not_cached_view"))
+    client.get(reverse("not_cached_view"))
+
+    (first_event, second_event) = events
+    # first_event - cache.get
+    assert first_event["spans"][0]["op"] == "cache.get"
+    assert first_event["spans"][0]["description"].startswith(
+        "views.decorators.cache.cache_header."
+    )
+    assert first_event["spans"][0]["data"]["network.peer.address"] is not None
+    assert first_event["spans"][0]["data"]["cache.key"][0].startswith(
+        "views.decorators.cache.cache_header."
+    )
+    assert not first_event["spans"][0]["data"]["cache.hit"]
+    assert "cache.item_size" not in first_event["spans"][0]["data"]
+    # first_event - cache.put
+    assert first_event["spans"][1]["op"] == "cache.put"
+    assert first_event["spans"][1]["description"].startswith(
+        "views.decorators.cache.cache_header."
+    )
+    assert first_event["spans"][1]["data"]["network.peer.address"] is not None
+    assert first_event["spans"][1]["data"]["cache.key"][0].startswith(
+        "views.decorators.cache.cache_header."
+    )
+    assert "cache.hit" not in first_event["spans"][1]["data"]
+    assert first_event["spans"][1]["data"]["cache.item_size"] == 2
+    # second_event - cache.get
+    assert second_event["spans"][0]["op"] == "cache.get"
+    assert second_event["spans"][0]["description"].startswith(
+        "views.decorators.cache.cache_header."
+    )
+    assert second_event["spans"][0]["data"]["network.peer.address"] is not None
+    assert second_event["spans"][0]["data"]["cache.key"][0].startswith(
+        "views.decorators.cache.cache_header."
+    )
+    assert second_event["spans"][0]["data"]["cache.hit"]
+    assert second_event["spans"][0]["data"]["cache.item_size"] == 2
+    # second_event - cache.get 2
+    assert second_event["spans"][1]["op"] == "cache.get"
+    assert second_event["spans"][1]["description"].startswith(
+        "views.decorators.cache.cache_page."
+    )
+    assert second_event["spans"][1]["data"]["network.peer.address"] is not None
+    assert second_event["spans"][1]["data"]["cache.key"][0].startswith(
+        "views.decorators.cache.cache_page."
+    )
+    assert second_event["spans"][1]["data"]["cache.hit"]
+    assert second_event["spans"][1]["data"]["cache.item_size"] == 58
+
+
+@pytest.mark.forked
+@pytest_mark_django_db_decorator()
+@pytest.mark.skipif(DJANGO_VERSION < (1, 9), reason="Requires Django >= 1.9")
+def test_cache_spans_decorator(sentry_init, client, capture_events, use_django_caching):
+    sentry_init(
+        integrations=[
+            DjangoIntegration(
+                cache_spans=True,
+                middleware_spans=False,
+                signals_spans=False,
+            )
+        ],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    client.get(reverse("cached_view"))
+    client.get(reverse("cached_view"))
+
+    (first_event, second_event) = events
+    # first_event - cache.get
+    assert first_event["spans"][0]["op"] == "cache.get"
+    assert first_event["spans"][0]["description"].startswith(
+        "views.decorators.cache.cache_header."
+    )
+    assert first_event["spans"][0]["data"]["network.peer.address"] is not None
+    assert first_event["spans"][0]["data"]["cache.key"][0].startswith(
+        "views.decorators.cache.cache_header."
+    )
+    assert not first_event["spans"][0]["data"]["cache.hit"]
+    assert "cache.item_size" not in first_event["spans"][0]["data"]
+    # first_event - cache.put
+    assert first_event["spans"][1]["op"] == "cache.put"
+    assert first_event["spans"][1]["description"].startswith(
+        "views.decorators.cache.cache_header."
+    )
+    assert first_event["spans"][1]["data"]["network.peer.address"] is not None
+    assert first_event["spans"][1]["data"]["cache.key"][0].startswith(
+        "views.decorators.cache.cache_header."
+    )
+    assert "cache.hit" not in first_event["spans"][1]["data"]
+    assert first_event["spans"][1]["data"]["cache.item_size"] == 2
+    # second_event - cache.get
+    assert second_event["spans"][1]["op"] == "cache.get"
+    assert second_event["spans"][1]["description"].startswith(
+        "views.decorators.cache.cache_page."
+    )
+    assert second_event["spans"][1]["data"]["network.peer.address"] is not None
+    assert second_event["spans"][1]["data"]["cache.key"][0].startswith(
+        "views.decorators.cache.cache_page."
+    )
+    assert second_event["spans"][1]["data"]["cache.hit"]
+    assert second_event["spans"][1]["data"]["cache.item_size"] == 58
+
+
+@pytest.mark.forked
+@pytest_mark_django_db_decorator()
+@pytest.mark.skipif(DJANGO_VERSION < (1, 9), reason="Requires Django >= 1.9")
+def test_cache_spans_templatetag(
+    sentry_init, client, capture_events, use_django_caching
+):
+    sentry_init(
+        integrations=[
+            DjangoIntegration(
+                cache_spans=True,
+                middleware_spans=False,
+                signals_spans=False,
+            )
+        ],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    client.get(reverse("view_with_cached_template_fragment"))
+    client.get(reverse("view_with_cached_template_fragment"))
+
+    (first_event, second_event) = events
+    assert len(first_event["spans"]) == 2
+    # first_event - cache.get
+    assert first_event["spans"][0]["op"] == "cache.get"
+    assert first_event["spans"][0]["description"].startswith(
+        "template.cache.some_identifier."
+    )
+    assert first_event["spans"][0]["data"]["network.peer.address"] is not None
+    assert first_event["spans"][0]["data"]["cache.key"][0].startswith(
+        "template.cache.some_identifier."
+    )
+    assert not first_event["spans"][0]["data"]["cache.hit"]
+    assert "cache.item_size" not in first_event["spans"][0]["data"]
+    # first_event - cache.put
+    assert first_event["spans"][1]["op"] == "cache.put"
+    assert first_event["spans"][1]["description"].startswith(
+        "template.cache.some_identifier."
+    )
+    assert first_event["spans"][1]["data"]["network.peer.address"] is not None
+    assert first_event["spans"][1]["data"]["cache.key"][0].startswith(
+        "template.cache.some_identifier."
+    )
+    assert "cache.hit" not in first_event["spans"][1]["data"]
+    assert first_event["spans"][1]["data"]["cache.item_size"] == 51
+    # second_event - cache.get
+    assert second_event["spans"][0]["op"] == "cache.get"
+    assert second_event["spans"][0]["description"].startswith(
+        "template.cache.some_identifier."
+    )
+    assert second_event["spans"][0]["data"]["network.peer.address"] is not None
+    assert second_event["spans"][0]["data"]["cache.key"][0].startswith(
+        "template.cache.some_identifier."
+    )
+    assert second_event["spans"][0]["data"]["cache.hit"]
+    assert second_event["spans"][0]["data"]["cache.item_size"] == 51
+
+
+@pytest.mark.parametrize(
+    "method_name, args, kwargs, expected_description",
+    [
+        (None, None, None, ""),
+        ("get", None, None, ""),
+        ("get", [], {}, ""),
+        ("get", ["bla", "blub", "foo"], {}, "bla"),
+        ("get", [uuid.uuid4().bytes], {}, ""),
+        (
+            "get_many",
+            [["bla1", "bla2", "bla3"], "blub", "foo"],
+            {},
+            "bla1, bla2, bla3",
+        ),
+        (
+            "get_many",
+            [["bla:1", "bla:2", "bla:3"], "blub", "foo"],
+            {"key": "bar"},
+            "bla:1, bla:2, bla:3",
+        ),
+        ("get", [], {"key": "bar"}, "bar"),
+        (
+            "get",
+            "something",
+            {},
+            "s",
+        ),  # this case should never happen, just making sure that we are not raising an exception in that case.
+    ],
+)
+def test_cache_spans_get_span_description(
+    method_name, args, kwargs, expected_description
+):
+    assert _get_span_description(method_name, args, kwargs) == expected_description
+
+
+@pytest.mark.forked
+@pytest_mark_django_db_decorator()
+def test_cache_spans_location_with_port(
+    sentry_init, client, capture_events, use_django_caching_with_port
+):
+    sentry_init(
+        integrations=[
+            DjangoIntegration(
+                cache_spans=True,
+                middleware_spans=False,
+                signals_spans=False,
+            )
+        ],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    client.get(reverse("cached_view"))
+    client.get(reverse("cached_view"))
+
+    for event in events:
+        for span in event["spans"]:
+            assert (
+                span["data"]["network.peer.address"] == "redis://127.0.0.1"
+            )  # Note: the username/password are not included in the address
+            assert span["data"]["network.peer.port"] == 6379
+
+
+@pytest.mark.forked
+@pytest_mark_django_db_decorator()
+def test_cache_spans_location_without_port(
+    sentry_init, client, capture_events, use_django_caching_without_port
+):
+    sentry_init(
+        integrations=[
+            DjangoIntegration(
+                cache_spans=True,
+                middleware_spans=False,
+                signals_spans=False,
+            )
+        ],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    client.get(reverse("cached_view"))
+    client.get(reverse("cached_view"))
+
+    for event in events:
+        for span in event["spans"]:
+            assert span["data"]["network.peer.address"] == "redis://example.com"
+            assert "network.peer.port" not in span["data"]
+
+
+@pytest.mark.forked
+@pytest_mark_django_db_decorator()
+def test_cache_spans_location_with_cluster(
+    sentry_init, client, capture_events, use_django_caching_with_cluster
+):
+    sentry_init(
+        integrations=[
+            DjangoIntegration(
+                cache_spans=True,
+                middleware_spans=False,
+                signals_spans=False,
+            )
+        ],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    client.get(reverse("cached_view"))
+    client.get(reverse("cached_view"))
+
+    for event in events:
+        for span in event["spans"]:
+            # because it is a cluster we do not know what host is actually accessed, so we omit the data
+            assert "network.peer.address" not in span["data"].keys()
+            assert "network.peer.port" not in span["data"].keys()
+
+
+@pytest.mark.forked
+@pytest_mark_django_db_decorator()
+def test_cache_spans_item_size(sentry_init, client, capture_events, use_django_caching):
+    sentry_init(
+        integrations=[
+            DjangoIntegration(
+                cache_spans=True,
+                middleware_spans=False,
+                signals_spans=False,
+            )
+        ],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    client.get(reverse("cached_view"))
+    client.get(reverse("cached_view"))
+
+    (first_event, second_event) = events
+    assert len(first_event["spans"]) == 3
+    assert first_event["spans"][0]["op"] == "cache.get"
+    assert not first_event["spans"][0]["data"]["cache.hit"]
+    assert "cache.item_size" not in first_event["spans"][0]["data"]
+
+    assert first_event["spans"][1]["op"] == "cache.put"
+    assert "cache.hit" not in first_event["spans"][1]["data"]
+    assert first_event["spans"][1]["data"]["cache.item_size"] == 2
+
+    assert first_event["spans"][2]["op"] == "cache.put"
+    assert "cache.hit" not in first_event["spans"][2]["data"]
+    assert first_event["spans"][2]["data"]["cache.item_size"] == 58
+
+    assert len(second_event["spans"]) == 2
+    assert second_event["spans"][0]["op"] == "cache.get"
+    assert second_event["spans"][0]["data"]["cache.hit"]
+    assert second_event["spans"][0]["data"]["cache.item_size"] == 2
+
+    assert second_event["spans"][1]["op"] == "cache.get"
+    assert second_event["spans"][1]["data"]["cache.hit"]
+    assert second_event["spans"][1]["data"]["cache.item_size"] == 58
+
+
+@pytest.mark.forked
+@pytest_mark_django_db_decorator()
+def test_cache_spans_get_custom_default(
+    sentry_init, capture_events, use_django_caching
+):
+    sentry_init(
+        integrations=[
+            DjangoIntegration(
+                cache_spans=True,
+                middleware_spans=False,
+                signals_spans=False,
+            )
+        ],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    id = os.getpid()
+
+    from django.core.cache import cache
+
+    with sentry_sdk.start_transaction():
+        cache.set(f"S{id}", "Sensitive1")
+        cache.set(f"S{id + 1}", "")
+
+        cache.get(f"S{id}", "null")
+        cache.get(f"S{id}", default="null")
+
+        cache.get(f"S{id + 1}", "null")
+        cache.get(f"S{id + 1}", default="null")
+
+        cache.get(f"S{id + 2}", "null")
+        cache.get(f"S{id + 2}", default="null")
+
+    (transaction,) = events
+    assert len(transaction["spans"]) == 8
+
+    assert transaction["spans"][0]["op"] == "cache.put"
+    assert transaction["spans"][0]["description"] == f"S{id}"
+
+    assert transaction["spans"][1]["op"] == "cache.put"
+    assert transaction["spans"][1]["description"] == f"S{id + 1}"
+
+    for span in (transaction["spans"][2], transaction["spans"][3]):
+        assert span["op"] == "cache.get"
+        assert span["description"] == f"S{id}"
+        assert span["data"]["cache.hit"]
+        assert span["data"]["cache.item_size"] == 10
+
+    for span in (transaction["spans"][4], transaction["spans"][5]):
+        assert span["op"] == "cache.get"
+        assert span["description"] == f"S{id + 1}"
+        assert span["data"]["cache.hit"]
+        assert span["data"]["cache.item_size"] == 0
+
+    for span in (transaction["spans"][6], transaction["spans"][7]):
+        assert span["op"] == "cache.get"
+        assert span["description"] == f"S{id + 2}"
+        assert not span["data"]["cache.hit"]
+        assert "cache.item_size" not in span["data"]
+
+
+@pytest.mark.forked
+@pytest_mark_django_db_decorator()
+def test_cache_spans_get_many(sentry_init, capture_events, use_django_caching):
+    sentry_init(
+        integrations=[
+            DjangoIntegration(
+                cache_spans=True,
+                middleware_spans=False,
+                signals_spans=False,
+            )
+        ],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    id = os.getpid()
+
+    from django.core.cache import cache
+
+    with sentry_sdk.start_transaction():
+        cache.get_many([f"S{id}", f"S{id + 1}"])
+        cache.set(f"S{id}", "Sensitive1")
+        cache.get_many([f"S{id}", f"S{id + 1}"])
+
+    (transaction,) = events
+    assert len(transaction["spans"]) == 7
+
+    assert transaction["spans"][0]["op"] == "cache.get"
+    assert transaction["spans"][0]["description"] == f"S{id}, S{id + 1}"
+    assert not transaction["spans"][0]["data"]["cache.hit"]
+
+    assert transaction["spans"][1]["op"] == "cache.get"
+    assert transaction["spans"][1]["description"] == f"S{id}"
+    assert not transaction["spans"][1]["data"]["cache.hit"]
+
+    assert transaction["spans"][2]["op"] == "cache.get"
+    assert transaction["spans"][2]["description"] == f"S{id + 1}"
+    assert not transaction["spans"][2]["data"]["cache.hit"]
+
+    assert transaction["spans"][3]["op"] == "cache.put"
+    assert transaction["spans"][3]["description"] == f"S{id}"
+
+    assert transaction["spans"][4]["op"] == "cache.get"
+    assert transaction["spans"][4]["description"] == f"S{id}, S{id + 1}"
+    assert transaction["spans"][4]["data"]["cache.hit"]
+
+    assert transaction["spans"][5]["op"] == "cache.get"
+    assert transaction["spans"][5]["description"] == f"S{id}"
+    assert transaction["spans"][5]["data"]["cache.hit"]
+
+    assert transaction["spans"][6]["op"] == "cache.get"
+    assert transaction["spans"][6]["description"] == f"S{id + 1}"
+    assert not transaction["spans"][6]["data"]["cache.hit"]
+
+
+@pytest.mark.forked
+@pytest_mark_django_db_decorator()
+def test_cache_spans_set_many(sentry_init, capture_events, use_django_caching):
+    sentry_init(
+        integrations=[
+            DjangoIntegration(
+                cache_spans=True,
+                middleware_spans=False,
+                signals_spans=False,
+            )
+        ],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    id = os.getpid()
+
+    from django.core.cache import cache
+
+    with sentry_sdk.start_transaction():
+        cache.set_many({f"S{id}": "Sensitive1", f"S{id + 1}": "Sensitive2"})
+        cache.get(f"S{id}")
+
+    (transaction,) = events
+    assert len(transaction["spans"]) == 4
+
+    assert transaction["spans"][0]["op"] == "cache.put"
+    assert transaction["spans"][0]["description"] == f"S{id}, S{id + 1}"
+
+    assert transaction["spans"][1]["op"] == "cache.put"
+    assert transaction["spans"][1]["description"] == f"S{id}"
+
+    assert transaction["spans"][2]["op"] == "cache.put"
+    assert transaction["spans"][2]["description"] == f"S{id + 1}"
+
+    assert transaction["spans"][3]["op"] == "cache.get"
+    assert transaction["spans"][3]["description"] == f"S{id}"
+
+
+@pytest.mark.forked
+@pytest_mark_django_db_decorator()
+@pytest.mark.skipif(DJANGO_VERSION <= (1, 11), reason="Requires Django > 1.11")
+def test_span_origin_cache(sentry_init, client, capture_events, use_django_caching):
+    sentry_init(
+        integrations=[
+            DjangoIntegration(
+                middleware_spans=True,
+                signals_spans=True,
+                cache_spans=True,
+            )
+        ],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    client.get(reverse("cached_view"))
+
+    (transaction,) = events
+
+    assert transaction["contexts"]["trace"]["origin"] == "auto.http.django"
+
+    cache_span_found = False
+    for span in transaction["spans"]:
+        assert span["origin"] == "auto.http.django"
+        if span["op"].startswith("cache."):
+            cache_span_found = True
+
+    assert cache_span_found
diff --git a/tests/integrations/django/test_data_scrubbing.py b/tests/integrations/django/test_data_scrubbing.py
index b3e531183f..128da9b97e 100644
--- a/tests/integrations/django/test_data_scrubbing.py
+++ b/tests/integrations/django/test_data_scrubbing.py
@@ -3,6 +3,7 @@
 from werkzeug.test import Client
 
 from sentry_sdk.integrations.django import DjangoIntegration
+from tests.conftest import werkzeug_set_cookie
 from tests.integrations.django.myapp.wsgi import application
 from tests.integrations.django.utils import pytest_mark_django_db_decorator
 
@@ -26,9 +27,9 @@ def test_scrub_django_session_cookies_removed(
 ):
     sentry_init(integrations=[DjangoIntegration()], send_default_pii=False)
     events = capture_events()
-    client.set_cookie("localhost", "sessionid", "123")
-    client.set_cookie("localhost", "csrftoken", "456")
-    client.set_cookie("localhost", "foo", "bar")
+    werkzeug_set_cookie(client, "localhost", "sessionid", "123")
+    werkzeug_set_cookie(client, "localhost", "csrftoken", "456")
+    werkzeug_set_cookie(client, "localhost", "foo", "bar")
     client.get(reverse("view_exc"))
 
     (event,) = events
@@ -44,9 +45,9 @@ def test_scrub_django_session_cookies_filtered(
 ):
     sentry_init(integrations=[DjangoIntegration()], send_default_pii=True)
     events = capture_events()
-    client.set_cookie("localhost", "sessionid", "123")
-    client.set_cookie("localhost", "csrftoken", "456")
-    client.set_cookie("localhost", "foo", "bar")
+    werkzeug_set_cookie(client, "localhost", "sessionid", "123")
+    werkzeug_set_cookie(client, "localhost", "csrftoken", "456")
+    werkzeug_set_cookie(client, "localhost", "foo", "bar")
     client.get(reverse("view_exc"))
 
     (event,) = events
@@ -70,9 +71,9 @@ def test_scrub_django_custom_session_cookies_filtered(
 
     sentry_init(integrations=[DjangoIntegration()], send_default_pii=True)
     events = capture_events()
-    client.set_cookie("localhost", "my_sess", "123")
-    client.set_cookie("localhost", "csrf_secret", "456")
-    client.set_cookie("localhost", "foo", "bar")
+    werkzeug_set_cookie(client, "localhost", "my_sess", "123")
+    werkzeug_set_cookie(client, "localhost", "csrf_secret", "456")
+    werkzeug_set_cookie(client, "localhost", "foo", "bar")
     client.get(reverse("view_exc"))
 
     (event,) = events
diff --git a/tests/integrations/django/test_db_query_data.py b/tests/integrations/django/test_db_query_data.py
new file mode 100644
index 0000000000..41ad9d5e1c
--- /dev/null
+++ b/tests/integrations/django/test_db_query_data.py
@@ -0,0 +1,526 @@
+import os
+
+import pytest
+from datetime import datetime
+from unittest import mock
+
+from django import VERSION as DJANGO_VERSION
+from django.db import connections
+
+try:
+    from django.urls import reverse
+except ImportError:
+    from django.core.urlresolvers import reverse
+
+from werkzeug.test import Client
+
+from sentry_sdk import start_transaction
+from sentry_sdk.consts import SPANDATA
+from sentry_sdk.integrations.django import DjangoIntegration
+from sentry_sdk.tracing_utils import record_sql_queries
+
+from tests.conftest import unpack_werkzeug_response
+from tests.integrations.django.utils import pytest_mark_django_db_decorator
+from tests.integrations.django.myapp.wsgi import application
+
+
+@pytest.fixture
+def client():
+    return Client(application)
+
+
+@pytest.mark.forked
+@pytest_mark_django_db_decorator(transaction=True)
+def test_query_source_disabled(sentry_init, client, capture_events):
+    sentry_options = {
+        "integrations": [DjangoIntegration()],
+        "send_default_pii": True,
+        "traces_sample_rate": 1.0,
+        "enable_db_query_source": False,
+        "db_query_source_threshold_ms": 0,
+    }
+
+    sentry_init(**sentry_options)
+
+    if "postgres" not in connections:
+        pytest.skip("postgres tests disabled")
+
+    # trigger Django to open a new connection by marking the existing one as None.
+    connections["postgres"].connection = None
+
+    events = capture_events()
+
+    _, status, _ = unpack_werkzeug_response(client.get(reverse("postgres_select_orm")))
+    assert status == "200 OK"
+
+    (event,) = events
+    for span in event["spans"]:
+        if span.get("op") == "db" and "auth_user" in span.get("description"):
+            data = span.get("data", {})
+
+            assert SPANDATA.CODE_LINENO not in data
+            assert SPANDATA.CODE_NAMESPACE not in data
+            assert SPANDATA.CODE_FILEPATH not in data
+            assert SPANDATA.CODE_FUNCTION not in data
+            break
+    else:
+        raise AssertionError("No db span found")
+
+
+@pytest.mark.forked
+@pytest_mark_django_db_decorator(transaction=True)
+@pytest.mark.parametrize("enable_db_query_source", [None, True])
+def test_query_source_enabled(
+    sentry_init, client, capture_events, enable_db_query_source
+):
+    sentry_options = {
+        "integrations": [DjangoIntegration()],
+        "send_default_pii": True,
+        "traces_sample_rate": 1.0,
+        "db_query_source_threshold_ms": 0,
+    }
+
+    if enable_db_query_source is not None:
+        sentry_options["enable_db_query_source"] = enable_db_query_source
+
+    sentry_init(**sentry_options)
+
+    if "postgres" not in connections:
+        pytest.skip("postgres tests disabled")
+
+    # trigger Django to open a new connection by marking the existing one as None.
+    connections["postgres"].connection = None
+
+    events = capture_events()
+
+    _, status, _ = unpack_werkzeug_response(client.get(reverse("postgres_select_orm")))
+    assert status == "200 OK"
+
+    (event,) = events
+    for span in event["spans"]:
+        if span.get("op") == "db" and "auth_user" in span.get("description"):
+            data = span.get("data", {})
+
+            assert SPANDATA.CODE_LINENO in data
+            assert SPANDATA.CODE_NAMESPACE in data
+            assert SPANDATA.CODE_FILEPATH in data
+            assert SPANDATA.CODE_FUNCTION in data
+
+            break
+    else:
+        raise AssertionError("No db span found")
+
+
+@pytest.mark.forked
+@pytest_mark_django_db_decorator(transaction=True)
+def test_query_source(sentry_init, client, capture_events):
+    sentry_init(
+        integrations=[DjangoIntegration()],
+        send_default_pii=True,
+        traces_sample_rate=1.0,
+        enable_db_query_source=True,
+        db_query_source_threshold_ms=0,
+    )
+
+    if "postgres" not in connections:
+        pytest.skip("postgres tests disabled")
+
+    # trigger Django to open a new connection by marking the existing one as None.
+    connections["postgres"].connection = None
+
+    events = capture_events()
+
+    _, status, _ = unpack_werkzeug_response(client.get(reverse("postgres_select_orm")))
+    assert status == "200 OK"
+
+    (event,) = events
+    for span in event["spans"]:
+        if span.get("op") == "db" and "auth_user" in span.get("description"):
+            data = span.get("data", {})
+
+            assert SPANDATA.CODE_LINENO in data
+            assert SPANDATA.CODE_NAMESPACE in data
+            assert SPANDATA.CODE_FILEPATH in data
+            assert SPANDATA.CODE_FUNCTION in data
+
+            assert type(data.get(SPANDATA.CODE_LINENO)) == int
+            assert data.get(SPANDATA.CODE_LINENO) > 0
+
+            assert (
+                data.get(SPANDATA.CODE_NAMESPACE)
+                == "tests.integrations.django.myapp.views"
+            )
+            assert data.get(SPANDATA.CODE_FILEPATH).endswith(
+                "tests/integrations/django/myapp/views.py"
+            )
+
+            is_relative_path = data.get(SPANDATA.CODE_FILEPATH)[0] != os.sep
+            assert is_relative_path
+
+            assert data.get(SPANDATA.CODE_FUNCTION) == "postgres_select_orm"
+
+            break
+    else:
+        raise AssertionError("No db span found")
+
+
+@pytest.mark.forked
+@pytest_mark_django_db_decorator(transaction=True)
+def test_query_source_with_module_in_search_path(sentry_init, client, capture_events):
+    """
+    Test that query source is relative to the path of the module it ran in
+    """
+    client = Client(application)
+
+    sentry_init(
+        integrations=[DjangoIntegration()],
+        send_default_pii=True,
+        traces_sample_rate=1.0,
+        enable_db_query_source=True,
+        db_query_source_threshold_ms=0,
+    )
+
+    if "postgres" not in connections:
+        pytest.skip("postgres tests disabled")
+
+    # trigger Django to open a new connection by marking the existing one as None.
+    connections["postgres"].connection = None
+
+    events = capture_events()
+
+    _, status, _ = unpack_werkzeug_response(
+        client.get(reverse("postgres_select_slow_from_supplement"))
+    )
+    assert status == "200 OK"
+
+    (event,) = events
+    for span in event["spans"]:
+        if span.get("op") == "db" and "auth_user" in span.get("description"):
+            data = span.get("data", {})
+
+            assert SPANDATA.CODE_LINENO in data
+            assert SPANDATA.CODE_NAMESPACE in data
+            assert SPANDATA.CODE_FILEPATH in data
+            assert SPANDATA.CODE_FUNCTION in data
+
+            assert type(data.get(SPANDATA.CODE_LINENO)) == int
+            assert data.get(SPANDATA.CODE_LINENO) > 0
+            assert data.get(SPANDATA.CODE_NAMESPACE) == "django_helpers.views"
+            assert data.get(SPANDATA.CODE_FILEPATH) == "django_helpers/views.py"
+
+            is_relative_path = data.get(SPANDATA.CODE_FILEPATH)[0] != os.sep
+            assert is_relative_path
+
+            assert data.get(SPANDATA.CODE_FUNCTION) == "postgres_select_orm"
+
+            break
+    else:
+        raise AssertionError("No db span found")
+
+
+@pytest.mark.forked
+@pytest_mark_django_db_decorator(transaction=True)
+def test_query_source_with_in_app_exclude(sentry_init, client, capture_events):
+    sentry_init(
+        integrations=[DjangoIntegration()],
+        send_default_pii=True,
+        traces_sample_rate=1.0,
+        enable_db_query_source=True,
+        db_query_source_threshold_ms=0,
+        in_app_exclude=["tests.integrations.django.myapp.views"],
+    )
+
+    if "postgres" not in connections:
+        pytest.skip("postgres tests disabled")
+
+    # trigger Django to open a new connection by marking the existing one as None.
+    connections["postgres"].connection = None
+
+    events = capture_events()
+
+    _, status, _ = unpack_werkzeug_response(client.get(reverse("postgres_select_orm")))
+    assert status == "200 OK"
+
+    (event,) = events
+    for span in event["spans"]:
+        if span.get("op") == "db" and "auth_user" in span.get("description"):
+            data = span.get("data", {})
+
+            assert SPANDATA.CODE_LINENO in data
+            assert SPANDATA.CODE_NAMESPACE in data
+            assert SPANDATA.CODE_FILEPATH in data
+            assert SPANDATA.CODE_FUNCTION in data
+
+            assert type(data.get(SPANDATA.CODE_LINENO)) == int
+            assert data.get(SPANDATA.CODE_LINENO) > 0
+
+            if DJANGO_VERSION >= (1, 11):
+                assert (
+                    data.get(SPANDATA.CODE_NAMESPACE)
+                    == "tests.integrations.django.myapp.settings"
+                )
+                assert data.get(SPANDATA.CODE_FILEPATH).endswith(
+                    "tests/integrations/django/myapp/settings.py"
+                )
+                assert data.get(SPANDATA.CODE_FUNCTION) == "middleware"
+            else:
+                assert (
+                    data.get(SPANDATA.CODE_NAMESPACE)
+                    == "tests.integrations.django.test_db_query_data"
+                )
+                assert data.get(SPANDATA.CODE_FILEPATH).endswith(
+                    "tests/integrations/django/test_db_query_data.py"
+                )
+                assert (
+                    data.get(SPANDATA.CODE_FUNCTION)
+                    == "test_query_source_with_in_app_exclude"
+                )
+
+            break
+    else:
+        raise AssertionError("No db span found")
+
+
+@pytest.mark.forked
+@pytest_mark_django_db_decorator(transaction=True)
+def test_query_source_with_in_app_include(sentry_init, client, capture_events):
+    sentry_init(
+        integrations=[DjangoIntegration()],
+        send_default_pii=True,
+        traces_sample_rate=1.0,
+        enable_db_query_source=True,
+        db_query_source_threshold_ms=0,
+        in_app_include=["django"],
+    )
+
+    if "postgres" not in connections:
+        pytest.skip("postgres tests disabled")
+
+    # trigger Django to open a new connection by marking the existing one as None.
+    connections["postgres"].connection = None
+
+    events = capture_events()
+
+    _, status, _ = unpack_werkzeug_response(client.get(reverse("postgres_select_orm")))
+    assert status == "200 OK"
+
+    (event,) = events
+    for span in event["spans"]:
+        if span.get("op") == "db" and "auth_user" in span.get("description"):
+            data = span.get("data", {})
+
+            assert SPANDATA.CODE_LINENO in data
+            assert SPANDATA.CODE_NAMESPACE in data
+            assert SPANDATA.CODE_FILEPATH in data
+            assert SPANDATA.CODE_FUNCTION in data
+
+            assert type(data.get(SPANDATA.CODE_LINENO)) == int
+            assert data.get(SPANDATA.CODE_LINENO) > 0
+
+            assert data.get(SPANDATA.CODE_NAMESPACE) == "django.db.models.sql.compiler"
+            assert data.get(SPANDATA.CODE_FILEPATH).endswith(
+                "django/db/models/sql/compiler.py"
+            )
+            assert data.get(SPANDATA.CODE_FUNCTION) == "execute_sql"
+            break
+    else:
+        raise AssertionError("No db span found")
+
+
+@pytest.mark.forked
+@pytest_mark_django_db_decorator(transaction=True)
+def test_no_query_source_if_duration_too_short(sentry_init, client, capture_events):
+    sentry_init(
+        integrations=[DjangoIntegration()],
+        send_default_pii=True,
+        traces_sample_rate=1.0,
+        enable_db_query_source=True,
+        db_query_source_threshold_ms=100,
+    )
+
+    if "postgres" not in connections:
+        pytest.skip("postgres tests disabled")
+
+    # trigger Django to open a new connection by marking the existing one as None.
+    connections["postgres"].connection = None
+
+    events = capture_events()
+
+    class fake_record_sql_queries:  # noqa: N801
+        def __init__(self, *args, **kwargs):
+            with record_sql_queries(*args, **kwargs) as span:
+                self.span = span
+
+            self.span.start_timestamp = datetime(2024, 1, 1, microsecond=0)
+            self.span.timestamp = datetime(2024, 1, 1, microsecond=99999)
+
+        def __enter__(self):
+            return self.span
+
+        def __exit__(self, type, value, traceback):
+            pass
+
+    with mock.patch(
+        "sentry_sdk.integrations.django.record_sql_queries",
+        fake_record_sql_queries,
+    ):
+        _, status, _ = unpack_werkzeug_response(
+            client.get(reverse("postgres_select_orm"))
+        )
+
+    assert status == "200 OK"
+
+    (event,) = events
+    for span in event["spans"]:
+        if span.get("op") == "db" and "auth_user" in span.get("description"):
+            data = span.get("data", {})
+
+            assert SPANDATA.CODE_LINENO not in data
+            assert SPANDATA.CODE_NAMESPACE not in data
+            assert SPANDATA.CODE_FILEPATH not in data
+            assert SPANDATA.CODE_FUNCTION not in data
+
+            break
+    else:
+        raise AssertionError("No db span found")
+
+
+@pytest.mark.forked
+@pytest_mark_django_db_decorator(transaction=True)
+def test_query_source_if_duration_over_threshold(sentry_init, client, capture_events):
+    sentry_init(
+        integrations=[DjangoIntegration()],
+        send_default_pii=True,
+        traces_sample_rate=1.0,
+        enable_db_query_source=True,
+        db_query_source_threshold_ms=100,
+    )
+
+    if "postgres" not in connections:
+        pytest.skip("postgres tests disabled")
+
+    # trigger Django to open a new connection by marking the existing one as None.
+    connections["postgres"].connection = None
+
+    events = capture_events()
+
+    class fake_record_sql_queries:  # noqa: N801
+        def __init__(self, *args, **kwargs):
+            with record_sql_queries(*args, **kwargs) as span:
+                self.span = span
+
+            self.span.start_timestamp = datetime(2024, 1, 1, microsecond=0)
+            self.span.timestamp = datetime(2024, 1, 1, microsecond=101000)
+
+        def __enter__(self):
+            return self.span
+
+        def __exit__(self, type, value, traceback):
+            pass
+
+    with mock.patch(
+        "sentry_sdk.integrations.django.record_sql_queries",
+        fake_record_sql_queries,
+    ):
+        _, status, _ = unpack_werkzeug_response(
+            client.get(reverse("postgres_select_orm"))
+        )
+
+    assert status == "200 OK"
+
+    (event,) = events
+    for span in event["spans"]:
+        if span.get("op") == "db" and "auth_user" in span.get("description"):
+            data = span.get("data", {})
+
+            assert SPANDATA.CODE_LINENO in data
+            assert SPANDATA.CODE_NAMESPACE in data
+            assert SPANDATA.CODE_FILEPATH in data
+            assert SPANDATA.CODE_FUNCTION in data
+
+            assert type(data.get(SPANDATA.CODE_LINENO)) == int
+            assert data.get(SPANDATA.CODE_LINENO) > 0
+
+            assert (
+                data.get(SPANDATA.CODE_NAMESPACE)
+                == "tests.integrations.django.myapp.views"
+            )
+            assert data.get(SPANDATA.CODE_FILEPATH).endswith(
+                "tests/integrations/django/myapp/views.py"
+            )
+
+            is_relative_path = data.get(SPANDATA.CODE_FILEPATH)[0] != os.sep
+            assert is_relative_path
+
+            assert data.get(SPANDATA.CODE_FUNCTION) == "postgres_select_orm"
+            break
+    else:
+        raise AssertionError("No db span found")
+
+
+@pytest.mark.forked
+@pytest_mark_django_db_decorator(transaction=True)
+def test_db_span_origin_execute(sentry_init, client, capture_events):
+    sentry_init(
+        integrations=[DjangoIntegration()],
+        traces_sample_rate=1.0,
+    )
+
+    if "postgres" not in connections:
+        pytest.skip("postgres tests disabled")
+
+    # trigger Django to open a new connection by marking the existing one as None.
+    connections["postgres"].connection = None
+
+    events = capture_events()
+
+    client.get(reverse("postgres_select_orm"))
+
+    (event,) = events
+
+    assert event["contexts"]["trace"]["origin"] == "auto.http.django"
+
+    for span in event["spans"]:
+        if span["op"] == "db":
+            assert span["origin"] == "auto.db.django"
+        else:
+            assert span["origin"] == "auto.http.django"
+
+
+@pytest.mark.forked
+@pytest_mark_django_db_decorator(transaction=True)
+def test_db_span_origin_executemany(sentry_init, client, capture_events):
+    sentry_init(
+        integrations=[DjangoIntegration()],
+        traces_sample_rate=1.0,
+    )
+
+    events = capture_events()
+
+    if "postgres" not in connections:
+        pytest.skip("postgres tests disabled")
+
+    with start_transaction(name="test_transaction"):
+        from django.db import connection, transaction
+
+        cursor = connection.cursor()
+
+        query = """UPDATE auth_user SET username = %s where id = %s;"""
+        query_list = (
+            (
+                "test1",
+                1,
+            ),
+            (
+                "test2",
+                2,
+            ),
+        )
+        cursor.executemany(query, query_list)
+
+        transaction.commit()
+
+    (event,) = events
+
+    assert event["contexts"]["trace"]["origin"] == "manual"
+    assert event["spans"][0]["origin"] == "auto.db.django"
diff --git a/tests/integrations/django/test_db_transactions.py b/tests/integrations/django/test_db_transactions.py
new file mode 100644
index 0000000000..2750397b0e
--- /dev/null
+++ b/tests/integrations/django/test_db_transactions.py
@@ -0,0 +1,977 @@
+import os
+import pytest
+import itertools
+from datetime import datetime
+
+from django.db import connections
+from django.contrib.auth.models import User
+
+try:
+    from django.urls import reverse
+except ImportError:
+    from django.core.urlresolvers import reverse
+
+from werkzeug.test import Client
+
+from sentry_sdk import start_transaction
+from sentry_sdk.consts import SPANDATA, SPANNAME
+from sentry_sdk.integrations.django import DjangoIntegration
+
+from tests.integrations.django.utils import pytest_mark_django_db_decorator
+from tests.integrations.django.myapp.wsgi import application
+
+
+@pytest.fixture
+def client():
+    return Client(application)
+
+
+@pytest.mark.forked
+@pytest_mark_django_db_decorator(transaction=True)
+def test_db_transaction_spans_disabled_no_autocommit(
+    sentry_init, client, capture_events
+):
+    sentry_init(
+        integrations=[DjangoIntegration()],
+        traces_sample_rate=1.0,
+    )
+
+    if "postgres" not in connections:
+        pytest.skip("postgres tests disabled")
+
+    # trigger Django to open a new connection by marking the existing one as None.
+    connections["postgres"].connection = None
+
+    events = capture_events()
+
+    client.get(reverse("postgres_insert_orm_no_autocommit_rollback"))
+    client.get(reverse("postgres_insert_orm_no_autocommit"))
+
+    with start_transaction(name="test_transaction"):
+        from django.db import connection, transaction
+
+        cursor = connection.cursor()
+
+        query = """INSERT INTO auth_user (
+    password,
+    is_superuser,
+    username,
+    first_name,
+    last_name,
+    email,
+    is_staff,
+    is_active,
+    date_joined
+)
+VALUES ('password', false, %s, %s, %s, %s, false, true, %s);"""
+
+        query_list = (
+            (
+                "user1",
+                "John",
+                "Doe",
+                "user1@example.com",
+                datetime(1970, 1, 1),
+            ),
+            (
+                "user2",
+                "Max",
+                "Mustermann",
+                "user2@example.com",
+                datetime(1970, 1, 1),
+            ),
+        )
+
+        transaction.set_autocommit(False)
+        cursor.executemany(query, query_list)
+        transaction.rollback()
+        transaction.set_autocommit(True)
+
+    with start_transaction(name="test_transaction"):
+        from django.db import connection, transaction
+
+        cursor = connection.cursor()
+
+        query = """INSERT INTO auth_user (
+    password,
+    is_superuser,
+    username,
+    first_name,
+    last_name,
+    email,
+    is_staff,
+    is_active,
+    date_joined
+)
+VALUES ('password', false, %s, %s, %s, %s, false, true, %s);"""
+
+        query_list = (
+            (
+                "user1",
+                "John",
+                "Doe",
+                "user1@example.com",
+                datetime(1970, 1, 1),
+            ),
+            (
+                "user2",
+                "Max",
+                "Mustermann",
+                "user2@example.com",
+                datetime(1970, 1, 1),
+            ),
+        )
+
+        transaction.set_autocommit(False)
+        cursor.executemany(query, query_list)
+        transaction.commit()
+        transaction.set_autocommit(True)
+
+    (postgres_rollback, postgres_commit, sqlite_rollback, sqlite_commit) = events
+
+    # Ensure operation is persisted
+    assert User.objects.using("postgres").exists()
+
+    assert postgres_rollback["contexts"]["trace"]["origin"] == "auto.http.django"
+    assert postgres_commit["contexts"]["trace"]["origin"] == "auto.http.django"
+    assert sqlite_rollback["contexts"]["trace"]["origin"] == "manual"
+    assert sqlite_commit["contexts"]["trace"]["origin"] == "manual"
+
+    commit_spans = [
+        span
+        for span in itertools.chain(
+            postgres_rollback["spans"],
+            postgres_commit["spans"],
+            sqlite_rollback["spans"],
+            sqlite_commit["spans"],
+        )
+        if span["data"].get(SPANDATA.DB_OPERATION) == SPANNAME.DB_COMMIT
+        or span["data"].get(SPANDATA.DB_OPERATION) == SPANNAME.DB_ROLLBACK
+    ]
+    assert len(commit_spans) == 0
+
+
+@pytest.mark.forked
+@pytest_mark_django_db_decorator(transaction=True)
+def test_db_transaction_spans_disabled_atomic(sentry_init, client, capture_events):
+    sentry_init(
+        integrations=[DjangoIntegration()],
+        traces_sample_rate=1.0,
+    )
+
+    if "postgres" not in connections:
+        pytest.skip("postgres tests disabled")
+
+    # trigger Django to open a new connection by marking the existing one as None.
+    connections["postgres"].connection = None
+
+    events = capture_events()
+
+    client.get(reverse("postgres_insert_orm_atomic_rollback"))
+    client.get(reverse("postgres_insert_orm_atomic"))
+
+    with start_transaction(name="test_transaction"):
+        from django.db import connection, transaction
+
+        with transaction.atomic():
+            cursor = connection.cursor()
+
+            query = """INSERT INTO auth_user (
+    password,
+    is_superuser,
+    username,
+    first_name,
+    last_name,
+    email,
+    is_staff,
+    is_active,
+    date_joined
+)
+VALUES ('password', false, %s, %s, %s, %s, false, true, %s);"""
+
+            query_list = (
+                (
+                    "user1",
+                    "John",
+                    "Doe",
+                    "user1@example.com",
+                    datetime(1970, 1, 1),
+                ),
+                (
+                    "user2",
+                    "Max",
+                    "Mustermann",
+                    "user2@example.com",
+                    datetime(1970, 1, 1),
+                ),
+            )
+            cursor.executemany(query, query_list)
+            transaction.set_rollback(True)
+
+    with start_transaction(name="test_transaction"):
+        from django.db import connection, transaction
+
+        with transaction.atomic():
+            cursor = connection.cursor()
+
+            query = """INSERT INTO auth_user (
+    password,
+    is_superuser,
+    username,
+    first_name,
+    last_name,
+    email,
+    is_staff,
+    is_active,
+    date_joined
+)
+VALUES ('password', false, %s, %s, %s, %s, false, true, %s);"""
+
+            query_list = (
+                (
+                    "user1",
+                    "John",
+                    "Doe",
+                    "user1@example.com",
+                    datetime(1970, 1, 1),
+                ),
+                (
+                    "user2",
+                    "Max",
+                    "Mustermann",
+                    "user2@example.com",
+                    datetime(1970, 1, 1),
+                ),
+            )
+            cursor.executemany(query, query_list)
+
+    (postgres_rollback, postgres_commit, sqlite_rollback, sqlite_commit) = events
+
+    # Ensure operation is persisted
+    assert User.objects.using("postgres").exists()
+
+    assert postgres_rollback["contexts"]["trace"]["origin"] == "auto.http.django"
+    assert postgres_commit["contexts"]["trace"]["origin"] == "auto.http.django"
+    assert sqlite_rollback["contexts"]["trace"]["origin"] == "manual"
+    assert sqlite_commit["contexts"]["trace"]["origin"] == "manual"
+
+    commit_spans = [
+        span
+        for span in itertools.chain(
+            postgres_rollback["spans"],
+            postgres_commit["spans"],
+            sqlite_rollback["spans"],
+            sqlite_commit["spans"],
+        )
+        if span["data"].get(SPANDATA.DB_OPERATION) == SPANNAME.DB_COMMIT
+        or span["data"].get(SPANDATA.DB_OPERATION) == SPANNAME.DB_ROLLBACK
+    ]
+    assert len(commit_spans) == 0
+
+
+@pytest.mark.forked
+@pytest_mark_django_db_decorator(transaction=True)
+def test_db_no_autocommit_execute(sentry_init, client, capture_events):
+    sentry_init(
+        integrations=[DjangoIntegration(db_transaction_spans=True)],
+        traces_sample_rate=1.0,
+    )
+
+    if "postgres" not in connections:
+        pytest.skip("postgres tests disabled")
+
+    # trigger Django to open a new connection by marking the existing one as None.
+    connections["postgres"].connection = None
+
+    events = capture_events()
+
+    client.get(reverse("postgres_insert_orm_no_autocommit"))
+
+    (event,) = events
+
+    # Ensure operation is persisted
+    assert User.objects.using("postgres").exists()
+
+    assert event["contexts"]["trace"]["origin"] == "auto.http.django"
+
+    commit_spans = [
+        span
+        for span in event["spans"]
+        if span["data"].get(SPANDATA.DB_OPERATION) == SPANNAME.DB_COMMIT
+    ]
+    assert len(commit_spans) == 1
+    commit_span = commit_spans[0]
+    assert commit_span["origin"] == "auto.db.django"
+
+    # Verify other database attributes
+    assert commit_span["data"].get(SPANDATA.DB_SYSTEM) == "postgresql"
+    conn_params = connections["postgres"].get_connection_params()
+    assert commit_span["data"].get(SPANDATA.DB_NAME) is not None
+    assert commit_span["data"].get(SPANDATA.DB_NAME) == conn_params.get(
+        "database"
+    ) or conn_params.get("dbname")
+    assert commit_span["data"].get(SPANDATA.SERVER_ADDRESS) == os.environ.get(
+        "SENTRY_PYTHON_TEST_POSTGRES_HOST", "localhost"
+    )
+    assert commit_span["data"].get(SPANDATA.SERVER_PORT) == os.environ.get(
+        "SENTRY_PYTHON_TEST_POSTGRES_PORT", "5432"
+    )
+
+    insert_spans = [
+        span for span in event["spans"] if span["description"].startswith("INSERT INTO")
+    ]
+    assert len(insert_spans) == 1
+    insert_span = insert_spans[0]
+
+    # Verify query and commit statements are siblings
+    assert commit_span["parent_span_id"] == insert_span["parent_span_id"]
+
+
+@pytest.mark.forked
+@pytest_mark_django_db_decorator(transaction=True)
+def test_db_no_autocommit_executemany(sentry_init, client, capture_events):
+    sentry_init(
+        integrations=[DjangoIntegration(db_transaction_spans=True)],
+        traces_sample_rate=1.0,
+    )
+
+    events = capture_events()
+
+    with start_transaction(name="test_transaction"):
+        from django.db import connection, transaction
+
+        cursor = connection.cursor()
+
+        query = """INSERT INTO auth_user (
+    password,
+    is_superuser,
+    username,
+    first_name,
+    last_name,
+    email,
+    is_staff,
+    is_active,
+    date_joined
+)
+VALUES ('password', false, %s, %s, %s, %s, false, true, %s);"""
+
+        query_list = (
+            (
+                "user1",
+                "John",
+                "Doe",
+                "user1@example.com",
+                datetime(1970, 1, 1),
+            ),
+            (
+                "user2",
+                "Max",
+                "Mustermann",
+                "user2@example.com",
+                datetime(1970, 1, 1),
+            ),
+        )
+
+        transaction.set_autocommit(False)
+        cursor.executemany(query, query_list)
+        transaction.commit()
+        transaction.set_autocommit(True)
+
+    (event,) = events
+
+    # Ensure operation is persisted
+    assert User.objects.exists()
+
+    assert event["contexts"]["trace"]["origin"] == "manual"
+    assert event["spans"][0]["origin"] == "auto.db.django"
+
+    commit_spans = [
+        span
+        for span in event["spans"]
+        if span["data"].get(SPANDATA.DB_OPERATION) == SPANNAME.DB_COMMIT
+    ]
+    assert len(commit_spans) == 1
+    commit_span = commit_spans[0]
+    assert commit_span["origin"] == "auto.db.django"
+
+    # Verify other database attributes
+    assert commit_span["data"].get(SPANDATA.DB_SYSTEM) == "sqlite"
+    conn_params = connection.get_connection_params()
+    assert commit_span["data"].get(SPANDATA.DB_NAME) is not None
+    assert commit_span["data"].get(SPANDATA.DB_NAME) == conn_params.get(
+        "database"
+    ) or conn_params.get("dbname")
+
+    insert_spans = [
+        span for span in event["spans"] if span["description"].startswith("INSERT INTO")
+    ]
+
+    # Verify queries and commit statements are siblings
+    for insert_span in insert_spans:
+        assert commit_span["parent_span_id"] == insert_span["parent_span_id"]
+
+
+@pytest.mark.forked
+@pytest_mark_django_db_decorator(transaction=True)
+def test_db_no_autocommit_rollback_execute(sentry_init, client, capture_events):
+    sentry_init(
+        integrations=[DjangoIntegration(db_transaction_spans=True)],
+        traces_sample_rate=1.0,
+    )
+
+    if "postgres" not in connections:
+        pytest.skip("postgres tests disabled")
+
+    # trigger Django to open a new connection by marking the existing one as None.
+    connections["postgres"].connection = None
+
+    events = capture_events()
+
+    client.get(reverse("postgres_insert_orm_no_autocommit_rollback"))
+
+    (event,) = events
+
+    # Ensure operation is rolled back
+    assert not User.objects.using("postgres").exists()
+
+    assert event["contexts"]["trace"]["origin"] == "auto.http.django"
+
+    rollback_spans = [
+        span
+        for span in event["spans"]
+        if span["data"].get(SPANDATA.DB_OPERATION) == SPANNAME.DB_ROLLBACK
+    ]
+    assert len(rollback_spans) == 1
+    rollback_span = rollback_spans[0]
+    assert rollback_span["origin"] == "auto.db.django"
+
+    # Verify other database attributes
+    assert rollback_span["data"].get(SPANDATA.DB_SYSTEM) == "postgresql"
+    conn_params = connections["postgres"].get_connection_params()
+    assert rollback_span["data"].get(SPANDATA.DB_NAME) is not None
+    assert rollback_span["data"].get(SPANDATA.DB_NAME) == conn_params.get(
+        "database"
+    ) or conn_params.get("dbname")
+    assert rollback_span["data"].get(SPANDATA.SERVER_ADDRESS) == os.environ.get(
+        "SENTRY_PYTHON_TEST_POSTGRES_HOST", "localhost"
+    )
+    assert rollback_span["data"].get(SPANDATA.SERVER_PORT) == os.environ.get(
+        "SENTRY_PYTHON_TEST_POSTGRES_PORT", "5432"
+    )
+
+    insert_spans = [
+        span for span in event["spans"] if span["description"].startswith("INSERT INTO")
+    ]
+    assert len(insert_spans) == 1
+    insert_span = insert_spans[0]
+
+    # Verify query and rollback statements are siblings
+    assert rollback_span["parent_span_id"] == insert_span["parent_span_id"]
+
+
+@pytest.mark.forked
+@pytest_mark_django_db_decorator(transaction=True)
+def test_db_no_autocommit_rollback_executemany(sentry_init, client, capture_events):
+    sentry_init(
+        integrations=[DjangoIntegration(db_transaction_spans=True)],
+        traces_sample_rate=1.0,
+    )
+
+    events = capture_events()
+
+    with start_transaction(name="test_transaction"):
+        from django.db import connection, transaction
+
+        cursor = connection.cursor()
+
+        query = """INSERT INTO auth_user (
+    password,
+    is_superuser,
+    username,
+    first_name,
+    last_name,
+    email,
+    is_staff,
+    is_active,
+    date_joined
+)
+VALUES ('password', false, %s, %s, %s, %s, false, true, %s);"""
+
+        query_list = (
+            (
+                "user1",
+                "John",
+                "Doe",
+                "user1@example.com",
+                datetime(1970, 1, 1),
+            ),
+            (
+                "user2",
+                "Max",
+                "Mustermann",
+                "user2@example.com",
+                datetime(1970, 1, 1),
+            ),
+        )
+
+        transaction.set_autocommit(False)
+        cursor.executemany(query, query_list)
+        transaction.rollback()
+        transaction.set_autocommit(True)
+
+    (event,) = events
+
+    # Ensure operation is rolled back
+    assert not User.objects.exists()
+
+    assert event["contexts"]["trace"]["origin"] == "manual"
+    assert event["spans"][0]["origin"] == "auto.db.django"
+
+    rollback_spans = [
+        span
+        for span in event["spans"]
+        if span["data"].get(SPANDATA.DB_OPERATION) == SPANNAME.DB_ROLLBACK
+    ]
+    assert len(rollback_spans) == 1
+    rollback_span = rollback_spans[0]
+    assert rollback_span["origin"] == "auto.db.django"
+
+    # Verify other database attributes
+    assert rollback_span["data"].get(SPANDATA.DB_SYSTEM) == "sqlite"
+    conn_params = connection.get_connection_params()
+    assert rollback_span["data"].get(SPANDATA.DB_NAME) is not None
+    assert rollback_span["data"].get(SPANDATA.DB_NAME) == conn_params.get(
+        "database"
+    ) or conn_params.get("dbname")
+
+    insert_spans = [
+        span for span in event["spans"] if span["description"].startswith("INSERT INTO")
+    ]
+
+    # Verify queries and rollback statements are siblings
+    for insert_span in insert_spans:
+        assert rollback_span["parent_span_id"] == insert_span["parent_span_id"]
+
+
+@pytest.mark.forked
+@pytest_mark_django_db_decorator(transaction=True)
+def test_db_atomic_execute(sentry_init, client, capture_events):
+    sentry_init(
+        integrations=[DjangoIntegration(db_transaction_spans=True)],
+        traces_sample_rate=1.0,
+    )
+
+    if "postgres" not in connections:
+        pytest.skip("postgres tests disabled")
+
+    # trigger Django to open a new connection by marking the existing one as None.
+    connections["postgres"].connection = None
+
+    events = capture_events()
+
+    client.get(reverse("postgres_insert_orm_atomic"))
+
+    (event,) = events
+
+    # Ensure operation is persisted
+    assert User.objects.using("postgres").exists()
+
+    assert event["contexts"]["trace"]["origin"] == "auto.http.django"
+
+    commit_spans = [
+        span
+        for span in event["spans"]
+        if span["data"].get(SPANDATA.DB_OPERATION) == SPANNAME.DB_COMMIT
+    ]
+    assert len(commit_spans) == 1
+    commit_span = commit_spans[0]
+    assert commit_span["origin"] == "auto.db.django"
+
+    # Verify other database attributes
+    assert commit_span["data"].get(SPANDATA.DB_SYSTEM) == "postgresql"
+    conn_params = connections["postgres"].get_connection_params()
+    assert commit_span["data"].get(SPANDATA.DB_NAME) is not None
+    assert commit_span["data"].get(SPANDATA.DB_NAME) == conn_params.get(
+        "database"
+    ) or conn_params.get("dbname")
+    assert commit_span["data"].get(SPANDATA.SERVER_ADDRESS) == os.environ.get(
+        "SENTRY_PYTHON_TEST_POSTGRES_HOST", "localhost"
+    )
+    assert commit_span["data"].get(SPANDATA.SERVER_PORT) == os.environ.get(
+        "SENTRY_PYTHON_TEST_POSTGRES_PORT", "5432"
+    )
+
+    insert_spans = [
+        span for span in event["spans"] if span["description"].startswith("INSERT INTO")
+    ]
+    assert len(insert_spans) == 1
+    insert_span = insert_spans[0]
+
+    # Verify query and commit statements are siblings
+    assert commit_span["parent_span_id"] == insert_span["parent_span_id"]
+
+
+@pytest.mark.forked
+@pytest_mark_django_db_decorator(transaction=True)
+def test_db_atomic_executemany(sentry_init, client, capture_events):
+    sentry_init(
+        integrations=[DjangoIntegration(db_transaction_spans=True)],
+        send_default_pii=True,
+        traces_sample_rate=1.0,
+    )
+
+    events = capture_events()
+
+    with start_transaction(name="test_transaction"):
+        from django.db import connection, transaction
+
+        with transaction.atomic():
+            cursor = connection.cursor()
+
+            query = """INSERT INTO auth_user (
+    password,
+    is_superuser,
+    username,
+    first_name,
+    last_name,
+    email,
+    is_staff,
+    is_active,
+    date_joined
+)
+VALUES ('password', false, %s, %s, %s, %s, false, true, %s);"""
+
+            query_list = (
+                (
+                    "user1",
+                    "John",
+                    "Doe",
+                    "user1@example.com",
+                    datetime(1970, 1, 1),
+                ),
+                (
+                    "user2",
+                    "Max",
+                    "Mustermann",
+                    "user2@example.com",
+                    datetime(1970, 1, 1),
+                ),
+            )
+            cursor.executemany(query, query_list)
+
+    (event,) = events
+
+    # Ensure operation is persisted
+    assert User.objects.exists()
+
+    assert event["contexts"]["trace"]["origin"] == "manual"
+
+    commit_spans = [
+        span
+        for span in event["spans"]
+        if span["data"].get(SPANDATA.DB_OPERATION) == SPANNAME.DB_COMMIT
+    ]
+    assert len(commit_spans) == 1
+    commit_span = commit_spans[0]
+    assert commit_span["origin"] == "auto.db.django"
+
+    # Verify other database attributes
+    assert commit_span["data"].get(SPANDATA.DB_SYSTEM) == "sqlite"
+    conn_params = connection.get_connection_params()
+    assert commit_span["data"].get(SPANDATA.DB_NAME) is not None
+    assert commit_span["data"].get(SPANDATA.DB_NAME) == conn_params.get(
+        "database"
+    ) or conn_params.get("dbname")
+
+    insert_spans = [
+        span for span in event["spans"] if span["description"].startswith("INSERT INTO")
+    ]
+
+    # Verify queries and commit statements are siblings
+    for insert_span in insert_spans:
+        assert commit_span["parent_span_id"] == insert_span["parent_span_id"]
+
+
+@pytest.mark.forked
+@pytest_mark_django_db_decorator(transaction=True)
+def test_db_atomic_rollback_execute(sentry_init, client, capture_events):
+    sentry_init(
+        integrations=[DjangoIntegration(db_transaction_spans=True)],
+        send_default_pii=True,
+        traces_sample_rate=1.0,
+    )
+
+    if "postgres" not in connections:
+        pytest.skip("postgres tests disabled")
+
+    # trigger Django to open a new connection by marking the existing one as None.
+    connections["postgres"].connection = None
+
+    events = capture_events()
+
+    client.get(reverse("postgres_insert_orm_atomic_rollback"))
+
+    (event,) = events
+
+    # Ensure operation is rolled back
+    assert not User.objects.using("postgres").exists()
+
+    assert event["contexts"]["trace"]["origin"] == "auto.http.django"
+
+    rollback_spans = [
+        span
+        for span in event["spans"]
+        if span["data"].get(SPANDATA.DB_OPERATION) == SPANNAME.DB_ROLLBACK
+    ]
+    assert len(rollback_spans) == 1
+    rollback_span = rollback_spans[0]
+    assert rollback_span["origin"] == "auto.db.django"
+
+    # Verify other database attributes
+    assert rollback_span["data"].get(SPANDATA.DB_SYSTEM) == "postgresql"
+    conn_params = connections["postgres"].get_connection_params()
+    assert rollback_span["data"].get(SPANDATA.DB_NAME) is not None
+    assert rollback_span["data"].get(SPANDATA.DB_NAME) == conn_params.get(
+        "database"
+    ) or conn_params.get("dbname")
+    assert rollback_span["data"].get(SPANDATA.SERVER_ADDRESS) == os.environ.get(
+        "SENTRY_PYTHON_TEST_POSTGRES_HOST", "localhost"
+    )
+    assert rollback_span["data"].get(SPANDATA.SERVER_PORT) == os.environ.get(
+        "SENTRY_PYTHON_TEST_POSTGRES_PORT", "5432"
+    )
+
+    insert_spans = [
+        span for span in event["spans"] if span["description"].startswith("INSERT INTO")
+    ]
+    assert len(insert_spans) == 1
+    insert_span = insert_spans[0]
+
+    # Verify query and rollback statements are siblings
+    assert rollback_span["parent_span_id"] == insert_span["parent_span_id"]
+
+
+@pytest.mark.forked
+@pytest_mark_django_db_decorator(transaction=True)
+def test_db_atomic_rollback_executemany(sentry_init, client, capture_events):
+    sentry_init(
+        integrations=[DjangoIntegration(db_transaction_spans=True)],
+        send_default_pii=True,
+        traces_sample_rate=1.0,
+    )
+
+    events = capture_events()
+
+    with start_transaction(name="test_transaction"):
+        from django.db import connection, transaction
+
+        with transaction.atomic():
+            cursor = connection.cursor()
+
+            query = """INSERT INTO auth_user (
+    password,
+    is_superuser,
+    username,
+    first_name,
+    last_name,
+    email,
+    is_staff,
+    is_active,
+    date_joined
+)
+VALUES ('password', false, %s, %s, %s, %s, false, true, %s);"""
+
+            query_list = (
+                (
+                    "user1",
+                    "John",
+                    "Doe",
+                    "user1@example.com",
+                    datetime(1970, 1, 1),
+                ),
+                (
+                    "user2",
+                    "Max",
+                    "Mustermann",
+                    "user2@example.com",
+                    datetime(1970, 1, 1),
+                ),
+            )
+            cursor.executemany(query, query_list)
+            transaction.set_rollback(True)
+
+    (event,) = events
+
+    # Ensure operation is rolled back
+    assert not User.objects.exists()
+
+    assert event["contexts"]["trace"]["origin"] == "manual"
+
+    rollback_spans = [
+        span
+        for span in event["spans"]
+        if span["data"].get(SPANDATA.DB_OPERATION) == SPANNAME.DB_ROLLBACK
+    ]
+    assert len(rollback_spans) == 1
+    rollback_span = rollback_spans[0]
+    assert rollback_span["origin"] == "auto.db.django"
+
+    # Verify other database attributes
+    assert rollback_span["data"].get(SPANDATA.DB_SYSTEM) == "sqlite"
+    conn_params = connection.get_connection_params()
+    assert rollback_span["data"].get(SPANDATA.DB_NAME) is not None
+    assert rollback_span["data"].get(SPANDATA.DB_NAME) == conn_params.get(
+        "database"
+    ) or conn_params.get("dbname")
+
+    insert_spans = [
+        span for span in event["spans"] if span["description"].startswith("INSERT INTO")
+    ]
+
+    # Verify queries and rollback statements are siblings
+    for insert_span in insert_spans:
+        assert rollback_span["parent_span_id"] == insert_span["parent_span_id"]
+
+
+@pytest.mark.forked
+@pytest_mark_django_db_decorator(transaction=True)
+def test_db_atomic_execute_exception(sentry_init, client, capture_events):
+    sentry_init(
+        integrations=[DjangoIntegration(db_transaction_spans=True)],
+        send_default_pii=True,
+        traces_sample_rate=1.0,
+    )
+
+    if "postgres" not in connections:
+        pytest.skip("postgres tests disabled")
+
+    # trigger Django to open a new connection by marking the existing one as None.
+    connections["postgres"].connection = None
+
+    events = capture_events()
+
+    client.get(reverse("postgres_insert_orm_atomic_exception"))
+
+    (event,) = events
+
+    # Ensure operation is rolled back
+    assert not User.objects.using("postgres").exists()
+
+    assert event["contexts"]["trace"]["origin"] == "auto.http.django"
+
+    rollback_spans = [
+        span
+        for span in event["spans"]
+        if span["data"].get(SPANDATA.DB_OPERATION) == SPANNAME.DB_ROLLBACK
+    ]
+    assert len(rollback_spans) == 1
+    rollback_span = rollback_spans[0]
+    assert rollback_span["origin"] == "auto.db.django"
+
+    # Verify other database attributes
+    assert rollback_span["data"].get(SPANDATA.DB_SYSTEM) == "postgresql"
+    conn_params = connections["postgres"].get_connection_params()
+    assert rollback_span["data"].get(SPANDATA.DB_NAME) is not None
+    assert rollback_span["data"].get(SPANDATA.DB_NAME) == conn_params.get(
+        "database"
+    ) or conn_params.get("dbname")
+    assert rollback_span["data"].get(SPANDATA.SERVER_ADDRESS) == os.environ.get(
+        "SENTRY_PYTHON_TEST_POSTGRES_HOST", "localhost"
+    )
+    assert rollback_span["data"].get(SPANDATA.SERVER_PORT) == os.environ.get(
+        "SENTRY_PYTHON_TEST_POSTGRES_PORT", "5432"
+    )
+
+    insert_spans = [
+        span for span in event["spans"] if span["description"].startswith("INSERT INTO")
+    ]
+    assert len(insert_spans) == 1
+    insert_span = insert_spans[0]
+
+    # Verify query and rollback statements are siblings
+    assert rollback_span["parent_span_id"] == insert_span["parent_span_id"]
+
+
+@pytest.mark.forked
+@pytest_mark_django_db_decorator(transaction=True)
+def test_db_atomic_executemany_exception(sentry_init, client, capture_events):
+    sentry_init(
+        integrations=[DjangoIntegration(db_transaction_spans=True)],
+        send_default_pii=True,
+        traces_sample_rate=1.0,
+    )
+
+    events = capture_events()
+
+    with start_transaction(name="test_transaction"):
+        from django.db import connection, transaction
+
+        try:
+            with transaction.atomic():
+                cursor = connection.cursor()
+
+                query = """INSERT INTO auth_user (
+    password,
+    is_superuser,
+    username,
+    first_name,
+    last_name,
+    email,
+    is_staff,
+    is_active,
+    date_joined
+)
+VALUES ('password', false, %s, %s, %s, %s, false, true, %s);"""
+
+                query_list = (
+                    (
+                        "user1",
+                        "John",
+                        "Doe",
+                        "user1@example.com",
+                        datetime(1970, 1, 1),
+                    ),
+                    (
+                        "user2",
+                        "Max",
+                        "Mustermann",
+                        "user2@example.com",
+                        datetime(1970, 1, 1),
+                    ),
+                )
+                cursor.executemany(query, query_list)
+                1 / 0
+        except ZeroDivisionError:
+            pass
+
+    (event,) = events
+
+    # Ensure operation is rolled back
+    assert not User.objects.exists()
+
+    assert event["contexts"]["trace"]["origin"] == "manual"
+
+    rollback_spans = [
+        span
+        for span in event["spans"]
+        if span["data"].get(SPANDATA.DB_OPERATION) == SPANNAME.DB_ROLLBACK
+    ]
+    assert len(rollback_spans) == 1
+    rollback_span = rollback_spans[0]
+    assert rollback_span["origin"] == "auto.db.django"
+
+    # Verify other database attributes
+    assert rollback_span["data"].get(SPANDATA.DB_SYSTEM) == "sqlite"
+    conn_params = connection.get_connection_params()
+    assert rollback_span["data"].get(SPANDATA.DB_NAME) is not None
+    assert rollback_span["data"].get(SPANDATA.DB_NAME) == conn_params.get(
+        "database"
+    ) or conn_params.get("dbname")
+
+    insert_spans = [
+        span for span in event["spans"] if span["description"].startswith("INSERT INTO")
+    ]
+
+    # Verify queries and rollback statements are siblings
+    for insert_span in insert_spans:
+        assert rollback_span["parent_span_id"] == insert_span["parent_span_id"]
diff --git a/tests/integrations/django/test_middleware.py b/tests/integrations/django/test_middleware.py
new file mode 100644
index 0000000000..9c4c1ddfd1
--- /dev/null
+++ b/tests/integrations/django/test_middleware.py
@@ -0,0 +1,33 @@
+from typing import Optional
+
+import pytest
+
+from sentry_sdk.integrations.django.middleware import _wrap_middleware
+
+
+def _sync_capable_middleware_factory(sync_capable: "Optional[bool]") -> type:
+    """Create a middleware class with a sync_capable attribute set to the value passed to the factory.
+    If the factory is called with None, the middleware class will not have a sync_capable attribute.
+    """
+    sc = sync_capable  # rename so we can set sync_capable in the class
+
+    class TestMiddleware:
+        nonlocal sc
+        if sc is not None:
+            sync_capable = sc
+
+    return TestMiddleware
+
+
+@pytest.mark.parametrize(
+    ("middleware", "sync_capable"),
+    (
+        (_sync_capable_middleware_factory(True), True),
+        (_sync_capable_middleware_factory(False), False),
+        (_sync_capable_middleware_factory(None), True),
+    ),
+)
+def test_wrap_middleware_sync_capable_attribute(middleware, sync_capable):
+    wrapped_middleware = _wrap_middleware(middleware, "test_middleware")
+
+    assert wrapped_middleware.sync_capable is sync_capable
diff --git a/tests/integrations/django/test_tasks.py b/tests/integrations/django/test_tasks.py
new file mode 100644
index 0000000000..220d64b111
--- /dev/null
+++ b/tests/integrations/django/test_tasks.py
@@ -0,0 +1,187 @@
+import pytest
+
+import sentry_sdk
+from sentry_sdk import start_span
+from sentry_sdk.integrations.django import DjangoIntegration
+from sentry_sdk.consts import OP
+
+
+try:
+    from django.tasks import task
+
+    HAS_DJANGO_TASKS = True
+except ImportError:
+    HAS_DJANGO_TASKS = False
+
+
+@pytest.fixture
+def immediate_backend(settings):
+    """Configure Django to use the immediate task backend for synchronous testing."""
+    settings.TASKS = {
+        "default": {"BACKEND": "django.tasks.backends.immediate.ImmediateBackend"}
+    }
+
+
+if HAS_DJANGO_TASKS:
+
+    @task
+    def simple_task():
+        return "result"
+
+    @task
+    def add_numbers(a, b):
+        return a + b
+
+    @task
+    def greet(name, greeting="Hello"):
+        return f"{greeting}, {name}!"
+
+    @task
+    def failing_task():
+        raise ValueError("Task failed!")
+
+    @task
+    def task_one():
+        return 1
+
+    @task
+    def task_two():
+        return 2
+
+
+@pytest.mark.skipif(
+    not HAS_DJANGO_TASKS,
+    reason="Django tasks are only available in Django 6.0+",
+)
+def test_task_span_is_created(sentry_init, capture_events, immediate_backend):
+    """Test that the queue.submit.django span is created when a task is enqueued."""
+    sentry_init(
+        integrations=[DjangoIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    with sentry_sdk.start_transaction(name="test_transaction"):
+        simple_task.enqueue()
+
+    (event,) = events
+    assert event["type"] == "transaction"
+
+    queue_submit_spans = [
+        span for span in event["spans"] if span["op"] == OP.QUEUE_SUBMIT_DJANGO
+    ]
+    assert len(queue_submit_spans) == 1
+    assert (
+        queue_submit_spans[0]["description"]
+        == "tests.integrations.django.test_tasks.simple_task"
+    )
+    assert queue_submit_spans[0]["origin"] == "auto.http.django"
+
+
+@pytest.mark.skipif(
+    not HAS_DJANGO_TASKS,
+    reason="Django tasks are only available in Django 6.0+",
+)
+def test_task_enqueue_returns_result(sentry_init, immediate_backend):
+    """Test that the task enqueuing behavior is unchanged from the user perspective."""
+    sentry_init(
+        integrations=[DjangoIntegration()],
+        traces_sample_rate=1.0,
+    )
+
+    result = add_numbers.enqueue(3, 5)
+
+    assert result is not None
+    assert result.return_value == 8
+
+
+@pytest.mark.skipif(
+    not HAS_DJANGO_TASKS,
+    reason="Django tasks are only available in Django 6.0+",
+)
+def test_task_enqueue_with_kwargs(sentry_init, immediate_backend, capture_events):
+    """Test that task enqueuing works correctly with keyword arguments."""
+    sentry_init(
+        integrations=[DjangoIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    with sentry_sdk.start_transaction(name="test_transaction"):
+        result = greet.enqueue(name="World", greeting="Hi")
+
+    assert result.return_value == "Hi, World!"
+
+    (event,) = events
+    queue_submit_spans = [
+        span for span in event["spans"] if span["op"] == OP.QUEUE_SUBMIT_DJANGO
+    ]
+    assert len(queue_submit_spans) == 1
+    assert (
+        queue_submit_spans[0]["description"]
+        == "tests.integrations.django.test_tasks.greet"
+    )
+
+
+@pytest.mark.skipif(
+    not HAS_DJANGO_TASKS,
+    reason="Django tasks are only available in Django 6.0+",
+)
+def test_task_error_reporting(sentry_init, immediate_backend, capture_events):
+    """Test that errors in tasks are correctly reported and don't break the span."""
+    sentry_init(
+        integrations=[DjangoIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    with sentry_sdk.start_transaction(name="test_transaction"):
+        result = failing_task.enqueue()
+
+    with pytest.raises(ValueError, match="Task failed"):
+        _ = result.return_value
+
+    assert len(events) == 2
+    transaction_event = events[-1]
+    assert transaction_event["type"] == "transaction"
+
+    queue_submit_spans = [
+        span
+        for span in transaction_event["spans"]
+        if span["op"] == OP.QUEUE_SUBMIT_DJANGO
+    ]
+    assert len(queue_submit_spans) == 1
+    assert (
+        queue_submit_spans[0]["description"]
+        == "tests.integrations.django.test_tasks.failing_task"
+    )
+
+
+@pytest.mark.skipif(
+    not HAS_DJANGO_TASKS,
+    reason="Django tasks are only available in Django 6.0+",
+)
+def test_multiple_task_enqueues_create_multiple_spans(
+    sentry_init, capture_events, immediate_backend
+):
+    """Test that enqueueing multiple tasks creates multiple spans."""
+    sentry_init(
+        integrations=[DjangoIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    with sentry_sdk.start_transaction(name="test_transaction"):
+        task_one.enqueue()
+        task_two.enqueue()
+        task_one.enqueue()
+
+    (event,) = events
+    queue_submit_spans = [
+        span for span in event["spans"] if span["op"] == OP.QUEUE_SUBMIT_DJANGO
+    ]
+    assert len(queue_submit_spans) == 3
+
+    span_names = [span["description"] for span in queue_submit_spans]
+    assert span_names.count("tests.integrations.django.test_tasks.task_one") == 2
+    assert span_names.count("tests.integrations.django.test_tasks.task_two") == 1
diff --git a/tests/integrations/django/test_transactions.py b/tests/integrations/django/test_transactions.py
index 4c94a2c955..14f8170fc3 100644
--- a/tests/integrations/django/test_transactions.py
+++ b/tests/integrations/django/test_transactions.py
@@ -1,49 +1,53 @@
-from __future__ import absolute_import
+from unittest import mock
 
 import pytest
 import django
+from django.utils.translation import pgettext_lazy
 
+
+# django<2.0 has only `url` with regex based patterns.
+# django>=2.0 renames `url` to `re_path`, and additionally introduces `path`
+# for new style URL patterns, e.g. .
 if django.VERSION >= (2, 0):
-    # TODO: once we stop supporting django < 2, use the real name of this
-    # function (re_path)
-    from django.urls import re_path as url
+    from django.urls import path, re_path
+    from django.urls.converters import PathConverter
     from django.conf.urls import include
 else:
-    from django.conf.urls import url, include
+    from django.conf.urls import url as re_path, include
 
 if django.VERSION < (1, 9):
-    included_url_conf = (url(r"^foo/bar/(?P[\w]+)", lambda x: ""),), "", ""
+    included_url_conf = (re_path(r"^foo/bar/(?P[\w]+)", lambda x: ""),), "", ""
 else:
-    included_url_conf = ((url(r"^foo/bar/(?P[\w]+)", lambda x: ""),), "")
+    included_url_conf = ((re_path(r"^foo/bar/(?P[\w]+)", lambda x: ""),), "")
 
 from sentry_sdk.integrations.django.transactions import RavenResolver
 
 
 example_url_conf = (
-    url(r"^api/(?P[\w_-]+)/store/$", lambda x: ""),
-    url(r"^api/(?P(v1|v2))/author/$", lambda x: ""),
-    url(
+    re_path(r"^api/(?P[\w_-]+)/store/$", lambda x: ""),
+    re_path(r"^api/(?P(v1|v2))/author/$", lambda x: ""),
+    re_path(
         r"^api/(?P[^\/]+)/product/(?P(?:\d+|[A-Fa-f0-9-]{32,36}))/$",
         lambda x: "",
     ),
-    url(r"^report/", lambda x: ""),
-    url(r"^example/", include(included_url_conf)),
+    re_path(r"^report/", lambda x: ""),
+    re_path(r"^example/", include(included_url_conf)),
 )
 
 
-def test_legacy_resolver_no_match():
+def test_resolver_no_match():
     resolver = RavenResolver()
     result = resolver.resolve("/foo/bar", example_url_conf)
     assert result is None
 
 
-def test_legacy_resolver_complex_match():
+def test_resolver_re_path_complex_match():
     resolver = RavenResolver()
     result = resolver.resolve("/api/1234/store/", example_url_conf)
     assert result == "/api/{project_id}/store/"
 
 
-def test_legacy_resolver_complex_either_match():
+def test_resolver_re_path_complex_either_match():
     resolver = RavenResolver()
     result = resolver.resolve("/api/v1/author/", example_url_conf)
     assert result == "/api/{version}/author/"
@@ -51,13 +55,13 @@ def test_legacy_resolver_complex_either_match():
     assert result == "/api/{version}/author/"
 
 
-def test_legacy_resolver_included_match():
+def test_resolver_re_path_included_match():
     resolver = RavenResolver()
     result = resolver.resolve("/example/foo/bar/baz", example_url_conf)
     assert result == "/example/foo/bar/{param}"
 
 
-def test_capture_multiple_named_groups():
+def test_resolver_re_path_multiple_groups():
     resolver = RavenResolver()
     result = resolver.resolve(
         "/api/myproject/product/cb4ef1caf3554c34ae134f3c1b3d605f/", example_url_conf
@@ -65,21 +69,85 @@ def test_capture_multiple_named_groups():
     assert result == "/api/{project_id}/product/{pid}/"
 
 
-@pytest.mark.skipif(django.VERSION < (2, 0), reason="Requires Django > 2.0")
-def test_legacy_resolver_newstyle_django20_urlconf():
-    from django.urls import path
-
+@pytest.mark.skipif(
+    django.VERSION < (2, 0),
+    reason="Django>=2.0 required for  patterns",
+)
+def test_resolver_path_group():
     url_conf = (path("api/v2//store/", lambda x: ""),)
     resolver = RavenResolver()
     result = resolver.resolve("/api/v2/1234/store/", url_conf)
     assert result == "/api/v2/{project_id}/store/"
 
 
-@pytest.mark.skipif(django.VERSION < (2, 0), reason="Requires Django > 2.0")
-def test_legacy_resolver_newstyle_django20_urlconf_multiple_groups():
-    from django.urls import path
-
-    url_conf = (path("api/v2//product/", lambda x: ""),)
+@pytest.mark.skipif(
+    django.VERSION < (2, 0),
+    reason="Django>=2.0 required for  patterns",
+)
+def test_resolver_path_multiple_groups():
+    url_conf = (path("api/v2//product/", lambda x: ""),)
     resolver = RavenResolver()
-    result = resolver.resolve("/api/v2/1234/product/5689", url_conf)
+    result = resolver.resolve("/api/v2/myproject/product/5689", url_conf)
     assert result == "/api/v2/{project_id}/product/{pid}"
+
+
+@pytest.mark.skipif(
+    django.VERSION < (2, 0),
+    reason="Django>=2.0 required for  patterns",
+)
+@pytest.mark.skipif(
+    django.VERSION > (5, 1),
+    reason="get_converter removed in 5.1",
+)
+def test_resolver_path_complex_path_legacy():
+    class CustomPathConverter(PathConverter):
+        regex = r"[^/]+(/[^/]+){0,2}"
+
+    with mock.patch(
+        "django.urls.resolvers.get_converter",
+        return_value=CustomPathConverter,
+    ):
+        url_conf = (path("api/v3/", lambda x: ""),)
+        resolver = RavenResolver()
+        result = resolver.resolve("/api/v3/abc/def/ghi", url_conf)
+        assert result == "/api/v3/{my_path}"
+
+
+@pytest.mark.skipif(
+    django.VERSION < (5, 1),
+    reason="get_converters is used in 5.1",
+)
+def test_resolver_path_complex_path():
+    class CustomPathConverter(PathConverter):
+        regex = r"[^/]+(/[^/]+){0,2}"
+
+    with mock.patch(
+        "django.urls.resolvers.get_converters",
+        return_value={"custom_path": CustomPathConverter},
+    ):
+        url_conf = (path("api/v3/", lambda x: ""),)
+        resolver = RavenResolver()
+        result = resolver.resolve("/api/v3/abc/def/ghi", url_conf)
+        assert result == "/api/v3/{my_path}"
+
+
+@pytest.mark.skipif(
+    django.VERSION < (2, 0),
+    reason="Django>=2.0 required for  patterns",
+)
+def test_resolver_path_no_converter():
+    url_conf = (path("api/v4/", lambda x: ""),)
+    resolver = RavenResolver()
+    result = resolver.resolve("/api/v4/myproject", url_conf)
+    assert result == "/api/v4/{project_id}"
+
+
+@pytest.mark.skipif(
+    django.VERSION < (2, 0),
+    reason="Django>=2.0 required for path patterns",
+)
+def test_resolver_path_with_i18n():
+    url_conf = (path(pgettext_lazy("url", "pgettext"), lambda x: ""),)
+    resolver = RavenResolver()
+    result = resolver.resolve("/pgettext", url_conf)
+    assert result == "/pgettext"
diff --git a/tests/integrations/dramatiq/__init__.py b/tests/integrations/dramatiq/__init__.py
new file mode 100644
index 0000000000..70bbf21db4
--- /dev/null
+++ b/tests/integrations/dramatiq/__init__.py
@@ -0,0 +1,3 @@
+import pytest
+
+pytest.importorskip("dramatiq")
diff --git a/tests/integrations/dramatiq/test_dramatiq.py b/tests/integrations/dramatiq/test_dramatiq.py
new file mode 100644
index 0000000000..3860ee61d9
--- /dev/null
+++ b/tests/integrations/dramatiq/test_dramatiq.py
@@ -0,0 +1,388 @@
+import pytest
+import uuid
+
+import dramatiq
+from dramatiq.brokers.stub import StubBroker
+
+import sentry_sdk
+from sentry_sdk.tracing import TransactionSource
+from sentry_sdk import start_transaction
+from sentry_sdk.consts import SPANSTATUS
+from sentry_sdk.integrations.dramatiq import DramatiqIntegration
+from sentry_sdk.integrations.logging import ignore_logger
+
+ignore_logger("dramatiq.worker.WorkerThread")
+
+
+@pytest.fixture(scope="function")
+def broker(request, sentry_init):
+    sentry_init(
+        integrations=[DramatiqIntegration()],
+        traces_sample_rate=getattr(request, "param", None),
+    )
+    broker = StubBroker()
+    broker.emit_after("process_boot")
+    dramatiq.set_broker(broker)
+    yield broker
+    broker.flush_all()
+    broker.close()
+
+
+@pytest.fixture
+def worker(broker):
+    worker = dramatiq.Worker(broker, worker_timeout=100, worker_threads=1)
+    worker.start()
+    yield worker
+    worker.stop()
+
+
+@pytest.mark.parametrize(
+    "fail_fast",
+    [
+        False,
+        True,
+    ],
+)
+def test_that_a_single_error_is_captured(broker, worker, capture_events, fail_fast):
+    events = capture_events()
+
+    @dramatiq.actor(max_retries=0)
+    def dummy_actor(x, y):
+        return x / y
+
+    dummy_actor.send(1, 2)
+    dummy_actor.send(1, 0)
+    if fail_fast:
+        with pytest.raises(ZeroDivisionError):
+            broker.join(dummy_actor.queue_name, fail_fast=fail_fast)
+    else:
+        broker.join(dummy_actor.queue_name, fail_fast=fail_fast)
+    worker.join()
+
+    (event,) = events
+    exception = event["exception"]["values"][0]
+    assert exception["type"] == "ZeroDivisionError"
+
+
+@pytest.mark.parametrize(
+    "broker,expected_span_status,fail_fast",
+    [
+        (1.0, SPANSTATUS.INTERNAL_ERROR, False),
+        (1.0, SPANSTATUS.OK, False),
+        (1.0, SPANSTATUS.INTERNAL_ERROR, True),
+        (1.0, SPANSTATUS.OK, True),
+    ],
+    ids=["error", "success", "error_fail_fast", "success_fail_fast"],
+    indirect=["broker"],
+)
+def test_task_transaction(
+    broker, worker, capture_events, expected_span_status, fail_fast
+):
+    events = capture_events()
+    task_fails = expected_span_status == SPANSTATUS.INTERNAL_ERROR
+
+    @dramatiq.actor(max_retries=0)
+    def dummy_actor(x, y):
+        return x / y
+
+    dummy_actor.send(1, int(not task_fails))
+
+    if expected_span_status == SPANSTATUS.INTERNAL_ERROR and fail_fast:
+        with pytest.raises(ZeroDivisionError):
+            broker.join(dummy_actor.queue_name, fail_fast=fail_fast)
+    else:
+        broker.join(dummy_actor.queue_name, fail_fast=fail_fast)
+
+    worker.join()
+
+    if task_fails:
+        error_event = events.pop(0)
+        exception = error_event["exception"]["values"][0]
+        assert exception["type"] == "ZeroDivisionError"
+        assert exception["mechanism"]["type"] == DramatiqIntegration.identifier
+
+    (event,) = events
+    assert event["type"] == "transaction"
+    assert event["transaction"] == "dummy_actor"
+    assert event["transaction_info"] == {"source": TransactionSource.TASK}
+    assert event["contexts"]["trace"]["status"] == expected_span_status
+
+
+@pytest.mark.parametrize("broker", [1.0], indirect=True)
+def test_dramatiq_propagate_trace(broker, worker, capture_events):
+    events = capture_events()
+
+    @dramatiq.actor(max_retries=0)
+    def propagated_trace_task():
+        pass
+
+    with start_transaction() as outer_transaction:
+        propagated_trace_task.send()
+        broker.join(propagated_trace_task.queue_name)
+        worker.join()
+
+    assert (
+        events[0]["transaction"] == "propagated_trace_task"
+    )  # the "inner" transaction
+    assert events[0]["contexts"]["trace"]["trace_id"] == outer_transaction.trace_id
+
+
+@pytest.mark.parametrize(
+    "fail_fast",
+    [
+        False,
+        True,
+    ],
+)
+def test_that_dramatiq_message_id_is_set_as_extra(
+    broker, worker, capture_events, fail_fast
+):
+    events = capture_events()
+
+    @dramatiq.actor(max_retries=0)
+    def dummy_actor(x, y):
+        sentry_sdk.capture_message("hi")
+        return x / y
+
+    dummy_actor.send(1, 0)
+    if fail_fast:
+        with pytest.raises(ZeroDivisionError):
+            broker.join(dummy_actor.queue_name, fail_fast=fail_fast)
+    else:
+        broker.join(dummy_actor.queue_name, fail_fast=fail_fast)
+    worker.join()
+
+    event_message, event_error = events
+    assert "dramatiq_message_id" in event_message["extra"]
+    assert "dramatiq_message_id" in event_error["extra"]
+    assert (
+        event_message["extra"]["dramatiq_message_id"]
+        == event_error["extra"]["dramatiq_message_id"]
+    )
+    msg_ids = [e["extra"]["dramatiq_message_id"] for e in events]
+    assert all(uuid.UUID(msg_id) and isinstance(msg_id, str) for msg_id in msg_ids)
+
+
+@pytest.mark.parametrize(
+    "fail_fast",
+    [
+        False,
+        True,
+    ],
+)
+def test_that_local_variables_are_captured(broker, worker, capture_events, fail_fast):
+    events = capture_events()
+
+    @dramatiq.actor(max_retries=0)
+    def dummy_actor(x, y):
+        foo = 42  # noqa
+        return x / y
+
+    dummy_actor.send(1, 2)
+    dummy_actor.send(1, 0)
+    if fail_fast:
+        with pytest.raises(ZeroDivisionError):
+            broker.join(dummy_actor.queue_name, fail_fast=fail_fast)
+    else:
+        broker.join(dummy_actor.queue_name, fail_fast=fail_fast)
+    worker.join()
+
+    (event,) = events
+    exception = event["exception"]["values"][0]
+    assert exception["stacktrace"]["frames"][-1]["vars"] == {
+        "x": "1",
+        "y": "0",
+        "foo": "42",
+    }
+
+
+def test_that_messages_are_captured(broker, worker, capture_events):
+    events = capture_events()
+
+    @dramatiq.actor(max_retries=0)
+    def dummy_actor():
+        sentry_sdk.capture_message("hi")
+
+    dummy_actor.send()
+    broker.join(dummy_actor.queue_name)
+    worker.join()
+
+    (event,) = events
+    assert event["message"] == "hi"
+    assert event["level"] == "info"
+    assert event["transaction"] == "dummy_actor"
+
+
+@pytest.mark.parametrize(
+    "fail_fast",
+    [
+        False,
+        True,
+    ],
+)
+def test_that_sub_actor_errors_are_captured(broker, worker, capture_events, fail_fast):
+    events = capture_events()
+
+    @dramatiq.actor(max_retries=0)
+    def dummy_actor(x, y):
+        sub_actor.send(x, y)
+
+    @dramatiq.actor(max_retries=0)
+    def sub_actor(x, y):
+        return x / y
+
+    dummy_actor.send(1, 2)
+    dummy_actor.send(1, 0)
+    if fail_fast:
+        with pytest.raises(ZeroDivisionError):
+            broker.join(dummy_actor.queue_name, fail_fast=fail_fast)
+    else:
+        broker.join(dummy_actor.queue_name, fail_fast=fail_fast)
+    worker.join()
+
+    (event,) = events
+    assert event["transaction"] == "sub_actor"
+
+    exception = event["exception"]["values"][0]
+    assert exception["type"] == "ZeroDivisionError"
+
+
+@pytest.mark.parametrize(
+    "fail_fast",
+    [
+        False,
+        True,
+    ],
+)
+def test_that_multiple_errors_are_captured(broker, worker, capture_events, fail_fast):
+    events = capture_events()
+
+    @dramatiq.actor(max_retries=0)
+    def dummy_actor(x, y):
+        return x / y
+
+    dummy_actor.send(1, 0)
+    if fail_fast:
+        with pytest.raises(ZeroDivisionError):
+            broker.join(dummy_actor.queue_name, fail_fast=fail_fast)
+    else:
+        broker.join(dummy_actor.queue_name, fail_fast=fail_fast)
+    worker.join()
+
+    dummy_actor.send(1, None)
+    if fail_fast:
+        with pytest.raises(ZeroDivisionError):
+            broker.join(dummy_actor.queue_name, fail_fast=fail_fast)
+    else:
+        broker.join(dummy_actor.queue_name, fail_fast=fail_fast)
+    worker.join()
+
+    event1, event2 = events
+
+    assert event1["transaction"] == "dummy_actor"
+    exception = event1["exception"]["values"][0]
+    assert exception["type"] == "ZeroDivisionError"
+
+    assert event2["transaction"] == "dummy_actor"
+    exception = event2["exception"]["values"][0]
+    assert exception["type"] == "TypeError"
+
+
+@pytest.mark.parametrize(
+    "fail_fast",
+    [
+        False,
+        True,
+    ],
+)
+def test_that_message_data_is_added_as_request(
+    broker, worker, capture_events, fail_fast
+):
+    events = capture_events()
+
+    @dramatiq.actor(max_retries=0)
+    def dummy_actor(x, y):
+        return x / y
+
+    dummy_actor.send_with_options(
+        args=(
+            1,
+            0,
+        ),
+        max_retries=0,
+    )
+    if fail_fast:
+        with pytest.raises(ZeroDivisionError):
+            broker.join(dummy_actor.queue_name, fail_fast=fail_fast)
+    else:
+        broker.join(dummy_actor.queue_name, fail_fast=fail_fast)
+    worker.join()
+
+    (event,) = events
+
+    assert event["transaction"] == "dummy_actor"
+    request_data = event["contexts"]["dramatiq"]["data"]
+    assert request_data["queue_name"] == "default"
+    assert request_data["actor_name"] == "dummy_actor"
+    assert request_data["args"] == [1, 0]
+    assert request_data["kwargs"] == {}
+    assert request_data["options"]["max_retries"] == 0
+    assert uuid.UUID(request_data["message_id"])
+    assert isinstance(request_data["message_timestamp"], int)
+
+
+@pytest.mark.parametrize(
+    "fail_fast",
+    [
+        False,
+        True,
+    ],
+)
+def test_that_expected_exceptions_are_not_captured(
+    broker, worker, capture_events, fail_fast
+):
+    events = capture_events()
+
+    class ExpectedException(Exception):
+        pass
+
+    @dramatiq.actor(max_retries=0, throws=ExpectedException)
+    def dummy_actor():
+        raise ExpectedException
+
+    dummy_actor.send()
+    if fail_fast:
+        with pytest.raises(ExpectedException):
+            broker.join(dummy_actor.queue_name, fail_fast=fail_fast)
+    else:
+        broker.join(dummy_actor.queue_name, fail_fast=fail_fast)
+    worker.join()
+
+    assert events == []
+
+
+@pytest.mark.parametrize(
+    "fail_fast",
+    [
+        False,
+        True,
+    ],
+)
+def test_that_retry_exceptions_are_not_captured(
+    broker, worker, capture_events, fail_fast
+):
+    events = capture_events()
+
+    @dramatiq.actor(max_retries=2)
+    def dummy_actor():
+        raise dramatiq.errors.Retry("Retrying", delay=100)
+
+    dummy_actor.send()
+    if fail_fast:
+        with pytest.raises(dramatiq.errors.Retry):
+            broker.join(dummy_actor.queue_name, fail_fast=fail_fast)
+    else:
+        broker.join(dummy_actor.queue_name, fail_fast=fail_fast)
+    worker.join()
+
+    assert events == []
diff --git a/tests/integrations/excepthook/test_excepthook.py b/tests/integrations/excepthook/test_excepthook.py
index 18deccd76e..5a19b4f985 100644
--- a/tests/integrations/excepthook/test_excepthook.py
+++ b/tests/integrations/excepthook/test_excepthook.py
@@ -5,25 +5,34 @@
 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(
             """
     from sentry_sdk import init, transport
 
-    def send_event(self, event):
-        print("capture event was called")
-        print(event)
+    def capture_envelope(self, envelope):
+        print("capture_envelope was called")
+        event = envelope.get_event()
+        if event is not None:
+            print(event)
 
-    transport.HttpTransport._send_event = send_event
+    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)
         )
     )
 
@@ -31,14 +40,14 @@ def send_event(self, event):
         subprocess.check_output([sys.executable, str(app)], stderr=subprocess.STDOUT)
 
     output = excinfo.value.output
-    print(output)
 
     assert b"ZeroDivisionError" in output
     assert b"LOL" in output
-    assert b"capture event was called" in output
+    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(
@@ -47,21 +56,24 @@ def test_always_value_excepthook(tmpdir):
     from sentry_sdk import init, transport
     from sentry_sdk.integrations.excepthook import ExcepthookIntegration
 
-    def send_event(self, event):
-        print("capture event was called")
-        print(event)
+    def capture_envelope(self, envelope):
+        print("capture_envelope was called")
+        event = envelope.get_event()
+        if event is not None:
+            print(event)
 
-    transport.HttpTransport._send_event = send_event
+    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)
         )
     )
 
@@ -69,8 +81,7 @@ def send_event(self, event):
         subprocess.check_output([sys.executable, str(app)], stderr=subprocess.STDOUT)
 
     output = excinfo.value.output
-    print(output)
 
     assert b"ZeroDivisionError" in output
     assert b"LOL" in output
-    assert b"capture event was called" in output
+    assert b"capture_envelope was called" in output
diff --git a/tests/integrations/falcon/test_falcon.py b/tests/integrations/falcon/test_falcon.py
index 19b56c749a..f972419092 100644
--- a/tests/integrations/falcon/test_falcon.py
+++ b/tests/integrations/falcon/test_falcon.py
@@ -1,5 +1,3 @@
-from __future__ import absolute_import
-
 import logging
 
 import pytest
@@ -7,8 +5,10 @@
 import falcon
 import falcon.testing
 import sentry_sdk
+from sentry_sdk.consts import DEFAULT_MAX_VALUE_LENGTH
 from sentry_sdk.integrations.falcon import FalconIntegration
 from sentry_sdk.integrations.logging import LoggingIntegration
+from sentry_sdk.utils import parse_version
 
 
 try:
@@ -19,6 +19,9 @@
     import falcon.inspect  # We only need this module for the ASGI test
 
 
+FALCON_VERSION = parse_version(falcon.__version__)
+
+
 @pytest.fixture
 def make_app(sentry_init):
     def inner():
@@ -32,9 +35,22 @@ def on_get(self, req, resp, message_id):
                 sentry_sdk.capture_message("hi")
                 resp.media = "hi"
 
+        class CustomError(Exception):
+            pass
+
+        class CustomErrorResource:
+            def on_get(self, req, resp):
+                raise CustomError()
+
+        def custom_error_handler(*args, **kwargs):
+            raise falcon.HTTPError(status=falcon.HTTP_400)
+
         app = falcon.API()
         app.add_route("/message", MessageResource())
         app.add_route("/message/{message_id:int}", MessageByIdResource())
+        app.add_route("/custom-error", CustomErrorResource())
+
+        app.add_error_handler(CustomError, custom_error_handler)
 
         return app
 
@@ -96,7 +112,7 @@ def test_transaction_style(
 
 
 def test_unhandled_errors(sentry_init, capture_exceptions, capture_events):
-    sentry_init(integrations=[FalconIntegration()], debug=True)
+    sentry_init(integrations=[FalconIntegration()])
 
     class Resource:
         def on_get(self, req, resp):
@@ -124,7 +140,7 @@ def on_get(self, req, resp):
 
 
 def test_raised_5xx_errors(sentry_init, capture_exceptions, capture_events):
-    sentry_init(integrations=[FalconIntegration()], debug=True)
+    sentry_init(integrations=[FalconIntegration()])
 
     class Resource:
         def on_get(self, req, resp):
@@ -148,7 +164,7 @@ def on_get(self, req, resp):
 
 
 def test_raised_4xx_errors(sentry_init, capture_exceptions, capture_events):
-    sentry_init(integrations=[FalconIntegration()], debug=True)
+    sentry_init(integrations=[FalconIntegration()])
 
     class Resource:
         def on_get(self, req, resp):
@@ -172,7 +188,7 @@ def test_http_status(sentry_init, capture_exceptions, capture_events):
     This just demonstrates, that if Falcon raises a HTTPStatus with code 500
     (instead of a HTTPError with code 500) Sentry will not capture it.
     """
-    sentry_init(integrations=[FalconIntegration()], debug=True)
+    sentry_init(integrations=[FalconIntegration()])
 
     class Resource:
         def on_get(self, req, resp):
@@ -192,9 +208,9 @@ def on_get(self, req, resp):
 
 
 def test_falcon_large_json_request(sentry_init, capture_events):
-    sentry_init(integrations=[FalconIntegration()])
+    sentry_init(integrations=[FalconIntegration()], max_request_body_size="always")
 
-    data = {"foo": {"bar": "a" * 2000}}
+    data = {"foo": {"bar": "a" * (DEFAULT_MAX_VALUE_LENGTH + 10)}}
 
     class Resource:
         def on_post(self, req, resp):
@@ -213,9 +229,14 @@ def on_post(self, req, resp):
 
     (event,) = events
     assert event["_meta"]["request"]["data"]["foo"]["bar"] == {
-        "": {"len": 2000, "rem": [["!limit", "x", 1021, 1024]]}
+        "": {
+            "len": DEFAULT_MAX_VALUE_LENGTH + 10,
+            "rem": [
+                ["!limit", "x", DEFAULT_MAX_VALUE_LENGTH - 3, DEFAULT_MAX_VALUE_LENGTH]
+            ],
+        }
     }
-    assert len(event["request"]["data"]["foo"]["bar"]) == 1024
+    assert len(event["request"]["data"]["foo"]["bar"]) == DEFAULT_MAX_VALUE_LENGTH
 
 
 @pytest.mark.parametrize("data", [{}, []], ids=["empty-dict", "empty-list"])
@@ -288,7 +309,7 @@ def on_get(self, req, resp):
     assert event["level"] == "error"
 
 
-def test_500(sentry_init, capture_events):
+def test_500(sentry_init):
     sentry_init(integrations=[FalconIntegration()])
 
     app = falcon.API()
@@ -301,17 +322,14 @@ def on_get(self, req, resp):
 
     def http500_handler(ex, req, resp, params):
         sentry_sdk.capture_exception(ex)
-        resp.media = {"message": "Sentry error: %s" % sentry_sdk.last_event_id()}
+        resp.media = {"message": "Sentry error."}
 
     app.add_error_handler(Exception, http500_handler)
 
-    events = capture_events()
-
     client = falcon.testing.TestClient(app)
     response = client.simulate_get("/")
 
-    (event,) = events
-    assert response.json == {"message": "Sentry error: %s" % event["event_id"]}
+    assert response.json == {"message": "Sentry error."}
 
 
 def test_error_in_errorhandler(sentry_init, capture_events):
@@ -367,20 +385,17 @@ def test_does_not_leak_scope(sentry_init, capture_events):
     sentry_init(integrations=[FalconIntegration()])
     events = capture_events()
 
-    with sentry_sdk.configure_scope() as scope:
-        scope.set_tag("request_data", False)
+    sentry_sdk.get_isolation_scope().set_tag("request_data", False)
 
     app = falcon.API()
 
     class Resource:
         def on_get(self, req, resp):
-            with sentry_sdk.configure_scope() as scope:
-                scope.set_tag("request_data", True)
+            sentry_sdk.get_isolation_scope().set_tag("request_data", True)
 
             def generator():
                 for row in range(1000):
-                    with sentry_sdk.configure_scope() as scope:
-                        assert scope._tags["request_data"]
+                    assert sentry_sdk.get_isolation_scope()._tags["request_data"]
 
                     yield (str(row) + "\n").encode()
 
@@ -394,9 +409,7 @@ def generator():
     expected_response = "".join(str(row) + "\n" for row in range(1000))
     assert response.text == expected_response
     assert not events
-
-    with sentry_sdk.configure_scope() as scope:
-        assert not scope._tags["request_data"]
+    assert not sentry_sdk.get_isolation_scope()._tags["request_data"]
 
 
 @pytest.mark.skipif(
@@ -418,3 +431,83 @@ def test_falcon_not_breaking_asgi(sentry_init):
         falcon.inspect.inspect_app(asgi_app)
     except TypeError:
         pytest.fail("Falcon integration causing errors in ASGI apps.")
+
+
+@pytest.mark.skipif(
+    (FALCON_VERSION or ()) < (3,),
+    reason="The Sentry Falcon integration only supports custom error handlers on Falcon 3+",
+)
+def test_falcon_custom_error_handler(sentry_init, make_app, capture_events):
+    """
+    When a custom error handler handles what otherwise would have resulted in a 5xx error,
+    changing the HTTP status to a non-5xx status, no error event should be sent to Sentry.
+    """
+    sentry_init(integrations=[FalconIntegration()])
+    events = capture_events()
+
+    app = make_app()
+    client = falcon.testing.TestClient(app)
+
+    client.simulate_get("/custom-error")
+
+    assert len(events) == 0
+
+
+def test_span_origin(sentry_init, capture_events, make_client):
+    sentry_init(
+        integrations=[FalconIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    client = make_client()
+    client.simulate_get("/message")
+
+    (_, event) = events
+
+    assert event["contexts"]["trace"]["origin"] == "auto.http.falcon"
+
+
+def test_falcon_request_media(sentry_init):
+    # test_passed stores whether the test has passed.
+    test_passed = False
+
+    # test_failure_reason stores the reason why the test failed
+    # if test_passed is False. The value is meaningless when
+    # test_passed is True.
+    test_failure_reason = "test endpoint did not get called"
+
+    class SentryCaptureMiddleware:
+        def process_request(self, _req, _resp):
+            # This capture message forces Falcon event processors to run
+            # before the request handler runs
+            sentry_sdk.capture_message("Processing request")
+
+    class RequestMediaResource:
+        def on_post(self, req, _):
+            nonlocal test_passed, test_failure_reason
+            raw_data = req.bounded_stream.read()
+
+            # If the raw_data is empty, the request body stream
+            # has been exhausted by the SDK. Test should fail in
+            # this case.
+            test_passed = raw_data != b""
+            test_failure_reason = "request body has been read"
+
+    sentry_init(integrations=[FalconIntegration()])
+
+    try:
+        app_class = falcon.App  # Falcon ≥3.0
+    except AttributeError:
+        app_class = falcon.API  # Falcon <3.0
+
+    app = app_class(middleware=[SentryCaptureMiddleware()])
+    app.add_route("/read_body", RequestMediaResource())
+
+    client = falcon.testing.TestClient(app)
+
+    client.simulate_post("/read_body", json={"foo": "bar"})
+
+    # Check that simulate_post actually calls the resource, and
+    # that the SDK does not exhaust the request body stream.
+    assert test_passed, test_failure_reason
diff --git a/tests/integrations/fastapi/test_fastapi.py b/tests/integrations/fastapi/test_fastapi.py
index 524eed0560..005189f00c 100644
--- a/tests/integrations/fastapi/test_fastapi.py
+++ b/tests/integrations/fastapi/test_fastapi.py
@@ -1,21 +1,28 @@
 import json
 import logging
-import threading
-
 import pytest
-from sentry_sdk.integrations.fastapi import FastApiIntegration
+import threading
+import warnings
+from unittest import mock
 
-from fastapi import FastAPI, Request
+import fastapi
+from fastapi import FastAPI, HTTPException, Request
 from fastapi.testclient import TestClient
 from fastapi.middleware.trustedhost import TrustedHostMiddleware
+
+import sentry_sdk
 from sentry_sdk import capture_message
-from sentry_sdk.integrations.starlette import StarletteIntegration
+from sentry_sdk.feature_flags import add_feature_flag
 from sentry_sdk.integrations.asgi import SentryAsgiMiddleware
+from sentry_sdk.integrations.fastapi import FastApiIntegration
+from sentry_sdk.integrations.starlette import StarletteIntegration
+from sentry_sdk.utils import parse_version
+
+
+FASTAPI_VERSION = parse_version(fastapi.__version__)
 
-try:
-    from unittest import mock  # python 3.3 and above
-except ImportError:
-    import mock  # python < 3.3
+from tests.integrations.conftest import parametrize_test_configurable_status_codes
+from tests.integrations.starlette import test_starlette
 
 
 def fastapi_app_factory():
@@ -32,6 +39,17 @@ async def _message():
         capture_message("Hi")
         return {"message": "Hi"}
 
+    @app.delete("/nomessage")
+    @app.get("/nomessage")
+    @app.head("/nomessage")
+    @app.options("/nomessage")
+    @app.patch("/nomessage")
+    @app.post("/nomessage")
+    @app.put("/nomessage")
+    @app.trace("/nomessage")
+    async def _nomessage():
+        return {"message": "nothing here..."}
+
     @app.get("/message/{message_id}")
     async def _message_with_id(message_id):
         capture_message("Hi")
@@ -63,7 +81,6 @@ async def test_response(sentry_init, capture_events):
         integrations=[StarletteIntegration(), FastApiIntegration()],
         traces_sample_rate=1.0,
         send_default_pii=True,
-        debug=True,
     )
 
     app = fastapi_app_factory()
@@ -166,11 +183,11 @@ def test_legacy_setup(
 
 
 @pytest.mark.parametrize("endpoint", ["/sync/thread_ids", "/async/thread_ids"])
-@mock.patch("sentry_sdk.profiler.PROFILE_MINIMUM_SAMPLES", 0)
+@mock.patch("sentry_sdk.profiler.transaction_profiler.PROFILE_MINIMUM_SAMPLES", 0)
 def test_active_thread_id(sentry_init, capture_envelopes, teardown_profiling, endpoint):
     sentry_init(
         traces_sample_rate=1.0,
-        _experiments={"profiles_sample_rate": 1.0},
+        profiles_sample_rate=1.0,
     )
     app = fastapi_app_factory()
     asgi_app = SentryAsgiMiddleware(app)
@@ -189,18 +206,25 @@ def test_active_thread_id(sentry_init, capture_envelopes, teardown_profiling, en
     profiles = [item for item in envelopes[0].items if item.type == "profile"]
     assert len(profiles) == 1
 
-    for profile in profiles:
-        transactions = profile.payload.json["transactions"]
+    for item in profiles:
+        transactions = item.payload.json["transactions"]
         assert len(transactions) == 1
         assert str(data["active"]) == transactions[0]["active_thread_id"]
 
+    transactions = [item for item in envelopes[0].items if item.type == "transaction"]
+    assert len(transactions) == 1
+
+    for item in transactions:
+        transaction = item.payload.json
+        trace_context = transaction["contexts"]["trace"]
+        assert str(data["active"]) == trace_context["data"]["thread.id"]
+
 
 @pytest.mark.asyncio
 async def test_original_request_not_scrubbed(sentry_init, capture_events):
     sentry_init(
         integrations=[StarletteIntegration(), FastApiIntegration()],
         traces_sample_rate=1.0,
-        debug=True,
     )
 
     app = FastAPI()
@@ -225,7 +249,6 @@ async def _error(request: Request):
     assert event["request"]["headers"]["authorization"] == "[Filtered]"
 
 
-@pytest.mark.asyncio
 def test_response_status_code_ok_in_transaction_context(sentry_init, capture_envelopes):
     """
     Tests that the response status code is added to the transaction "response" context.
@@ -248,13 +271,12 @@ def test_response_status_code_ok_in_transaction_context(sentry_init, capture_env
 
     assert transaction["type"] == "transaction"
     assert len(transaction["contexts"]) > 0
-    assert (
-        "response" in transaction["contexts"].keys()
-    ), "Response context not found in transaction"
+    assert "response" in transaction["contexts"].keys(), (
+        "Response context not found in transaction"
+    )
     assert transaction["contexts"]["response"]["status_code"] == 200
 
 
-@pytest.mark.asyncio
 def test_response_status_code_error_in_transaction_context(
     sentry_init,
     capture_envelopes,
@@ -285,13 +307,12 @@ def test_response_status_code_error_in_transaction_context(
 
     assert transaction["type"] == "transaction"
     assert len(transaction["contexts"]) > 0
-    assert (
-        "response" in transaction["contexts"].keys()
-    ), "Response context not found in transaction"
+    assert "response" in transaction["contexts"].keys(), (
+        "Response context not found in transaction"
+    )
     assert transaction["contexts"]["response"]["status_code"] == 500
 
 
-@pytest.mark.asyncio
 def test_response_status_code_not_found_in_transaction_context(
     sentry_init,
     capture_envelopes,
@@ -317,9 +338,9 @@ def test_response_status_code_not_found_in_transaction_context(
 
     assert transaction["type"] == "transaction"
     assert len(transaction["contexts"]) > 0
-    assert (
-        "response" in transaction["contexts"].keys()
-    ), "Response context not found in transaction"
+    assert "response" in transaction["contexts"].keys(), (
+        "Response context not found in transaction"
+    )
     assert transaction["contexts"]["response"]["status_code"] == 404
 
 
@@ -358,7 +379,6 @@ def test_transaction_name(
             FastApiIntegration(transaction_style=transaction_style),
         ],
         traces_sample_rate=1.0,
-        debug=True,
     )
 
     envelopes = capture_envelopes()
@@ -377,6 +397,27 @@ def test_transaction_name(
     )
 
 
+def test_route_endpoint_equal_dependant_call(sentry_init):
+    """
+    Tests that the route endpoint name is equal to the wrapped dependant call name.
+    """
+    sentry_init(
+        auto_enabling_integrations=False,  # Make sure that httpx integration is not added, because it adds tracing information to the starlette test clients request.
+        integrations=[
+            StarletteIntegration(),
+            FastApiIntegration(),
+        ],
+        traces_sample_rate=1.0,
+    )
+
+    app = fastapi_app_factory()
+
+    for route in app.router.routes:
+        if not hasattr(route, "dependant"):
+            continue
+        assert route.endpoint.__qualname__ == route.dependant.call.__qualname__
+
+
 @pytest.mark.parametrize(
     "request_url,transaction_style,expected_transaction_name,expected_transaction_source",
     [
@@ -420,7 +461,6 @@ def dummy_traces_sampler(sampling_context):
         integrations=[StarletteIntegration(transaction_style=transaction_style)],
         traces_sampler=dummy_traces_sampler,
         traces_sample_rate=1.0,
-        debug=True,
     )
 
     app = fastapi_app_factory()
@@ -429,6 +469,7 @@ def dummy_traces_sampler(sampling_context):
     client.get(request_url)
 
 
+@pytest.mark.parametrize("middleware_spans", [False, True])
 @pytest.mark.parametrize(
     "request_url,transaction_style,expected_transaction_name,expected_transaction_source",
     [
@@ -448,6 +489,7 @@ def dummy_traces_sampler(sampling_context):
 )
 def test_transaction_name_in_middleware(
     sentry_init,
+    middleware_spans,
     request_url,
     transaction_style,
     expected_transaction_name,
@@ -460,11 +502,14 @@ def test_transaction_name_in_middleware(
     sentry_init(
         auto_enabling_integrations=False,  # Make sure that httpx integration is not added, because it adds tracing information to the starlette test clients request.
         integrations=[
-            StarletteIntegration(transaction_style=transaction_style),
-            FastApiIntegration(transaction_style=transaction_style),
+            StarletteIntegration(
+                transaction_style=transaction_style, middleware_spans=middleware_spans
+            ),
+            FastApiIntegration(
+                transaction_style=transaction_style, middleware_spans=middleware_spans
+            ),
         ],
         traces_sample_rate=1.0,
-        debug=True,
     )
 
     envelopes = capture_envelopes()
@@ -489,3 +534,229 @@ def test_transaction_name_in_middleware(
     assert (
         transaction_event["transaction_info"]["source"] == expected_transaction_source
     )
+
+
+@test_starlette.parametrize_test_configurable_status_codes_deprecated
+def test_configurable_status_codes_deprecated(
+    sentry_init,
+    capture_events,
+    failed_request_status_codes,
+    status_code,
+    expected_error,
+):
+    with pytest.warns(DeprecationWarning):
+        starlette_integration = StarletteIntegration(
+            failed_request_status_codes=failed_request_status_codes
+        )
+
+    with pytest.warns(DeprecationWarning):
+        fast_api_integration = FastApiIntegration(
+            failed_request_status_codes=failed_request_status_codes
+        )
+
+    sentry_init(
+        integrations=[
+            starlette_integration,
+            fast_api_integration,
+        ]
+    )
+
+    events = capture_events()
+
+    app = FastAPI()
+
+    @app.get("/error")
+    async def _error():
+        raise HTTPException(status_code)
+
+    client = TestClient(app)
+    client.get("/error")
+
+    if expected_error:
+        assert len(events) == 1
+    else:
+        assert not events
+
+
+@pytest.mark.skipif(
+    FASTAPI_VERSION < (0, 80),
+    reason="Requires FastAPI >= 0.80, because earlier versions do not support HTTP 'HEAD' requests",
+)
+def test_transaction_http_method_default(sentry_init, capture_events):
+    """
+    By default OPTIONS and HEAD requests do not create a transaction.
+    """
+    # FastAPI is heavily based on Starlette so we also need
+    # to enable StarletteIntegration.
+    # In the future this will be auto enabled.
+    sentry_init(
+        traces_sample_rate=1.0,
+        integrations=[
+            StarletteIntegration(),
+            FastApiIntegration(),
+        ],
+    )
+
+    app = fastapi_app_factory()
+
+    events = capture_events()
+
+    client = TestClient(app)
+    client.get("/nomessage")
+    client.options("/nomessage")
+    client.head("/nomessage")
+
+    assert len(events) == 1
+
+    (event,) = events
+
+    assert event["request"]["method"] == "GET"
+
+
+@pytest.mark.skipif(
+    FASTAPI_VERSION < (0, 80),
+    reason="Requires FastAPI >= 0.80, because earlier versions do not support HTTP 'HEAD' requests",
+)
+def test_transaction_http_method_custom(sentry_init, capture_events):
+    # FastAPI is heavily based on Starlette so we also need
+    # to enable StarletteIntegration.
+    # In the future this will be auto enabled.
+    sentry_init(
+        traces_sample_rate=1.0,
+        integrations=[
+            StarletteIntegration(
+                http_methods_to_capture=(
+                    "OPTIONS",
+                    "head",
+                ),  # capitalization does not matter
+            ),
+            FastApiIntegration(
+                http_methods_to_capture=(
+                    "OPTIONS",
+                    "head",
+                ),  # capitalization does not matter
+            ),
+        ],
+    )
+
+    app = fastapi_app_factory()
+
+    events = capture_events()
+
+    client = TestClient(app)
+    client.get("/nomessage")
+    client.options("/nomessage")
+    client.head("/nomessage")
+
+    assert len(events) == 2
+
+    (event1, event2) = events
+
+    assert event1["request"]["method"] == "OPTIONS"
+    assert event2["request"]["method"] == "HEAD"
+
+
+@parametrize_test_configurable_status_codes
+def test_configurable_status_codes(
+    sentry_init,
+    capture_events,
+    failed_request_status_codes,
+    status_code,
+    expected_error,
+):
+    integration_kwargs = {}
+    if failed_request_status_codes is not None:
+        integration_kwargs["failed_request_status_codes"] = failed_request_status_codes
+
+    with warnings.catch_warnings():
+        warnings.simplefilter("error", DeprecationWarning)
+        starlette_integration = StarletteIntegration(**integration_kwargs)
+        fastapi_integration = FastApiIntegration(**integration_kwargs)
+
+    sentry_init(integrations=[starlette_integration, fastapi_integration])
+
+    events = capture_events()
+
+    app = FastAPI()
+
+    @app.get("/error")
+    async def _error():
+        raise HTTPException(status_code)
+
+    client = TestClient(app)
+    client.get("/error")
+
+    assert len(events) == int(expected_error)
+
+
+@pytest.mark.parametrize("transaction_style", ["endpoint", "url"])
+def test_app_host(sentry_init, capture_events, transaction_style):
+    sentry_init(
+        traces_sample_rate=1.0,
+        integrations=[
+            StarletteIntegration(transaction_style=transaction_style),
+            FastApiIntegration(transaction_style=transaction_style),
+        ],
+    )
+
+    app = FastAPI()
+    subapp = FastAPI()
+
+    @subapp.get("/subapp")
+    async def subapp_route():
+        return {"message": "Hello world!"}
+
+    app.host("subapp", subapp)
+
+    events = capture_events()
+
+    client = TestClient(app)
+    client.get("/subapp", headers={"Host": "subapp"})
+
+    assert len(events) == 1
+
+    (event,) = events
+    assert "transaction" in event
+
+    if transaction_style == "url":
+        assert event["transaction"] == "/subapp"
+    else:
+        assert event["transaction"].endswith("subapp_route")
+
+
+@pytest.mark.asyncio
+async def test_feature_flags(sentry_init, capture_events):
+    sentry_init(
+        traces_sample_rate=1.0,
+        integrations=[StarletteIntegration(), FastApiIntegration()],
+    )
+
+    events = capture_events()
+
+    app = FastAPI()
+
+    @app.get("/error")
+    async def _error():
+        add_feature_flag("hello", False)
+
+        with sentry_sdk.start_span(name="test-span"):
+            with sentry_sdk.start_span(name="test-span-2"):
+                raise ValueError("something is wrong!")
+
+    try:
+        client = TestClient(app)
+        client.get("/error")
+    except ValueError:
+        pass
+
+    found = False
+    for event in events:
+        if "exception" in event.keys():
+            assert event["contexts"]["flags"] == {
+                "values": [
+                    {"flag": "hello", "result": False},
+                ]
+            }
+            found = True
+
+    assert found, "No event with exception found"
diff --git a/tests/integrations/fastmcp/__init__.py b/tests/integrations/fastmcp/__init__.py
new file mode 100644
index 0000000000..01ef442500
--- /dev/null
+++ b/tests/integrations/fastmcp/__init__.py
@@ -0,0 +1,3 @@
+import pytest
+
+pytest.importorskip("mcp")
diff --git a/tests/integrations/fastmcp/test_fastmcp.py b/tests/integrations/fastmcp/test_fastmcp.py
new file mode 100644
index 0000000000..ef2a1f9cb7
--- /dev/null
+++ b/tests/integrations/fastmcp/test_fastmcp.py
@@ -0,0 +1,1135 @@
+"""
+Unit tests for the Sentry MCP integration with FastMCP.
+
+This test suite verifies that Sentry's MCPIntegration properly instruments
+both FastMCP implementations:
+- mcp.server.fastmcp.FastMCP (FastMCP from the mcp package)
+- fastmcp.FastMCP (standalone fastmcp package)
+
+Tests focus on verifying Sentry integration behavior:
+- Integration doesn't break FastMCP functionality
+- Span creation when tools/prompts/resources are called through MCP protocol
+- Span data accuracy (operation, description, origin, etc.)
+- Error capture and instrumentation
+- PII and include_prompts flag behavior
+- Request context data extraction
+- Transport detection (stdio, http, sse)
+
+All tests invoke tools/prompts/resources through the MCP Server's low-level
+request handlers (via CallToolRequest, GetPromptRequest, ReadResourceRequest)
+to properly trigger Sentry instrumentation and span creation. This ensures
+accurate testing of the integration's behavior in real MCP Server scenarios.
+"""
+
+import asyncio
+import json
+import pytest
+from unittest import mock
+
+try:
+    from unittest.mock import AsyncMock
+except ImportError:
+
+    class AsyncMock(mock.MagicMock):
+        async def __call__(self, *args, **kwargs):
+            return super(AsyncMock, self).__call__(*args, **kwargs)
+
+
+from sentry_sdk import start_transaction
+from sentry_sdk.consts import SPANDATA, OP
+from sentry_sdk.integrations.mcp import MCPIntegration
+
+# Try to import both FastMCP implementations
+try:
+    from mcp.server.fastmcp import FastMCP as MCPFastMCP
+
+    HAS_MCP_FASTMCP = True
+except ImportError:
+    HAS_MCP_FASTMCP = False
+    MCPFastMCP = None
+
+try:
+    from fastmcp import FastMCP as StandaloneFastMCP
+
+    HAS_STANDALONE_FASTMCP = True
+except ImportError:
+    HAS_STANDALONE_FASTMCP = False
+    StandaloneFastMCP = None
+
+# Try to import request_ctx for context testing
+try:
+    from mcp.server.lowlevel.server import request_ctx
+except ImportError:
+    request_ctx = None
+
+# Try to import MCP types for helper functions
+try:
+    from mcp.types import CallToolRequest, GetPromptRequest, ReadResourceRequest
+except ImportError:
+    # If mcp.types not available, tests will be skipped anyway
+    CallToolRequest = None
+    GetPromptRequest = None
+    ReadResourceRequest = None
+
+
+# Collect available FastMCP implementations for parametrization
+fastmcp_implementations = []
+fastmcp_ids = []
+
+if HAS_MCP_FASTMCP:
+    fastmcp_implementations.append(MCPFastMCP)
+    fastmcp_ids.append("mcp.server.fastmcp")
+
+if HAS_STANDALONE_FASTMCP:
+    fastmcp_implementations.append(StandaloneFastMCP)
+    fastmcp_ids.append("fastmcp")
+
+
+# Helper functions to call tools through MCP Server protocol
+def call_tool_through_mcp(mcp_instance, tool_name, arguments):
+    """
+    Call a tool through MCP Server's low-level handler.
+    This properly triggers Sentry instrumentation.
+
+    Args:
+        mcp_instance: The FastMCP instance
+        tool_name: Name of the tool to call
+        arguments: Dictionary of arguments to pass to the tool
+
+    Returns:
+        The tool result normalized to {"result": value} format
+    """
+    handler = mcp_instance._mcp_server.request_handlers[CallToolRequest]
+    request = CallToolRequest(
+        method="tools/call", params={"name": tool_name, "arguments": arguments}
+    )
+
+    result = asyncio.run(handler(request))
+
+    if hasattr(result, "root"):
+        result = result.root
+    if hasattr(result, "structuredContent") and result.structuredContent:
+        result = result.structuredContent
+    elif hasattr(result, "content"):
+        if result.content:
+            text = result.content[0].text
+            try:
+                result = json.loads(text)
+            except (json.JSONDecodeError, TypeError):
+                result = text
+        else:
+            # Empty content means None return
+            result = None
+
+    # Normalize return value to consistent format
+    # If already a dict, return as-is (tool functions return dicts directly)
+    if isinstance(result, dict):
+        return result
+
+    # Handle string "None" or "null" as actual None
+    if isinstance(result, str) and result in ("None", "null"):
+        result = None
+
+    # Wrap primitive values (int, str, bool, None) in dict format for consistency
+    return {"result": result}
+
+
+async def call_tool_through_mcp_async(mcp_instance, tool_name, arguments):
+    """Async version of call_tool_through_mcp."""
+    handler = mcp_instance._mcp_server.request_handlers[CallToolRequest]
+    request = CallToolRequest(
+        method="tools/call", params={"name": tool_name, "arguments": arguments}
+    )
+
+    result = await handler(request)
+
+    if hasattr(result, "root"):
+        result = result.root
+    if hasattr(result, "structuredContent") and result.structuredContent:
+        result = result.structuredContent
+    elif hasattr(result, "content"):
+        if result.content:
+            text = result.content[0].text
+            try:
+                result = json.loads(text)
+            except (json.JSONDecodeError, TypeError):
+                result = text
+        else:
+            # Empty content means None return
+            result = None
+
+    # Normalize return value to consistent format
+    # If already a dict, return as-is (tool functions return dicts directly)
+    if isinstance(result, dict):
+        return result
+
+    # Handle string "None" or "null" as actual None
+    if isinstance(result, str) and result in ("None", "null"):
+        result = None
+
+    # Wrap primitive values (int, str, bool, None) in dict format for consistency
+    return {"result": result}
+
+
+def call_prompt_through_mcp(mcp_instance, prompt_name, arguments=None):
+    """Call a prompt through MCP Server's low-level handler."""
+    handler = mcp_instance._mcp_server.request_handlers[GetPromptRequest]
+    request = GetPromptRequest(
+        method="prompts/get", params={"name": prompt_name, "arguments": arguments or {}}
+    )
+
+    result = asyncio.run(handler(request))
+    if hasattr(result, "root"):
+        result = result.root
+    return result
+
+
+async def call_prompt_through_mcp_async(mcp_instance, prompt_name, arguments=None):
+    """Async version of call_prompt_through_mcp."""
+    handler = mcp_instance._mcp_server.request_handlers[GetPromptRequest]
+    request = GetPromptRequest(
+        method="prompts/get", params={"name": prompt_name, "arguments": arguments or {}}
+    )
+
+    result = await handler(request)
+    if hasattr(result, "root"):
+        result = result.root
+    return result
+
+
+def call_resource_through_mcp(mcp_instance, uri):
+    """Call a resource through MCP Server's low-level handler."""
+    handler = mcp_instance._mcp_server.request_handlers[ReadResourceRequest]
+    request = ReadResourceRequest(method="resources/read", params={"uri": str(uri)})
+
+    result = asyncio.run(handler(request))
+    if hasattr(result, "root"):
+        result = result.root
+    return result
+
+
+async def call_resource_through_mcp_async(mcp_instance, uri):
+    """Async version of call_resource_through_mcp."""
+    handler = mcp_instance._mcp_server.request_handlers[ReadResourceRequest]
+    request = ReadResourceRequest(method="resources/read", params={"uri": str(uri)})
+
+    result = await handler(request)
+    if hasattr(result, "root"):
+        result = result.root
+    return result
+
+
+# Skip all tests if neither implementation is available
+pytestmark = pytest.mark.skipif(
+    not (HAS_MCP_FASTMCP or HAS_STANDALONE_FASTMCP),
+    reason="Neither mcp.fastmcp nor standalone fastmcp is installed",
+)
+
+
+@pytest.fixture(autouse=True)
+def reset_request_ctx():
+    """Reset request context before and after each test"""
+    if request_ctx is not None:
+        try:
+            if request_ctx.get() is not None:
+                request_ctx.set(None)
+        except LookupError:
+            pass
+
+    yield
+
+    if request_ctx is not None:
+        try:
+            request_ctx.set(None)
+        except LookupError:
+            pass
+
+
+class MockRequestContext:
+    """Mock MCP request context"""
+
+    def __init__(self, request_id=None, session_id=None, transport="stdio"):
+        self.request_id = request_id
+        if transport in ("http", "sse"):
+            self.request = MockHTTPRequest(session_id, transport)
+        else:
+            self.request = None
+
+
+class MockHTTPRequest:
+    """Mock HTTP request for SSE/StreamableHTTP transport"""
+
+    def __init__(self, session_id=None, transport="http"):
+        self.headers = {}
+        self.query_params = {}
+
+        if transport == "sse":
+            # SSE transport uses query parameter
+            if session_id:
+                self.query_params["session_id"] = session_id
+        else:
+            # StreamableHTTP transport uses header
+            if session_id:
+                self.headers["mcp-session-id"] = session_id
+
+
+# =============================================================================
+# Tool Handler Tests - Verifying Sentry Integration
+# =============================================================================
+
+
+@pytest.mark.parametrize("FastMCP", fastmcp_implementations, ids=fastmcp_ids)
+@pytest.mark.parametrize(
+    "send_default_pii, include_prompts",
+    [(True, True), (True, False), (False, True), (False, False)],
+)
+def test_fastmcp_tool_sync(
+    sentry_init, capture_events, FastMCP, send_default_pii, include_prompts
+):
+    """Test that FastMCP synchronous tool handlers create proper spans"""
+    sentry_init(
+        integrations=[MCPIntegration(include_prompts=include_prompts)],
+        traces_sample_rate=1.0,
+        send_default_pii=send_default_pii,
+    )
+    events = capture_events()
+
+    mcp = FastMCP("Test Server")
+
+    # Set up mock request context
+    if request_ctx is not None:
+        mock_ctx = MockRequestContext(request_id="req-123", transport="stdio")
+        request_ctx.set(mock_ctx)
+
+    @mcp.tool()
+    def add_numbers(a: int, b: int) -> dict:
+        """Add two numbers together"""
+        return {"result": a + b, "operation": "addition"}
+
+    with start_transaction(name="fastmcp tx"):
+        # Call through MCP protocol to trigger instrumentation
+        result = call_tool_through_mcp(mcp, "add_numbers", {"a": 10, "b": 5})
+
+    assert result == {"result": 15, "operation": "addition"}
+
+    (tx,) = events
+    assert tx["type"] == "transaction"
+    assert len(tx["spans"]) == 1
+
+    # Verify span structure
+    span = tx["spans"][0]
+    assert span["op"] == OP.MCP_SERVER
+    assert span["origin"] == "auto.ai.mcp"
+    assert span["description"] == "tools/call add_numbers"
+    assert span["data"][SPANDATA.MCP_TOOL_NAME] == "add_numbers"
+    assert span["data"][SPANDATA.MCP_METHOD_NAME] == "tools/call"
+    assert span["data"][SPANDATA.MCP_TRANSPORT] == "stdio"
+    assert span["data"][SPANDATA.MCP_REQUEST_ID] == "req-123"
+
+    # Check PII-sensitive data
+    if send_default_pii and include_prompts:
+        assert SPANDATA.MCP_TOOL_RESULT_CONTENT in span["data"]
+    else:
+        assert SPANDATA.MCP_TOOL_RESULT_CONTENT not in span["data"]
+
+
+@pytest.mark.parametrize("FastMCP", fastmcp_implementations, ids=fastmcp_ids)
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    "send_default_pii, include_prompts",
+    [(True, True), (True, False), (False, True), (False, False)],
+)
+async def test_fastmcp_tool_async(
+    sentry_init, capture_events, FastMCP, send_default_pii, include_prompts
+):
+    """Test that FastMCP async tool handlers create proper spans"""
+    sentry_init(
+        integrations=[MCPIntegration(include_prompts=include_prompts)],
+        traces_sample_rate=1.0,
+        send_default_pii=send_default_pii,
+    )
+    events = capture_events()
+
+    mcp = FastMCP("Test Server")
+
+    # Set up mock request context
+    if request_ctx is not None:
+        mock_ctx = MockRequestContext(
+            request_id="req-456", session_id="session-789", transport="http"
+        )
+        request_ctx.set(mock_ctx)
+
+    @mcp.tool()
+    async def multiply_numbers(x: int, y: int) -> dict:
+        """Multiply two numbers together"""
+        return {"result": x * y, "operation": "multiplication"}
+
+    with start_transaction(name="fastmcp tx"):
+        result = await call_tool_through_mcp_async(
+            mcp, "multiply_numbers", {"x": 7, "y": 6}
+        )
+
+    assert result == {"result": 42, "operation": "multiplication"}
+
+    (tx,) = events
+    assert tx["type"] == "transaction"
+    assert len(tx["spans"]) == 1
+
+    # Verify span structure
+    span = tx["spans"][0]
+    assert span["op"] == OP.MCP_SERVER
+    assert span["origin"] == "auto.ai.mcp"
+    assert span["description"] == "tools/call multiply_numbers"
+    assert span["data"][SPANDATA.MCP_TOOL_NAME] == "multiply_numbers"
+    assert span["data"][SPANDATA.MCP_METHOD_NAME] == "tools/call"
+    assert span["data"][SPANDATA.MCP_TRANSPORT] == "http"
+    assert span["data"][SPANDATA.MCP_REQUEST_ID] == "req-456"
+    assert span["data"][SPANDATA.MCP_SESSION_ID] == "session-789"
+
+    # Check PII-sensitive data
+    if send_default_pii and include_prompts:
+        assert SPANDATA.MCP_TOOL_RESULT_CONTENT in span["data"]
+    else:
+        assert SPANDATA.MCP_TOOL_RESULT_CONTENT not in span["data"]
+
+
+@pytest.mark.parametrize("FastMCP", fastmcp_implementations, ids=fastmcp_ids)
+def test_fastmcp_tool_with_error(sentry_init, capture_events, FastMCP):
+    """Test that FastMCP tool handler errors are captured properly"""
+    sentry_init(
+        integrations=[MCPIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    mcp = FastMCP("Test Server")
+
+    # Set up mock request context
+    if request_ctx is not None:
+        mock_ctx = MockRequestContext(request_id="req-error", transport="stdio")
+        request_ctx.set(mock_ctx)
+
+    @mcp.tool()
+    def failing_tool(value: int) -> int:
+        """A tool that always fails"""
+        raise ValueError("Tool execution failed")
+
+    with start_transaction(name="fastmcp tx"):
+        # MCP protocol may raise the error or return it as an error result
+        try:
+            result = call_tool_through_mcp(mcp, "failing_tool", {"value": 42})
+            # If no exception raised, check if result indicates error
+            if hasattr(result, "isError"):
+                assert result.isError is True
+        except ValueError:
+            # Error was raised as expected
+            pass
+
+    # Should have transaction and error events
+    assert len(events) >= 1
+
+    # Check span was created
+    tx = [e for e in events if e.get("type") == "transaction"][0]
+    tool_spans = [s for s in tx["spans"] if s["op"] == OP.MCP_SERVER]
+    assert len(tool_spans) == 1
+
+    # Check error event was captured
+    error_events = [e for e in events if e.get("level") == "error"]
+    assert len(error_events) >= 1
+    error_event = error_events[0]
+    assert error_event["exception"]["values"][0]["type"] == "ValueError"
+    assert error_event["exception"]["values"][0]["value"] == "Tool execution failed"
+    # Verify span is marked with error
+    assert tool_spans[0]["data"][SPANDATA.MCP_TOOL_RESULT_IS_ERROR] is True
+
+
+@pytest.mark.parametrize("FastMCP", fastmcp_implementations, ids=fastmcp_ids)
+def test_fastmcp_multiple_tools(sentry_init, capture_events, FastMCP):
+    """Test that multiple FastMCP tool calls create multiple spans"""
+    sentry_init(
+        integrations=[MCPIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    mcp = FastMCP("Test Server")
+
+    # Set up mock request context
+    if request_ctx is not None:
+        mock_ctx = MockRequestContext(request_id="req-multi", transport="stdio")
+        request_ctx.set(mock_ctx)
+
+    @mcp.tool()
+    def tool_one(x: int) -> int:
+        """First tool"""
+        return x * 2
+
+    @mcp.tool()
+    def tool_two(y: int) -> int:
+        """Second tool"""
+        return y + 10
+
+    @mcp.tool()
+    def tool_three(z: int) -> int:
+        """Third tool"""
+        return z - 5
+
+    with start_transaction(name="fastmcp tx"):
+        result1 = call_tool_through_mcp(mcp, "tool_one", {"x": 5})
+        result2 = call_tool_through_mcp(mcp, "tool_two", {"y": result1["result"]})
+        result3 = call_tool_through_mcp(mcp, "tool_three", {"z": result2["result"]})
+
+    assert result1["result"] == 10
+    assert result2["result"] == 20
+    assert result3["result"] == 15
+
+    (tx,) = events
+    assert tx["type"] == "transaction"
+
+    # Verify three spans were created
+    tool_spans = [s for s in tx["spans"] if s["op"] == OP.MCP_SERVER]
+    assert len(tool_spans) == 3
+    assert tool_spans[0]["data"][SPANDATA.MCP_TOOL_NAME] == "tool_one"
+    assert tool_spans[1]["data"][SPANDATA.MCP_TOOL_NAME] == "tool_two"
+    assert tool_spans[2]["data"][SPANDATA.MCP_TOOL_NAME] == "tool_three"
+
+
+@pytest.mark.parametrize("FastMCP", fastmcp_implementations, ids=fastmcp_ids)
+def test_fastmcp_tool_with_complex_return(sentry_init, capture_events, FastMCP):
+    """Test FastMCP tool with complex nested return value"""
+    sentry_init(
+        integrations=[MCPIntegration(include_prompts=True)],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+    events = capture_events()
+
+    mcp = FastMCP("Test Server")
+
+    # Set up mock request context
+    if request_ctx is not None:
+        mock_ctx = MockRequestContext(request_id="req-complex", transport="stdio")
+        request_ctx.set(mock_ctx)
+
+    @mcp.tool()
+    def get_user_data(user_id: int) -> dict:
+        """Get complex user data"""
+        return {
+            "id": user_id,
+            "name": "Alice",
+            "nested": {"preferences": {"theme": "dark", "notifications": True}},
+            "tags": ["admin", "verified"],
+        }
+
+    with start_transaction(name="fastmcp tx"):
+        result = call_tool_through_mcp(mcp, "get_user_data", {"user_id": 123})
+
+    assert result["id"] == 123
+    assert result["name"] == "Alice"
+    assert result["nested"]["preferences"]["theme"] == "dark"
+
+    (tx,) = events
+    assert tx["type"] == "transaction"
+
+    # Verify span was created with complex data
+    tool_spans = [s for s in tx["spans"] if s["op"] == OP.MCP_SERVER]
+    assert len(tool_spans) == 1
+    assert tool_spans[0]["op"] == OP.MCP_SERVER
+    assert tool_spans[0]["data"][SPANDATA.MCP_TOOL_NAME] == "get_user_data"
+    # Complex return value should be captured since include_prompts=True and send_default_pii=True
+    assert SPANDATA.MCP_TOOL_RESULT_CONTENT in tool_spans[0]["data"]
+
+
+# =============================================================================
+# Prompt Handler Tests (if supported)
+# =============================================================================
+
+
+@pytest.mark.parametrize("FastMCP", fastmcp_implementations, ids=fastmcp_ids)
+@pytest.mark.parametrize(
+    "send_default_pii, include_prompts",
+    [(True, True), (False, False)],
+)
+def test_fastmcp_prompt_sync(
+    sentry_init, capture_events, FastMCP, send_default_pii, include_prompts
+):
+    """Test that FastMCP synchronous prompt handlers create proper spans"""
+    sentry_init(
+        integrations=[MCPIntegration(include_prompts=include_prompts)],
+        traces_sample_rate=1.0,
+        send_default_pii=send_default_pii,
+    )
+    events = capture_events()
+
+    mcp = FastMCP("Test Server")
+
+    # Set up mock request context
+    if request_ctx is not None:
+        mock_ctx = MockRequestContext(request_id="req-prompt", transport="stdio")
+        request_ctx.set(mock_ctx)
+
+    # Try to register a prompt handler (may not be supported in all versions)
+    try:
+        if hasattr(mcp, "prompt"):
+
+            @mcp.prompt()
+            def code_help_prompt(language: str):
+                """Get help for a programming language"""
+                return [
+                    {
+                        "role": "user",
+                        "content": {
+                            "type": "text",
+                            "text": f"Tell me about {language}",
+                        },
+                    }
+                ]
+
+            with start_transaction(name="fastmcp tx"):
+                result = call_prompt_through_mcp(
+                    mcp, "code_help_prompt", {"language": "python"}
+                )
+
+            assert result.messages[0].role == "user"
+            assert "python" in result.messages[0].content.text.lower()
+
+            (tx,) = events
+            assert tx["type"] == "transaction"
+
+            # Verify prompt span was created
+            prompt_spans = [s for s in tx["spans"] if s["op"] == OP.MCP_SERVER]
+            assert len(prompt_spans) == 1
+            span = prompt_spans[0]
+            assert span["origin"] == "auto.ai.mcp"
+            assert span["description"] == "prompts/get code_help_prompt"
+            assert span["data"][SPANDATA.MCP_PROMPT_NAME] == "code_help_prompt"
+
+            # Check PII-sensitive data
+            if send_default_pii and include_prompts:
+                assert SPANDATA.MCP_PROMPT_CONTENT in span["data"]
+            else:
+                assert SPANDATA.MCP_PROMPT_CONTENT not in span["data"]
+    except AttributeError:
+        # Prompt handler not supported in this version
+        pytest.skip("Prompt handlers not supported in this FastMCP version")
+
+
+@pytest.mark.parametrize("FastMCP", fastmcp_implementations, ids=fastmcp_ids)
+@pytest.mark.asyncio
+async def test_fastmcp_prompt_async(sentry_init, capture_events, FastMCP):
+    """Test that FastMCP async prompt handlers create proper spans"""
+    sentry_init(
+        integrations=[MCPIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    mcp = FastMCP("Test Server")
+
+    # Set up mock request context
+    if request_ctx is not None:
+        mock_ctx = MockRequestContext(
+            request_id="req-async-prompt", session_id="session-abc", transport="http"
+        )
+        request_ctx.set(mock_ctx)
+
+    # Try to register an async prompt handler
+    try:
+        if hasattr(mcp, "prompt"):
+
+            @mcp.prompt()
+            async def async_prompt(topic: str):
+                """Get async prompt for a topic"""
+                return [
+                    {
+                        "role": "user",
+                        "content": {"type": "text", "text": f"What is {topic}?"},
+                    },
+                    {
+                        "role": "assistant",
+                        "content": {
+                            "type": "text",
+                            "text": "Let me explain that",
+                        },
+                    },
+                ]
+
+            with start_transaction(name="fastmcp tx"):
+                result = await call_prompt_through_mcp_async(
+                    mcp, "async_prompt", {"topic": "MCP"}
+                )
+
+            assert len(result.messages) == 2
+
+            (tx,) = events
+            assert tx["type"] == "transaction"
+    except AttributeError:
+        # Prompt handler not supported in this version
+        pytest.skip("Prompt handlers not supported in this FastMCP version")
+
+
+# =============================================================================
+# Resource Handler Tests (if supported)
+# =============================================================================
+
+
+@pytest.mark.parametrize("FastMCP", fastmcp_implementations, ids=fastmcp_ids)
+def test_fastmcp_resource_sync(sentry_init, capture_events, FastMCP):
+    """Test that FastMCP synchronous resource handlers create proper spans"""
+    sentry_init(
+        integrations=[MCPIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    mcp = FastMCP("Test Server")
+
+    # Set up mock request context
+    if request_ctx is not None:
+        mock_ctx = MockRequestContext(request_id="req-resource", transport="stdio")
+        request_ctx.set(mock_ctx)
+
+    # Try to register a resource handler
+    try:
+        if hasattr(mcp, "resource"):
+
+            @mcp.resource("file:///{path}")
+            def read_file(path: str):
+                """Read a file resource"""
+                return "file contents"
+
+            with start_transaction(name="fastmcp tx"):
+                try:
+                    result = call_resource_through_mcp(mcp, "file:///test.txt")
+                except ValueError as e:
+                    # Older FastMCP versions may not support this URI pattern
+                    if "Unknown resource" in str(e):
+                        pytest.skip(
+                            f"Resource URI not supported in this FastMCP version: {e}"
+                        )
+                    raise
+
+            # Resource content is returned as-is
+            assert "file contents" in result.contents[0].text
+
+            (tx,) = events
+            assert tx["type"] == "transaction"
+
+            # Verify resource span was created
+            resource_spans = [s for s in tx["spans"] if s["op"] == OP.MCP_SERVER]
+            assert len(resource_spans) == 1
+            span = resource_spans[0]
+            assert span["origin"] == "auto.ai.mcp"
+            assert span["description"] == "resources/read file:///test.txt"
+            assert span["data"][SPANDATA.MCP_RESOURCE_PROTOCOL] == "file"
+    except (AttributeError, TypeError):
+        # Resource handler not supported in this version
+        pytest.skip("Resource handlers not supported in this FastMCP version")
+
+
+@pytest.mark.parametrize("FastMCP", fastmcp_implementations, ids=fastmcp_ids)
+@pytest.mark.asyncio
+async def test_fastmcp_resource_async(sentry_init, capture_events, FastMCP):
+    """Test that FastMCP async resource handlers create proper spans"""
+    sentry_init(
+        integrations=[MCPIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    mcp = FastMCP("Test Server")
+
+    # Set up mock request context
+    if request_ctx is not None:
+        mock_ctx = MockRequestContext(
+            request_id="req-async-resource", session_id="session-res", transport="http"
+        )
+        request_ctx.set(mock_ctx)
+
+    # Try to register an async resource handler
+    try:
+        if hasattr(mcp, "resource"):
+
+            @mcp.resource("https://example.com/{resource}")
+            async def read_url(resource: str):
+                """Read a URL resource"""
+                return "resource data"
+
+            with start_transaction(name="fastmcp tx"):
+                try:
+                    result = await call_resource_through_mcp_async(
+                        mcp, "https://example.com/resource"
+                    )
+                except ValueError as e:
+                    # Older FastMCP versions may not support this URI pattern
+                    if "Unknown resource" in str(e):
+                        pytest.skip(
+                            f"Resource URI not supported in this FastMCP version: {e}"
+                        )
+                    raise
+
+            assert "resource data" in result.contents[0].text
+
+            (tx,) = events
+            assert tx["type"] == "transaction"
+
+            # Verify span was created
+            resource_spans = [s for s in tx["spans"] if s["op"] == OP.MCP_SERVER]
+            assert len(resource_spans) == 1
+            assert resource_spans[0]["data"][SPANDATA.MCP_RESOURCE_PROTOCOL] == "https"
+    except (AttributeError, TypeError):
+        # Resource handler not supported in this version
+        pytest.skip("Resource handlers not supported in this FastMCP version")
+
+
+# =============================================================================
+# Span Origin and Metadata Tests
+# =============================================================================
+
+
+@pytest.mark.parametrize("FastMCP", fastmcp_implementations, ids=fastmcp_ids)
+def test_fastmcp_span_origin(sentry_init, capture_events, FastMCP):
+    """Test that FastMCP span origin is set correctly"""
+    sentry_init(
+        integrations=[MCPIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    mcp = FastMCP("Test Server")
+
+    # Set up mock request context
+    if request_ctx is not None:
+        mock_ctx = MockRequestContext(request_id="req-origin", transport="stdio")
+        request_ctx.set(mock_ctx)
+
+    @mcp.tool()
+    def test_tool(value: int) -> int:
+        """Test tool for origin checking"""
+        return value * 2
+
+    with start_transaction(name="fastmcp tx"):
+        call_tool_through_mcp(mcp, "test_tool", {"value": 21})
+
+    (tx,) = events
+
+    assert tx["contexts"]["trace"]["origin"] == "manual"
+
+    # Verify MCP span has correct origin
+    mcp_spans = [s for s in tx["spans"] if s["op"] == OP.MCP_SERVER]
+    assert len(mcp_spans) == 1
+    assert mcp_spans[0]["origin"] == "auto.ai.mcp"
+
+
+@pytest.mark.parametrize("FastMCP", fastmcp_implementations, ids=fastmcp_ids)
+def test_fastmcp_without_request_context(sentry_init, capture_events, FastMCP):
+    """Test FastMCP handling when no request context is available"""
+    sentry_init(
+        integrations=[MCPIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    mcp = FastMCP("Test Server")
+
+    # Clear request context
+    if request_ctx is not None:
+        request_ctx.set(None)
+
+    @mcp.tool()
+    def test_tool_no_ctx(x: int) -> dict:
+        """Test tool without context"""
+        return {"result": x + 1}
+
+    with start_transaction(name="fastmcp tx"):
+        result = call_tool_through_mcp(mcp, "test_tool_no_ctx", {"x": 99})
+
+    assert result == {"result": 100}
+
+    # Should still create transaction even if context is missing
+    (tx,) = events
+    assert tx["type"] == "transaction"
+
+
+# =============================================================================
+# Transport Detection Tests
+# =============================================================================
+
+
+@pytest.mark.parametrize("FastMCP", fastmcp_implementations, ids=fastmcp_ids)
+def test_fastmcp_sse_transport(sentry_init, capture_events, FastMCP):
+    """Test that FastMCP correctly detects SSE transport"""
+    sentry_init(
+        integrations=[MCPIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    mcp = FastMCP("Test Server")
+
+    # Set up mock request context with SSE transport
+    if request_ctx is not None:
+        mock_ctx = MockRequestContext(
+            request_id="req-sse", session_id="session-sse-123", transport="sse"
+        )
+        request_ctx.set(mock_ctx)
+
+    @mcp.tool()
+    def sse_tool(value: str) -> dict:
+        """Tool for SSE transport test"""
+        return {"message": f"Received: {value}"}
+
+    with start_transaction(name="fastmcp tx"):
+        result = call_tool_through_mcp(mcp, "sse_tool", {"value": "hello"})
+
+    assert result == {"message": "Received: hello"}
+
+    (tx,) = events
+
+    # Find MCP spans
+    mcp_spans = [s for s in tx["spans"] if s["op"] == OP.MCP_SERVER]
+    assert len(mcp_spans) >= 1
+    span = mcp_spans[0]
+    # Check that SSE transport is detected
+    assert span["data"].get(SPANDATA.MCP_TRANSPORT) == "sse"
+
+
+@pytest.mark.parametrize("FastMCP", fastmcp_implementations, ids=fastmcp_ids)
+def test_fastmcp_http_transport(sentry_init, capture_events, FastMCP):
+    """Test that FastMCP correctly detects HTTP transport"""
+    sentry_init(
+        integrations=[MCPIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    mcp = FastMCP("Test Server")
+
+    # Set up mock request context with HTTP transport
+    if request_ctx is not None:
+        mock_ctx = MockRequestContext(
+            request_id="req-http", session_id="session-http-456", transport="http"
+        )
+        request_ctx.set(mock_ctx)
+
+    @mcp.tool()
+    def http_tool(data: str) -> dict:
+        """Tool for HTTP transport test"""
+        return {"processed": data.upper()}
+
+    with start_transaction(name="fastmcp tx"):
+        result = call_tool_through_mcp(mcp, "http_tool", {"data": "test"})
+
+    assert result == {"processed": "TEST"}
+
+    (tx,) = events
+
+    # Find MCP spans
+    mcp_spans = [s for s in tx["spans"] if s["op"] == OP.MCP_SERVER]
+    assert len(mcp_spans) >= 1
+    span = mcp_spans[0]
+    # Check that HTTP transport is detected
+    assert span["data"].get(SPANDATA.MCP_TRANSPORT) == "http"
+
+
+@pytest.mark.parametrize("FastMCP", fastmcp_implementations, ids=fastmcp_ids)
+def test_fastmcp_stdio_transport(sentry_init, capture_events, FastMCP):
+    """Test that FastMCP correctly detects stdio transport"""
+    sentry_init(
+        integrations=[MCPIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    mcp = FastMCP("Test Server")
+
+    # Set up mock request context with stdio transport
+    if request_ctx is not None:
+        mock_ctx = MockRequestContext(request_id="req-stdio", transport="stdio")
+        request_ctx.set(mock_ctx)
+
+    @mcp.tool()
+    def stdio_tool(n: int) -> dict:
+        """Tool for stdio transport test"""
+        return {"squared": n * n}
+
+    with start_transaction(name="fastmcp tx"):
+        result = call_tool_through_mcp(mcp, "stdio_tool", {"n": 7})
+
+    assert result == {"squared": 49}
+
+    (tx,) = events
+
+    # Find MCP spans
+    mcp_spans = [s for s in tx["spans"] if s["op"] == OP.MCP_SERVER]
+    assert len(mcp_spans) >= 1
+    span = mcp_spans[0]
+    # Check that stdio transport is detected
+    assert span["data"].get(SPANDATA.MCP_TRANSPORT) == "stdio"
+
+
+# =============================================================================
+# Integration-specific Tests
+# =============================================================================
+
+
+@pytest.mark.skipif(not HAS_MCP_FASTMCP, reason="mcp.server.fastmcp not installed")
+def test_mcp_fastmcp_specific_features(sentry_init, capture_events):
+    """Test features specific to mcp.server.fastmcp (from mcp package)"""
+    sentry_init(
+        integrations=[MCPIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    from mcp.server.fastmcp import FastMCP
+
+    mcp = FastMCP("MCP Package Server")
+
+    @mcp.tool()
+    def package_specific_tool(x: int) -> int:
+        """Tool for mcp.server.fastmcp package"""
+        return x + 100
+
+    with start_transaction(name="mcp.server.fastmcp tx"):
+        result = call_tool_through_mcp(mcp, "package_specific_tool", {"x": 50})
+
+    assert result["result"] == 150
+
+    (tx,) = events
+    assert tx["type"] == "transaction"
+
+
+@pytest.mark.skipif(
+    not HAS_STANDALONE_FASTMCP, reason="standalone fastmcp not installed"
+)
+def test_standalone_fastmcp_specific_features(sentry_init, capture_events):
+    """Test features specific to standalone fastmcp package"""
+    sentry_init(
+        integrations=[MCPIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    from fastmcp import FastMCP
+
+    mcp = FastMCP("Standalone FastMCP Server")
+
+    @mcp.tool()
+    def standalone_specific_tool(message: str) -> dict:
+        """Tool for standalone fastmcp package"""
+        return {"echo": message, "length": len(message)}
+
+    with start_transaction(name="standalone fastmcp tx"):
+        result = call_tool_through_mcp(
+            mcp, "standalone_specific_tool", {"message": "Hello FastMCP"}
+        )
+
+    assert result["echo"] == "Hello FastMCP"
+    assert result["length"] == 13
+
+    (tx,) = events
+    assert tx["type"] == "transaction"
+
+
+# =============================================================================
+# Edge Cases and Robustness Tests
+# =============================================================================
+
+
+@pytest.mark.parametrize("FastMCP", fastmcp_implementations, ids=fastmcp_ids)
+def test_fastmcp_tool_with_no_arguments(sentry_init, capture_events, FastMCP):
+    """Test FastMCP tool with no arguments"""
+    sentry_init(
+        integrations=[MCPIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    mcp = FastMCP("Test Server")
+
+    @mcp.tool()
+    def no_args_tool() -> str:
+        """Tool that takes no arguments"""
+        return "success"
+
+    with start_transaction(name="fastmcp tx"):
+        result = call_tool_through_mcp(mcp, "no_args_tool", {})
+
+    assert result["result"] == "success"
+
+    (tx,) = events
+    assert tx["type"] == "transaction"
+
+
+@pytest.mark.parametrize("FastMCP", fastmcp_implementations, ids=fastmcp_ids)
+def test_fastmcp_tool_with_none_return(sentry_init, capture_events, FastMCP):
+    """Test FastMCP tool that returns None"""
+    sentry_init(
+        integrations=[MCPIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    mcp = FastMCP("Test Server")
+
+    @mcp.tool()
+    def none_return_tool(action: str) -> None:
+        """Tool that returns None"""
+        pass
+
+    with start_transaction(name="fastmcp tx"):
+        result = call_tool_through_mcp(mcp, "none_return_tool", {"action": "log"})
+
+    # Helper function normalizes to {"result": value} format
+    assert result["result"] is None
+
+    (tx,) = events
+    assert tx["type"] == "transaction"
+
+
+@pytest.mark.parametrize("FastMCP", fastmcp_implementations, ids=fastmcp_ids)
+@pytest.mark.asyncio
+async def test_fastmcp_mixed_sync_async_tools(sentry_init, capture_events, FastMCP):
+    """Test mixing sync and async tools in FastMCP"""
+    sentry_init(
+        integrations=[MCPIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    mcp = FastMCP("Test Server")
+
+    # Set up mock request context
+    if request_ctx is not None:
+        mock_ctx = MockRequestContext(request_id="req-mixed", transport="stdio")
+        request_ctx.set(mock_ctx)
+
+    @mcp.tool()
+    def sync_add(a: int, b: int) -> int:
+        """Sync addition"""
+        return a + b
+
+    @mcp.tool()
+    async def async_multiply(x: int, y: int) -> int:
+        """Async multiplication"""
+        return x * y
+
+    with start_transaction(name="fastmcp tx"):
+        # Use async version for both since we're in an async context
+        result1 = await call_tool_through_mcp_async(mcp, "sync_add", {"a": 3, "b": 4})
+        result2 = await call_tool_through_mcp_async(
+            mcp, "async_multiply", {"x": 5, "y": 6}
+        )
+
+    assert result1["result"] == 7
+    assert result2["result"] == 30
+
+    (tx,) = events
+    assert tx["type"] == "transaction"
+
+    # Verify both sync and async tool spans were created
+    mcp_spans = [s for s in tx["spans"] if s["op"] == OP.MCP_SERVER]
+    assert len(mcp_spans) == 2
+    assert mcp_spans[0]["data"][SPANDATA.MCP_TOOL_NAME] == "sync_add"
+    assert mcp_spans[1]["data"][SPANDATA.MCP_TOOL_NAME] == "async_multiply"
diff --git a/tests/integrations/flask/test_flask.py b/tests/integrations/flask/test_flask.py
index 09b2c2fb30..e117b98ca9 100644
--- a/tests/integrations/flask/test_flask.py
+++ b/tests/integrations/flask/test_flask.py
@@ -1,10 +1,9 @@
 import json
 import re
-import pytest
 import logging
-
 from io import BytesIO
 
+import pytest
 from flask import (
     Flask,
     Response,
@@ -14,19 +13,22 @@
     render_template_string,
 )
 from flask.views import View
-
 from flask_login import LoginManager, login_user
 
+try:
+    from werkzeug.wrappers.request import UnsupportedMediaType
+except ImportError:
+    UnsupportedMediaType = None
+
+import sentry_sdk
+import sentry_sdk.integrations.flask as flask_sentry
 from sentry_sdk import (
     set_tag,
-    configure_scope,
     capture_message,
     capture_exception,
-    last_event_id,
-    Hub,
 )
+from sentry_sdk.consts import DEFAULT_MAX_VALUE_LENGTH
 from sentry_sdk.integrations.logging import LoggingIntegration
-import sentry_sdk.integrations.flask as flask_sentry
 from sentry_sdk.serializer import MAX_DATABAG_BREADTH
 
 
@@ -46,6 +48,10 @@ def hi():
         capture_message("hi")
         return "ok"
 
+    @app.route("/nomessage")
+    def nohi():
+        return "ok"
+
     @app.route("/message/")
     def hi_with_id(message_id):
         capture_message("hi again")
@@ -123,7 +129,7 @@ def test_errors(
     testing,
     integration_enabled_params,
 ):
-    sentry_init(debug=True, **integration_enabled_params)
+    sentry_init(**integration_enabled_params)
 
     app.debug = debug
     app.testing = testing
@@ -209,7 +215,7 @@ def test_flask_login_configured(
 ):
     sentry_init(send_default_pii=send_default_pii, **integration_enabled_params)
 
-    class User(object):
+    class User:
         is_authenticated = is_active = True
         is_anonymous = user_id is not None
 
@@ -243,9 +249,11 @@ def login():
 
 
 def test_flask_large_json_request(sentry_init, capture_events, app):
-    sentry_init(integrations=[flask_sentry.FlaskIntegration()])
+    sentry_init(
+        integrations=[flask_sentry.FlaskIntegration()], max_request_body_size="always"
+    )
 
-    data = {"foo": {"bar": "a" * 2000}}
+    data = {"foo": {"bar": "a" * (DEFAULT_MAX_VALUE_LENGTH + 10)}}
 
     @app.route("/", methods=["POST"])
     def index():
@@ -263,9 +271,14 @@ def index():
 
     (event,) = events
     assert event["_meta"]["request"]["data"]["foo"]["bar"] == {
-        "": {"len": 2000, "rem": [["!limit", "x", 1021, 1024]]}
+        "": {
+            "len": DEFAULT_MAX_VALUE_LENGTH + 10,
+            "rem": [
+                ["!limit", "x", DEFAULT_MAX_VALUE_LENGTH - 3, DEFAULT_MAX_VALUE_LENGTH]
+            ],
+        }
     }
-    assert len(event["request"]["data"]["foo"]["bar"]) == 1024
+    assert len(event["request"]["data"]["foo"]["bar"]) == DEFAULT_MAX_VALUE_LENGTH
 
 
 def test_flask_session_tracking(sentry_init, capture_envelopes, app):
@@ -276,8 +289,7 @@ def test_flask_session_tracking(sentry_init, capture_envelopes, app):
 
     @app.route("/")
     def index():
-        with configure_scope() as scope:
-            scope.set_user({"ip_address": "1.2.3.4", "id": "42"})
+        sentry_sdk.get_isolation_scope().set_user({"ip_address": "1.2.3.4", "id": "42"})
         try:
             raise ValueError("stuff")
         except Exception:
@@ -292,7 +304,7 @@ def index():
         except ZeroDivisionError:
             pass
 
-    Hub.current.client.flush()
+    sentry_sdk.get_client().flush()
 
     (first_event, error_event, session) = envelopes
     first_event = first_event.get_event()
@@ -332,15 +344,21 @@ def index():
 
 
 def test_flask_medium_formdata_request(sentry_init, capture_events, app):
-    sentry_init(integrations=[flask_sentry.FlaskIntegration()])
+    sentry_init(
+        integrations=[flask_sentry.FlaskIntegration()], max_request_body_size="always"
+    )
 
-    data = {"foo": "a" * 2000}
+    data = {"foo": "a" * (DEFAULT_MAX_VALUE_LENGTH + 10)}
 
     @app.route("/", methods=["POST"])
     def index():
         assert request.form["foo"] == data["foo"]
         assert not request.get_data()
-        assert not request.get_json()
+        try:
+            assert not request.get_json()
+        except UnsupportedMediaType:
+            # flask/werkzeug 3
+            pass
         capture_message("hi")
         return "ok"
 
@@ -352,9 +370,14 @@ def index():
 
     (event,) = events
     assert event["_meta"]["request"]["data"]["foo"] == {
-        "": {"len": 2000, "rem": [["!limit", "x", 1021, 1024]]}
+        "": {
+            "len": DEFAULT_MAX_VALUE_LENGTH + 10,
+            "rem": [
+                ["!limit", "x", DEFAULT_MAX_VALUE_LENGTH - 3, DEFAULT_MAX_VALUE_LENGTH]
+            ],
+        }
     }
-    assert len(event["request"]["data"]["foo"]) == 1024
+    assert len(event["request"]["data"]["foo"]) == DEFAULT_MAX_VALUE_LENGTH
 
 
 def test_flask_formdata_request_appear_transaction_body(
@@ -372,7 +395,11 @@ def index():
         assert request.form["username"] == data["username"]
         assert request.form["age"] == data["age"]
         assert not request.get_data()
-        assert not request.get_json()
+        try:
+            assert not request.get_json()
+        except UnsupportedMediaType:
+            # flask/werkzeug 3
+            pass
         set_tag("view", "yes")
         capture_message("hi")
         return "ok"
@@ -405,7 +432,11 @@ def index():
             assert request.get_data() == data
         else:
             assert request.get_data() == data.encode("ascii")
-        assert not request.get_json()
+        try:
+            assert not request.get_json()
+        except UnsupportedMediaType:
+            # flask/werkzeug 3
+            pass
         capture_message("hi")
         return "ok"
 
@@ -425,13 +456,20 @@ def test_flask_files_and_form(sentry_init, capture_events, app):
         integrations=[flask_sentry.FlaskIntegration()], max_request_body_size="always"
     )
 
-    data = {"foo": "a" * 2000, "file": (BytesIO(b"hello"), "hello.txt")}
+    data = {
+        "foo": "a" * (DEFAULT_MAX_VALUE_LENGTH + 10),
+        "file": (BytesIO(b"hello"), "hello.txt"),
+    }
 
     @app.route("/", methods=["POST"])
     def index():
         assert list(request.form) == ["foo"]
         assert list(request.files) == ["file"]
-        assert not request.get_json()
+        try:
+            assert not request.get_json()
+        except UnsupportedMediaType:
+            # flask/werkzeug 3
+            pass
         capture_message("hi")
         return "ok"
 
@@ -443,9 +481,14 @@ def index():
 
     (event,) = events
     assert event["_meta"]["request"]["data"]["foo"] == {
-        "": {"len": 2000, "rem": [["!limit", "x", 1021, 1024]]}
+        "": {
+            "len": DEFAULT_MAX_VALUE_LENGTH + 10,
+            "rem": [
+                ["!limit", "x", DEFAULT_MAX_VALUE_LENGTH - 3, DEFAULT_MAX_VALUE_LENGTH]
+            ],
+        }
     }
-    assert len(event["request"]["data"]["foo"]) == 1024
+    assert len(event["request"]["data"]["foo"]) == DEFAULT_MAX_VALUE_LENGTH
 
     assert event["_meta"]["request"]["data"]["file"] == {"": {"rem": [["!raw", "x"]]}}
     assert not event["request"]["data"]["file"]
@@ -545,9 +588,12 @@ def test_cli_commands_raise(app):
     def foo():
         1 / 0
 
+    def create_app(*_):
+        return app
+
     with pytest.raises(ZeroDivisionError):
         app.cli.main(
-            args=["foo"], prog_name="myapp", obj=ScriptInfo(create_app=lambda _: app)
+            args=["foo"], prog_name="myapp", obj=ScriptInfo(create_app=create_app)
         )
 
 
@@ -577,7 +623,7 @@ def wsgi_app(environ, start_response):
     assert event["exception"]["values"][0]["mechanism"]["type"] == "wsgi"
 
 
-def test_500(sentry_init, capture_events, app):
+def test_500(sentry_init, app):
     sentry_init(integrations=[flask_sentry.FlaskIntegration()])
 
     app.debug = False
@@ -589,15 +635,12 @@ def index():
 
     @app.errorhandler(500)
     def error_handler(err):
-        return "Sentry error: %s" % last_event_id()
-
-    events = capture_events()
+        return "Sentry error."
 
     client = app.test_client()
     response = client.get("/")
 
-    (event,) = events
-    assert response.data.decode("utf-8") == "Sentry error: %s" % event["event_id"]
+    assert response.data.decode("utf-8") == "Sentry error."
 
 
 def test_error_in_errorhandler(sentry_init, capture_events, app):
@@ -649,18 +692,15 @@ def test_does_not_leak_scope(sentry_init, capture_events, app):
     sentry_init(integrations=[flask_sentry.FlaskIntegration()])
     events = capture_events()
 
-    with configure_scope() as scope:
-        scope.set_tag("request_data", False)
+    sentry_sdk.get_isolation_scope().set_tag("request_data", False)
 
     @app.route("/")
     def index():
-        with configure_scope() as scope:
-            scope.set_tag("request_data", True)
+        sentry_sdk.get_isolation_scope().set_tag("request_data", True)
 
         def generate():
             for row in range(1000):
-                with configure_scope() as scope:
-                    assert scope._tags["request_data"]
+                assert sentry_sdk.get_isolation_scope()._tags["request_data"]
 
                 yield str(row) + "\n"
 
@@ -671,8 +711,7 @@ def generate():
     assert response.data.decode() == "".join(str(row) + "\n" for row in range(1000))
     assert not events
 
-    with configure_scope() as scope:
-        assert not scope._tags["request_data"]
+    assert not sentry_sdk.get_isolation_scope()._tags["request_data"]
 
 
 def test_scoped_test_client(sentry_init, app):
@@ -820,8 +859,7 @@ def test_template_tracing_meta(sentry_init, app, capture_events, template_string
 
     @app.route("/")
     def index():
-        hub = Hub.current
-        capture_message(hub.get_traceparent() + "\n" + hub.get_baggage())
+        capture_message(sentry_sdk.get_traceparent() + "\n" + sentry_sdk.get_baggage())
         return render_template_string(template_string)
 
     with app.test_client() as client:
@@ -840,9 +878,8 @@ def index():
     assert match is not None
     assert match.group(1) == traceparent
 
-    # Python 2 does not preserve sort order
     rendered_baggage = match.group(2)
-    assert sorted(rendered_baggage.split(",")) == sorted(baggage.split(","))
+    assert rendered_baggage == baggage
 
 
 def test_dont_override_sentry_trace_context(sentry_init, app):
@@ -881,37 +918,6 @@ def index():
     assert event["request"]["headers"]["Authorization"] == "[Filtered]"
 
 
-@pytest.mark.parametrize("traces_sample_rate", [None, 1.0])
-def test_replay_event_context(sentry_init, capture_events, app, traces_sample_rate):
-    """
-    Tests that the replay context is added to the event context.
-    This is not strictly a Flask integration test, but it's the easiest way to test this.
-    """
-    sentry_init(traces_sample_rate=traces_sample_rate)
-
-    @app.route("/error")
-    def error():
-        return 1 / 0
-
-    events = capture_events()
-
-    client = app.test_client()
-    headers = {
-        "baggage": "other-vendor-value-1=foo;bar;baz,sentry-trace_id=771a43a4192642f0b136d5159a501700,sentry-public_key=49d0f7386ad645858ae85020e393bef3, sentry-sample_rate=0.01337,sentry-user_id=Am%C3%A9lie,other-vendor-value-2=foo;bar,sentry-replay_id=12312012123120121231201212312012",
-        "sentry-trace": "771a43a4192642f0b136d5159a501700-1234567890abcdef-1",
-    }
-    with pytest.raises(ZeroDivisionError):
-        client.get("/error", headers=headers)
-
-    event = events[0]
-
-    assert event["contexts"]
-    assert event["contexts"]["replay"]
-    assert (
-        event["contexts"]["replay"]["replay_id"] == "12312012123120121231201212312012"
-    )
-
-
 def test_response_status_code_ok_in_transaction_context(
     sentry_init, capture_envelopes, app
 ):
@@ -930,16 +936,16 @@ def test_response_status_code_ok_in_transaction_context(
     client = app.test_client()
     client.get("/message")
 
-    Hub.current.client.flush()
+    sentry_sdk.get_client().flush()
 
     (_, transaction_envelope, _) = envelopes
     transaction = transaction_envelope.get_transaction_event()
 
     assert transaction["type"] == "transaction"
     assert len(transaction["contexts"]) > 0
-    assert (
-        "response" in transaction["contexts"].keys()
-    ), "Response context not found in transaction"
+    assert "response" in transaction["contexts"].keys(), (
+        "Response context not found in transaction"
+    )
     assert transaction["contexts"]["response"]["status_code"] == 200
 
 
@@ -957,14 +963,97 @@ def test_response_status_code_not_found_in_transaction_context(
     client = app.test_client()
     client.get("/not-existing-route")
 
-    Hub.current.client.flush()
+    sentry_sdk.get_client().flush()
 
     (transaction_envelope, _) = envelopes
     transaction = transaction_envelope.get_transaction_event()
 
     assert transaction["type"] == "transaction"
     assert len(transaction["contexts"]) > 0
-    assert (
-        "response" in transaction["contexts"].keys()
-    ), "Response context not found in transaction"
+    assert "response" in transaction["contexts"].keys(), (
+        "Response context not found in transaction"
+    )
     assert transaction["contexts"]["response"]["status_code"] == 404
+
+
+def test_span_origin(sentry_init, app, capture_events):
+    sentry_init(
+        integrations=[flask_sentry.FlaskIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    client = app.test_client()
+    client.get("/message")
+
+    (_, event) = events
+
+    assert event["contexts"]["trace"]["origin"] == "auto.http.flask"
+
+
+def test_transaction_http_method_default(
+    sentry_init,
+    app,
+    capture_events,
+):
+    """
+    By default OPTIONS and HEAD requests do not create a transaction.
+    """
+    sentry_init(
+        traces_sample_rate=1.0,
+        integrations=[flask_sentry.FlaskIntegration()],
+    )
+    events = capture_events()
+
+    client = app.test_client()
+    response = client.get("/nomessage")
+    assert response.status_code == 200
+
+    response = client.options("/nomessage")
+    assert response.status_code == 200
+
+    response = client.head("/nomessage")
+    assert response.status_code == 200
+
+    (event,) = events
+
+    assert len(events) == 1
+    assert event["request"]["method"] == "GET"
+
+
+def test_transaction_http_method_custom(
+    sentry_init,
+    app,
+    capture_events,
+):
+    """
+    Configure FlaskIntegration to ONLY capture OPTIONS and HEAD requests.
+    """
+    sentry_init(
+        traces_sample_rate=1.0,
+        integrations=[
+            flask_sentry.FlaskIntegration(
+                http_methods_to_capture=(
+                    "OPTIONS",
+                    "head",
+                )  # capitalization does not matter
+            )  # case does not matter
+        ],
+    )
+    events = capture_events()
+
+    client = app.test_client()
+    response = client.get("/nomessage")
+    assert response.status_code == 200
+
+    response = client.options("/nomessage")
+    assert response.status_code == 200
+
+    response = client.head("/nomessage")
+    assert response.status_code == 200
+
+    assert len(events) == 2
+
+    (event1, event2) = events
+    assert event1["request"]["method"] == "OPTIONS"
+    assert event2["request"]["method"] == "HEAD"
diff --git a/tests/integrations/gcp/__init__.py b/tests/integrations/gcp/__init__.py
new file mode 100644
index 0000000000..eaf1ba89bb
--- /dev/null
+++ b/tests/integrations/gcp/__init__.py
@@ -0,0 +1,6 @@
+import pytest
+import os
+
+
+if "gcp" not in os.environ.get("TOX_ENV_NAME", ""):
+    pytest.skip("GCP tests only run in GCP environment", allow_module_level=True)
diff --git a/tests/integrations/gcp/test_gcp.py b/tests/integrations/gcp/test_gcp.py
index 678219dc8b..c27c7653aa 100644
--- a/tests/integrations/gcp/test_gcp.py
+++ b/tests/integrations/gcp/test_gcp.py
@@ -2,6 +2,7 @@
 # GCP Cloud Functions unit tests
 
 """
+
 import json
 from textwrap import dedent
 import tempfile
@@ -12,10 +13,6 @@
 import os.path
 import os
 
-pytestmark = pytest.mark.skipif(
-    not hasattr(tempfile, "TemporaryDirectory"), reason="need Python 3.2+"
-)
-
 
 FUNCTIONS_PRELUDE = """
 from unittest.mock import Mock
@@ -62,17 +59,9 @@ def envelope_processor(envelope):
     return item.get_bytes()
 
 class TestTransport(HttpTransport):
-    def _send_event(self, event):
-        event = event_processor(event)
-        # Writing a single string to stdout holds the GIL (seems like) and
-        # therefore cannot be interleaved with other threads. This is why we
-        # explicitly add a newline at the end even though `print` would provide
-        # us one.
-        print("\\nEVENT: {}\\n".format(json.dumps(event)))
-
-    def _send_envelope(self, envelope):
-        envelope = envelope_processor(envelope)
-        print("\\nENVELOPE: {}\\n".format(envelope.decode(\"utf-8\")))
+    def capture_envelope(self, envelope):
+        envelope_item = envelope_processor(envelope)
+        print("\\nENVELOPE: {}\\n".format(envelope_item.decode(\"utf-8\")))
 
 
 def init_sdk(timeout_warning=False, **extra_init_args):
@@ -93,8 +82,7 @@ def init_sdk(timeout_warning=False, **extra_init_args):
 @pytest.fixture
 def run_cloud_function():
     def inner(code, subprocess_kwargs=()):
-        events = []
-        envelopes = []
+        envelope_items = []
         return_value = None
 
         # STEP : Create a zip of cloud function
@@ -113,14 +101,14 @@ def inner(code, subprocess_kwargs=()):
 
             subprocess.check_call(
                 [sys.executable, "setup.py", "sdist", "-d", os.path.join(tmpdir, "..")],
-                **subprocess_kwargs
+                **subprocess_kwargs,
             )
 
             subprocess.check_call(
                 "pip install ../*.tar.gz -t .",
                 cwd=tmpdir,
                 shell=True,
-                **subprocess_kwargs
+                **subprocess_kwargs,
             )
 
             stream = os.popen("python {}/main.py".format(tmpdir))
@@ -130,12 +118,9 @@ def inner(code, subprocess_kwargs=()):
 
             for line in stream_data.splitlines():
                 print("GCP:", line)
-                if line.startswith("EVENT: "):
-                    line = line[len("EVENT: ") :]
-                    events.append(json.loads(line))
-                elif line.startswith("ENVELOPE: "):
+                if line.startswith("ENVELOPE: "):
                     line = line[len("ENVELOPE: ") :]
-                    envelopes.append(json.loads(line))
+                    envelope_items.append(json.loads(line))
                 elif line.startswith("RETURN VALUE: "):
                     line = line[len("RETURN VALUE: ") :]
                     return_value = json.loads(line)
@@ -144,13 +129,13 @@ def inner(code, subprocess_kwargs=()):
 
             stream.close()
 
-        return envelopes, events, return_value
+        return envelope_items, return_value
 
     return inner
 
 
 def test_handled_exception(run_cloud_function):
-    _, events, return_value = run_cloud_function(
+    envelope_items, return_value = run_cloud_function(
         dedent(
             """
         functionhandler = None
@@ -167,8 +152,8 @@ def cloud_function(functionhandler, event):
         """
         )
     )
-    assert events[0]["level"] == "error"
-    (exception,) = events[0]["exception"]["values"]
+    assert envelope_items[0]["level"] == "error"
+    (exception,) = envelope_items[0]["exception"]["values"]
 
     assert exception["type"] == "Exception"
     assert exception["value"] == "something went wrong"
@@ -177,7 +162,7 @@ def cloud_function(functionhandler, event):
 
 
 def test_unhandled_exception(run_cloud_function):
-    _, events, _ = run_cloud_function(
+    envelope_items, _ = run_cloud_function(
         dedent(
             """
         functionhandler = None
@@ -195,8 +180,8 @@ def cloud_function(functionhandler, event):
         """
         )
     )
-    assert events[0]["level"] == "error"
-    (exception,) = events[0]["exception"]["values"]
+    assert envelope_items[0]["level"] == "error"
+    (exception,) = envelope_items[0]["exception"]["values"]
 
     assert exception["type"] == "ZeroDivisionError"
     assert exception["value"] == "division by zero"
@@ -205,12 +190,13 @@ def cloud_function(functionhandler, event):
 
 
 def test_timeout_error(run_cloud_function):
-    _, events, _ = run_cloud_function(
+    envelope_items, _ = run_cloud_function(
         dedent(
             """
         functionhandler = None
         event = {}
         def cloud_function(functionhandler, event):
+            sentry_sdk.set_tag("cloud_function", "true")
             time.sleep(10)
             return "3"
         """
@@ -223,8 +209,8 @@ def cloud_function(functionhandler, event):
         """
         )
     )
-    assert events[0]["level"] == "error"
-    (exception,) = events[0]["exception"]["values"]
+    assert envelope_items[0]["level"] == "error"
+    (exception,) = envelope_items[0]["exception"]["values"]
 
     assert exception["type"] == "ServerlessTimeoutWarning"
     assert (
@@ -234,9 +220,11 @@ def cloud_function(functionhandler, event):
     assert exception["mechanism"]["type"] == "threading"
     assert not exception["mechanism"]["handled"]
 
+    assert envelope_items[0]["tags"]["cloud_function"] == "true"
+
 
 def test_performance_no_error(run_cloud_function):
-    envelopes, _, _ = run_cloud_function(
+    envelope_items, _ = run_cloud_function(
         dedent(
             """
         functionhandler = None
@@ -254,15 +242,15 @@ def cloud_function(functionhandler, event):
         )
     )
 
-    assert envelopes[0]["type"] == "transaction"
-    assert envelopes[0]["contexts"]["trace"]["op"] == "function.gcp"
-    assert envelopes[0]["transaction"].startswith("Google Cloud function")
-    assert envelopes[0]["transaction_info"] == {"source": "component"}
-    assert envelopes[0]["transaction"] in envelopes[0]["request"]["url"]
+    assert envelope_items[0]["type"] == "transaction"
+    assert envelope_items[0]["contexts"]["trace"]["op"] == "function.gcp"
+    assert envelope_items[0]["transaction"].startswith("Google Cloud function")
+    assert envelope_items[0]["transaction_info"] == {"source": "component"}
+    assert envelope_items[0]["transaction"] in envelope_items[0]["request"]["url"]
 
 
 def test_performance_error(run_cloud_function):
-    envelopes, events, _ = run_cloud_function(
+    envelope_items, _ = run_cloud_function(
         dedent(
             """
         functionhandler = None
@@ -280,22 +268,23 @@ def cloud_function(functionhandler, event):
         )
     )
 
-    assert envelopes[0]["level"] == "error"
-    (exception,) = envelopes[0]["exception"]["values"]
+    assert envelope_items[0]["level"] == "error"
+    (exception,) = envelope_items[0]["exception"]["values"]
 
     assert exception["type"] == "Exception"
     assert exception["value"] == "something went wrong"
     assert exception["mechanism"]["type"] == "gcp"
     assert not exception["mechanism"]["handled"]
 
-    assert envelopes[1]["type"] == "transaction"
-    assert envelopes[1]["contexts"]["trace"]["op"] == "function.gcp"
-    assert envelopes[1]["transaction"].startswith("Google Cloud function")
-    assert envelopes[1]["transaction"] in envelopes[0]["request"]["url"]
+    assert envelope_items[1]["type"] == "transaction"
+    assert envelope_items[1]["contexts"]["trace"]["op"] == "function.gcp"
+    assert envelope_items[1]["transaction"].startswith("Google Cloud function")
+    assert envelope_items[1]["transaction"] in envelope_items[0]["request"]["url"]
 
 
 def test_traces_sampler_gets_correct_values_in_sampling_context(
-    run_cloud_function, DictionaryContaining  # noqa:N803
+    run_cloud_function,
+    DictionaryContaining,  # noqa:N803
 ):
     # TODO: There are some decent sized hacks below. For more context, see the
     # long comment in the test of the same name in the AWS integration. The
@@ -304,7 +293,7 @@ def test_traces_sampler_gets_correct_values_in_sampling_context(
 
     import inspect
 
-    envelopes, events, return_value = run_cloud_function(
+    _, return_value = run_cloud_function(
         dedent(
             """
             functionhandler = None
@@ -377,7 +366,7 @@ def test_error_has_new_trace_context_performance_enabled(run_cloud_function):
     """
     Check if an 'trace' context is added to errros and transactions when performance monitoring is enabled.
     """
-    envelopes, _, _ = run_cloud_function(
+    envelope_items, _ = run_cloud_function(
         dedent(
             """
         functionhandler = None
@@ -396,7 +385,7 @@ def cloud_function(functionhandler, event):
         """
         )
     )
-    (msg_event, error_event, transaction_event) = envelopes
+    (msg_event, error_event, transaction_event) = envelope_items
 
     assert "trace" in msg_event["contexts"]
     assert "trace_id" in msg_event["contexts"]["trace"]
@@ -418,7 +407,7 @@ def test_error_has_new_trace_context_performance_disabled(run_cloud_function):
     """
     Check if an 'trace' context is added to errros and transactions when performance monitoring is disabled.
     """
-    _, events, _ = run_cloud_function(
+    envelope_items, _ = run_cloud_function(
         dedent(
             """
         functionhandler = None
@@ -438,7 +427,7 @@ def cloud_function(functionhandler, event):
         )
     )
 
-    (msg_event, error_event) = events
+    (msg_event, error_event) = envelope_items
 
     assert "trace" in msg_event["contexts"]
     assert "trace_id" in msg_event["contexts"]["trace"]
@@ -462,7 +451,7 @@ def test_error_has_existing_trace_context_performance_enabled(run_cloud_function
     parent_sampled = 1
     sentry_trace_header = "{}-{}-{}".format(trace_id, parent_span_id, parent_sampled)
 
-    envelopes, _, _ = run_cloud_function(
+    envelope_items, _ = run_cloud_function(
         dedent(
             """
         functionhandler = None
@@ -486,7 +475,7 @@ def cloud_function(functionhandler, event):
         """
         )
     )
-    (msg_event, error_event, transaction_event) = envelopes
+    (msg_event, error_event, transaction_event) = envelope_items
 
     assert "trace" in msg_event["contexts"]
     assert "trace_id" in msg_event["contexts"]["trace"]
@@ -515,7 +504,7 @@ def test_error_has_existing_trace_context_performance_disabled(run_cloud_functio
     parent_sampled = 1
     sentry_trace_header = "{}-{}-{}".format(trace_id, parent_span_id, parent_sampled)
 
-    _, events, _ = run_cloud_function(
+    envelope_items, _ = run_cloud_function(
         dedent(
             """
         functionhandler = None
@@ -539,7 +528,7 @@ def cloud_function(functionhandler, event):
         """
         )
     )
-    (msg_event, error_event) = events
+    (msg_event, error_event) = envelope_items
 
     assert "trace" in msg_event["contexts"]
     assert "trace_id" in msg_event["contexts"]["trace"]
@@ -552,3 +541,27 @@ def cloud_function(functionhandler, event):
         == error_event["contexts"]["trace"]["trace_id"]
         == "471a43a4192642f0b136d5159a501701"
     )
+
+
+def test_span_origin(run_cloud_function):
+    events, _ = run_cloud_function(
+        dedent(
+            """
+        functionhandler = None
+        event = {}
+        def cloud_function(functionhandler, event):
+            return "test_string"
+        """
+        )
+        + FUNCTIONS_PRELUDE
+        + dedent(
+            """
+        init_sdk(traces_sample_rate=1.0)
+        gcp_functions.worker_v1.FunctionHandler.invoke_user_function(functionhandler, event)
+        """
+        )
+    )
+
+    (event,) = events
+
+    assert event["contexts"]["trace"]["origin"] == "auto.function.gcp"
diff --git a/tests/integrations/google_genai/__init__.py b/tests/integrations/google_genai/__init__.py
new file mode 100644
index 0000000000..5143bf4536
--- /dev/null
+++ b/tests/integrations/google_genai/__init__.py
@@ -0,0 +1,4 @@
+import pytest
+
+pytest.importorskip("google")
+pytest.importorskip("google.genai")
diff --git a/tests/integrations/google_genai/test_google_genai.py b/tests/integrations/google_genai/test_google_genai.py
new file mode 100644
index 0000000000..a49822f3d4
--- /dev/null
+++ b/tests/integrations/google_genai/test_google_genai.py
@@ -0,0 +1,1419 @@
+import json
+import pytest
+from unittest import mock
+
+from google import genai
+from google.genai import types as genai_types
+
+from sentry_sdk import start_transaction
+from sentry_sdk.consts import OP, SPANDATA
+from sentry_sdk.integrations.google_genai import GoogleGenAIIntegration
+
+
+@pytest.fixture
+def mock_genai_client():
+    """Fixture that creates a real genai.Client with mocked HTTP responses."""
+    client = genai.Client(api_key="test-api-key")
+    return client
+
+
+def create_mock_http_response(response_body):
+    """
+    Create a mock HTTP response that the API client's request() method would return.
+
+    Args:
+        response_body: The JSON body as a string or dict
+
+    Returns:
+        An HttpResponse object with headers and body
+    """
+    if isinstance(response_body, dict):
+        response_body = json.dumps(response_body)
+
+    return genai_types.HttpResponse(
+        headers={
+            "content-type": "application/json; charset=UTF-8",
+        },
+        body=response_body,
+    )
+
+
+def create_mock_streaming_responses(response_chunks):
+    """
+    Create a generator that yields mock HTTP responses for streaming.
+
+    Args:
+        response_chunks: List of dicts, each representing a chunk's JSON body
+
+    Returns:
+        A generator that yields HttpResponse objects
+    """
+    for chunk in response_chunks:
+        yield create_mock_http_response(chunk)
+
+
+# Sample API response JSON (based on real API format from user)
+EXAMPLE_API_RESPONSE_JSON = {
+    "candidates": [
+        {
+            "content": {
+                "role": "model",
+                "parts": [{"text": "Hello! How can I help you today?"}],
+            },
+            "finishReason": "STOP",
+        }
+    ],
+    "usageMetadata": {
+        "promptTokenCount": 10,
+        "candidatesTokenCount": 20,
+        "totalTokenCount": 30,
+        "cachedContentTokenCount": 5,
+        "thoughtsTokenCount": 3,
+    },
+    "modelVersion": "gemini-1.5-flash",
+    "responseId": "response-id-123",
+}
+
+
+def create_test_config(
+    temperature=None,
+    top_p=None,
+    top_k=None,
+    max_output_tokens=None,
+    presence_penalty=None,
+    frequency_penalty=None,
+    seed=None,
+    system_instruction=None,
+    tools=None,
+):
+    """Create a GenerateContentConfig."""
+    config_dict = {}
+
+    if temperature is not None:
+        config_dict["temperature"] = temperature
+    if top_p is not None:
+        config_dict["top_p"] = top_p
+    if top_k is not None:
+        config_dict["top_k"] = top_k
+    if max_output_tokens is not None:
+        config_dict["max_output_tokens"] = max_output_tokens
+    if presence_penalty is not None:
+        config_dict["presence_penalty"] = presence_penalty
+    if frequency_penalty is not None:
+        config_dict["frequency_penalty"] = frequency_penalty
+    if seed is not None:
+        config_dict["seed"] = seed
+    if system_instruction is not None:
+        # Convert string to Content for system instruction
+        if isinstance(system_instruction, str):
+            system_instruction = genai_types.Content(
+                parts=[genai_types.Part(text=system_instruction)], role="system"
+            )
+        config_dict["system_instruction"] = system_instruction
+    if tools is not None:
+        config_dict["tools"] = tools
+
+    return genai_types.GenerateContentConfig(**config_dict)
+
+
+@pytest.mark.parametrize(
+    "send_default_pii, include_prompts",
+    [
+        (True, True),
+        (True, False),
+        (False, True),
+        (False, False),
+    ],
+)
+def test_nonstreaming_generate_content(
+    sentry_init, capture_events, send_default_pii, include_prompts, mock_genai_client
+):
+    sentry_init(
+        integrations=[GoogleGenAIIntegration(include_prompts=include_prompts)],
+        traces_sample_rate=1.0,
+        send_default_pii=send_default_pii,
+    )
+    events = capture_events()
+
+    # Mock the HTTP response at the _api_client.request() level
+    mock_http_response = create_mock_http_response(EXAMPLE_API_RESPONSE_JSON)
+
+    with mock.patch.object(
+        mock_genai_client._api_client,
+        "request",
+        return_value=mock_http_response,
+    ):
+        with start_transaction(name="google_genai"):
+            config = create_test_config(temperature=0.7, max_output_tokens=100)
+            mock_genai_client.models.generate_content(
+                model="gemini-1.5-flash", contents="Tell me a joke", config=config
+            )
+    assert len(events) == 1
+    (event,) = events
+
+    assert event["type"] == "transaction"
+    assert event["transaction"] == "google_genai"
+
+    # Should have 2 spans: invoke_agent and chat
+    assert len(event["spans"]) == 2
+    invoke_span, chat_span = event["spans"]
+
+    # Check invoke_agent span
+    assert invoke_span["op"] == OP.GEN_AI_INVOKE_AGENT
+    assert invoke_span["description"] == "invoke_agent"
+    assert invoke_span["data"][SPANDATA.GEN_AI_AGENT_NAME] == "gemini-1.5-flash"
+    assert invoke_span["data"][SPANDATA.GEN_AI_SYSTEM] == "gcp.gemini"
+    assert invoke_span["data"][SPANDATA.GEN_AI_REQUEST_MODEL] == "gemini-1.5-flash"
+    assert invoke_span["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "invoke_agent"
+
+    # Check chat span
+    assert chat_span["op"] == OP.GEN_AI_CHAT
+    assert chat_span["description"] == "chat gemini-1.5-flash"
+    assert chat_span["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "chat"
+    assert chat_span["data"][SPANDATA.GEN_AI_SYSTEM] == "gcp.gemini"
+    assert chat_span["data"][SPANDATA.GEN_AI_REQUEST_MODEL] == "gemini-1.5-flash"
+
+    if send_default_pii and include_prompts:
+        # Messages are serialized as JSON strings
+        messages = json.loads(invoke_span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES])
+        assert messages == [{"role": "user", "content": "Tell me a joke"}]
+
+        # Response text is stored as a JSON array
+        response_text = chat_span["data"][SPANDATA.GEN_AI_RESPONSE_TEXT]
+        # Parse the JSON array
+        response_texts = json.loads(response_text)
+        assert response_texts == ["Hello! How can I help you today?"]
+    else:
+        assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in invoke_span["data"]
+        assert SPANDATA.GEN_AI_RESPONSE_TEXT not in chat_span["data"]
+
+    # Check token usage
+    assert chat_span["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10
+    # Output tokens now include reasoning tokens: candidates_token_count (20) + thoughts_token_count (3) = 23
+    assert chat_span["data"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 23
+    assert chat_span["data"][SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS] == 30
+    assert chat_span["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS_CACHED] == 5
+    assert chat_span["data"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS_REASONING] == 3
+
+    # Check configuration parameters
+    assert invoke_span["data"][SPANDATA.GEN_AI_REQUEST_TEMPERATURE] == 0.7
+    assert invoke_span["data"][SPANDATA.GEN_AI_REQUEST_MAX_TOKENS] == 100
+
+
+def test_generate_content_with_system_instruction(
+    sentry_init, capture_events, mock_genai_client
+):
+    sentry_init(
+        integrations=[GoogleGenAIIntegration(include_prompts=True)],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+    events = capture_events()
+
+    mock_http_response = create_mock_http_response(EXAMPLE_API_RESPONSE_JSON)
+
+    with mock.patch.object(
+        mock_genai_client._api_client, "request", return_value=mock_http_response
+    ):
+        with start_transaction(name="google_genai"):
+            config = create_test_config(
+                system_instruction="You are a helpful assistant",
+                temperature=0.5,
+            )
+            mock_genai_client.models.generate_content(
+                model="gemini-1.5-flash", contents="What is 2+2?", config=config
+            )
+
+    (event,) = events
+    invoke_span = event["spans"][0]
+
+    # Check that system instruction is included in messages
+    # (PII is enabled and include_prompts is True in this test)
+    messages_str = invoke_span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES]
+    # Parse the JSON string to verify content
+    messages = json.loads(messages_str)
+    assert len(messages) == 2
+    assert messages[0] == {"role": "system", "content": "You are a helpful assistant"}
+    assert messages[1] == {"role": "user", "content": "What is 2+2?"}
+
+
+def test_generate_content_with_tools(sentry_init, capture_events, mock_genai_client):
+    sentry_init(
+        integrations=[GoogleGenAIIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    # Create a mock tool function
+    def get_weather(location: str) -> str:
+        """Get the weather for a location"""
+        return f"The weather in {location} is sunny"
+
+    # Create a tool with function declarations using real types
+    function_declaration = genai_types.FunctionDeclaration(
+        name="get_weather_tool",
+        description="Get weather information (tool object)",
+        parameters=genai_types.Schema(
+            type=genai_types.Type.OBJECT,
+            properties={
+                "location": genai_types.Schema(
+                    type=genai_types.Type.STRING,
+                    description="The location to get weather for",
+                )
+            },
+            required=["location"],
+        ),
+    )
+
+    mock_tool = genai_types.Tool(function_declarations=[function_declaration])
+
+    # API response for tool usage
+    tool_response_json = {
+        "candidates": [
+            {
+                "content": {
+                    "role": "model",
+                    "parts": [{"text": "I'll check the weather."}],
+                },
+                "finishReason": "STOP",
+            }
+        ],
+        "usageMetadata": {
+            "promptTokenCount": 15,
+            "candidatesTokenCount": 10,
+            "totalTokenCount": 25,
+        },
+    }
+
+    mock_http_response = create_mock_http_response(tool_response_json)
+
+    with mock.patch.object(
+        mock_genai_client._api_client, "request", return_value=mock_http_response
+    ):
+        with start_transaction(name="google_genai"):
+            config = create_test_config(tools=[get_weather, mock_tool])
+            mock_genai_client.models.generate_content(
+                model="gemini-1.5-flash", contents="What's the weather?", config=config
+            )
+
+    (event,) = events
+    invoke_span = event["spans"][0]
+
+    # Check that tools are recorded (data is serialized as a string)
+    tools_data_str = invoke_span["data"][SPANDATA.GEN_AI_REQUEST_AVAILABLE_TOOLS]
+    # Parse the JSON string to verify content
+    tools_data = json.loads(tools_data_str)
+    assert len(tools_data) == 2
+
+    # The order of tools may not be guaranteed, so sort by name and description for comparison
+    sorted_tools = sorted(
+        tools_data, key=lambda t: (t.get("name", ""), t.get("description", ""))
+    )
+
+    # The function tool
+    assert sorted_tools[0]["name"] == "get_weather"
+    assert sorted_tools[0]["description"] == "Get the weather for a location"
+
+    # The FunctionDeclaration tool
+    assert sorted_tools[1]["name"] == "get_weather_tool"
+    assert sorted_tools[1]["description"] == "Get weather information (tool object)"
+
+
+def test_tool_execution(sentry_init, capture_events):
+    sentry_init(
+        integrations=[GoogleGenAIIntegration(include_prompts=True)],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+    events = capture_events()
+
+    # Create a mock tool function
+    def get_weather(location: str) -> str:
+        """Get the weather for a location"""
+        return f"The weather in {location} is sunny"
+
+    # Create wrapped version of the tool
+    from sentry_sdk.integrations.google_genai.utils import wrapped_tool
+
+    wrapped_weather = wrapped_tool(get_weather)
+
+    # Execute the wrapped tool
+    with start_transaction(name="test_tool"):
+        result = wrapped_weather("San Francisco")
+
+    assert result == "The weather in San Francisco is sunny"
+
+    (event,) = events
+    assert len(event["spans"]) == 1
+    tool_span = event["spans"][0]
+
+    assert tool_span["op"] == OP.GEN_AI_EXECUTE_TOOL
+    assert tool_span["description"] == "execute_tool get_weather"
+    assert tool_span["data"][SPANDATA.GEN_AI_TOOL_NAME] == "get_weather"
+    assert tool_span["data"][SPANDATA.GEN_AI_TOOL_TYPE] == "function"
+    assert (
+        tool_span["data"][SPANDATA.GEN_AI_TOOL_DESCRIPTION]
+        == "Get the weather for a location"
+    )
+
+
+def test_error_handling(sentry_init, capture_events, mock_genai_client):
+    sentry_init(
+        integrations=[GoogleGenAIIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    # Mock an error at the HTTP level
+    with mock.patch.object(
+        mock_genai_client._api_client, "request", side_effect=Exception("API Error")
+    ):
+        with start_transaction(name="google_genai"):
+            with pytest.raises(Exception, match="API Error"):
+                mock_genai_client.models.generate_content(
+                    model="gemini-1.5-flash",
+                    contents="This will fail",
+                    config=create_test_config(),
+                )
+
+    # Should have both transaction and error events
+    assert len(events) == 2
+    error_event, transaction_event = events
+
+    assert error_event["level"] == "error"
+    assert error_event["exception"]["values"][0]["type"] == "Exception"
+    assert error_event["exception"]["values"][0]["value"] == "API Error"
+    assert error_event["exception"]["values"][0]["mechanism"]["type"] == "google_genai"
+
+
+def test_streaming_generate_content(sentry_init, capture_events, mock_genai_client):
+    """Test streaming with generate_content_stream, verifying chunk accumulation."""
+    sentry_init(
+        integrations=[GoogleGenAIIntegration(include_prompts=True)],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+    events = capture_events()
+
+    # Create streaming chunks - simulating a multi-chunk response
+    # Chunk 1: First part of text with partial usage metadata
+    chunk1_json = {
+        "candidates": [
+            {
+                "content": {
+                    "role": "model",
+                    "parts": [{"text": "Hello! "}],
+                },
+                # No finishReason in intermediate chunks
+            }
+        ],
+        "usageMetadata": {
+            "promptTokenCount": 10,
+            "candidatesTokenCount": 2,
+            "totalTokenCount": 12,  # Not set in intermediate chunks
+        },
+        "responseId": "response-id-stream-123",
+        "modelVersion": "gemini-1.5-flash",
+    }
+
+    # Chunk 2: Second part of text with more usage metadata
+    chunk2_json = {
+        "candidates": [
+            {
+                "content": {
+                    "role": "model",
+                    "parts": [{"text": "How can I "}],
+                },
+            }
+        ],
+        "usageMetadata": {
+            "promptTokenCount": 10,
+            "candidatesTokenCount": 3,
+            "totalTokenCount": 13,
+        },
+    }
+
+    # Chunk 3: Final part with finish reason and complete usage metadata
+    chunk3_json = {
+        "candidates": [
+            {
+                "content": {
+                    "role": "model",
+                    "parts": [{"text": "help you today?"}],
+                },
+                "finishReason": "STOP",
+            }
+        ],
+        "usageMetadata": {
+            "promptTokenCount": 10,
+            "candidatesTokenCount": 7,
+            "totalTokenCount": 25,
+            "cachedContentTokenCount": 5,
+            "thoughtsTokenCount": 3,
+        },
+    }
+
+    # Create streaming mock responses
+    stream_chunks = [chunk1_json, chunk2_json, chunk3_json]
+    mock_stream = create_mock_streaming_responses(stream_chunks)
+
+    with mock.patch.object(
+        mock_genai_client._api_client, "request_streamed", return_value=mock_stream
+    ):
+        with start_transaction(name="google_genai"):
+            config = create_test_config()
+            stream = mock_genai_client.models.generate_content_stream(
+                model="gemini-1.5-flash", contents="Stream me a response", config=config
+            )
+
+            # Consume the stream (this is what users do with the integration wrapper)
+            collected_chunks = list(stream)
+
+    # Verify we got all chunks
+    assert len(collected_chunks) == 3
+    assert collected_chunks[0].candidates[0].content.parts[0].text == "Hello! "
+    assert collected_chunks[1].candidates[0].content.parts[0].text == "How can I "
+    assert collected_chunks[2].candidates[0].content.parts[0].text == "help you today?"
+
+    (event,) = events
+
+    # There should be 2 spans: invoke_agent and chat
+    assert len(event["spans"]) == 2
+    invoke_span = event["spans"][0]
+    chat_span = event["spans"][1]
+
+    # Check that streaming flag is set on both spans
+    assert invoke_span["data"][SPANDATA.GEN_AI_RESPONSE_STREAMING] is True
+    assert chat_span["data"][SPANDATA.GEN_AI_RESPONSE_STREAMING] is True
+
+    # Verify accumulated response text (all chunks combined)
+    expected_full_text = "Hello! How can I help you today?"
+    # Response text is stored as a JSON string
+    chat_response_text = json.loads(chat_span["data"][SPANDATA.GEN_AI_RESPONSE_TEXT])
+    invoke_response_text = json.loads(
+        invoke_span["data"][SPANDATA.GEN_AI_RESPONSE_TEXT]
+    )
+    assert chat_response_text == [expected_full_text]
+    assert invoke_response_text == [expected_full_text]
+
+    # Verify finish reasons (only the final chunk has a finish reason)
+    # When there's a single finish reason, it's stored as a plain string (not JSON)
+    assert SPANDATA.GEN_AI_RESPONSE_FINISH_REASONS in chat_span["data"]
+    assert SPANDATA.GEN_AI_RESPONSE_FINISH_REASONS in invoke_span["data"]
+    assert chat_span["data"][SPANDATA.GEN_AI_RESPONSE_FINISH_REASONS] == "STOP"
+    assert invoke_span["data"][SPANDATA.GEN_AI_RESPONSE_FINISH_REASONS] == "STOP"
+
+    # Verify token counts - should reflect accumulated values
+    # Input tokens: max of all chunks = 10
+    assert chat_span["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 30
+    assert invoke_span["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 30
+
+    # Output tokens: candidates (2 + 3 + 7 = 12) + reasoning (3) = 15
+    # Note: output_tokens includes both candidates and reasoning tokens
+    assert chat_span["data"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 15
+    assert invoke_span["data"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 15
+
+    # Total tokens: from the last chunk
+    assert chat_span["data"][SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS] == 50
+    assert invoke_span["data"][SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS] == 50
+
+    # Cached tokens: max of all chunks = 5
+    assert chat_span["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS_CACHED] == 5
+    assert invoke_span["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS_CACHED] == 5
+
+    # Reasoning tokens: sum of thoughts_token_count = 3
+    assert chat_span["data"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS_REASONING] == 3
+    assert invoke_span["data"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS_REASONING] == 3
+
+    # Verify model name
+    assert chat_span["data"][SPANDATA.GEN_AI_REQUEST_MODEL] == "gemini-1.5-flash"
+    assert invoke_span["data"][SPANDATA.GEN_AI_AGENT_NAME] == "gemini-1.5-flash"
+
+
+def test_span_origin(sentry_init, capture_events, mock_genai_client):
+    sentry_init(
+        integrations=[GoogleGenAIIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    mock_http_response = create_mock_http_response(EXAMPLE_API_RESPONSE_JSON)
+
+    with mock.patch.object(
+        mock_genai_client._api_client, "request", return_value=mock_http_response
+    ):
+        with start_transaction(name="google_genai"):
+            config = create_test_config()
+            mock_genai_client.models.generate_content(
+                model="gemini-1.5-flash", contents="Test origin", config=config
+            )
+
+    (event,) = events
+
+    assert event["contexts"]["trace"]["origin"] == "manual"
+    for span in event["spans"]:
+        assert span["origin"] == "auto.ai.google_genai"
+
+
+def test_response_without_usage_metadata(
+    sentry_init, capture_events, mock_genai_client
+):
+    """Test handling of responses without usage metadata"""
+    sentry_init(
+        integrations=[GoogleGenAIIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    # Response without usage metadata
+    response_json = {
+        "candidates": [
+            {
+                "content": {
+                    "role": "model",
+                    "parts": [{"text": "No usage data"}],
+                },
+                "finishReason": "STOP",
+            }
+        ],
+    }
+
+    mock_http_response = create_mock_http_response(response_json)
+
+    with mock.patch.object(
+        mock_genai_client._api_client, "request", return_value=mock_http_response
+    ):
+        with start_transaction(name="google_genai"):
+            config = create_test_config()
+            mock_genai_client.models.generate_content(
+                model="gemini-1.5-flash", contents="Test", config=config
+            )
+
+    (event,) = events
+    chat_span = event["spans"][1]
+
+    # Usage data should not be present
+    assert SPANDATA.GEN_AI_USAGE_INPUT_TOKENS not in chat_span["data"]
+    assert SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS not in chat_span["data"]
+    assert SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS not in chat_span["data"]
+
+
+def test_multiple_candidates(sentry_init, capture_events, mock_genai_client):
+    """Test handling of multiple response candidates"""
+    sentry_init(
+        integrations=[GoogleGenAIIntegration(include_prompts=True)],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+    events = capture_events()
+
+    # Response with multiple candidates
+    multi_candidate_json = {
+        "candidates": [
+            {
+                "content": {
+                    "role": "model",
+                    "parts": [{"text": "Response 1"}],
+                },
+                "finishReason": "STOP",
+            },
+            {
+                "content": {
+                    "role": "model",
+                    "parts": [{"text": "Response 2"}],
+                },
+                "finishReason": "MAX_TOKENS",
+            },
+        ],
+        "usageMetadata": {
+            "promptTokenCount": 5,
+            "candidatesTokenCount": 15,
+            "totalTokenCount": 20,
+        },
+    }
+
+    mock_http_response = create_mock_http_response(multi_candidate_json)
+
+    with mock.patch.object(
+        mock_genai_client._api_client, "request", return_value=mock_http_response
+    ):
+        with start_transaction(name="google_genai"):
+            config = create_test_config()
+            mock_genai_client.models.generate_content(
+                model="gemini-1.5-flash", contents="Generate multiple", config=config
+            )
+
+    (event,) = events
+    chat_span = event["spans"][1]
+
+    # Should capture all responses
+    # Response text is stored as a JSON string when there are multiple responses
+    response_text = chat_span["data"][SPANDATA.GEN_AI_RESPONSE_TEXT]
+    if isinstance(response_text, str) and response_text.startswith("["):
+        # It's a JSON array
+        response_list = json.loads(response_text)
+        assert response_list == ["Response 1", "Response 2"]
+    else:
+        # It's concatenated
+        assert response_text == "Response 1\nResponse 2"
+
+    # Finish reasons are serialized as JSON
+    finish_reasons = json.loads(
+        chat_span["data"][SPANDATA.GEN_AI_RESPONSE_FINISH_REASONS]
+    )
+    assert finish_reasons == ["STOP", "MAX_TOKENS"]
+
+
+def test_all_configuration_parameters(sentry_init, capture_events, mock_genai_client):
+    """Test that all configuration parameters are properly recorded"""
+    sentry_init(
+        integrations=[GoogleGenAIIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    mock_http_response = create_mock_http_response(EXAMPLE_API_RESPONSE_JSON)
+
+    with mock.patch.object(
+        mock_genai_client._api_client, "request", return_value=mock_http_response
+    ):
+        with start_transaction(name="google_genai"):
+            config = create_test_config(
+                temperature=0.8,
+                top_p=0.95,
+                top_k=40,
+                max_output_tokens=2048,
+                presence_penalty=0.1,
+                frequency_penalty=0.2,
+                seed=12345,
+            )
+            mock_genai_client.models.generate_content(
+                model="gemini-1.5-flash", contents="Test all params", config=config
+            )
+
+    (event,) = events
+    invoke_span = event["spans"][0]
+
+    # Check all parameters are recorded
+    assert invoke_span["data"][SPANDATA.GEN_AI_REQUEST_TEMPERATURE] == 0.8
+    assert invoke_span["data"][SPANDATA.GEN_AI_REQUEST_TOP_P] == 0.95
+    assert invoke_span["data"][SPANDATA.GEN_AI_REQUEST_TOP_K] == 40
+    assert invoke_span["data"][SPANDATA.GEN_AI_REQUEST_MAX_TOKENS] == 2048
+    assert invoke_span["data"][SPANDATA.GEN_AI_REQUEST_PRESENCE_PENALTY] == 0.1
+    assert invoke_span["data"][SPANDATA.GEN_AI_REQUEST_FREQUENCY_PENALTY] == 0.2
+    assert invoke_span["data"][SPANDATA.GEN_AI_REQUEST_SEED] == 12345
+
+
+def test_empty_response(sentry_init, capture_events, mock_genai_client):
+    """Test handling of minimal response with no content"""
+    sentry_init(
+        integrations=[GoogleGenAIIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    # Minimal response with empty candidates array
+    minimal_response_json = {"candidates": []}
+    mock_http_response = create_mock_http_response(minimal_response_json)
+
+    with mock.patch.object(
+        mock_genai_client._api_client, "request", return_value=mock_http_response
+    ):
+        with start_transaction(name="google_genai"):
+            response = mock_genai_client.models.generate_content(
+                model="gemini-1.5-flash", contents="Test", config=create_test_config()
+            )
+
+    # Response will have an empty candidates list
+    assert response is not None
+    assert len(response.candidates) == 0
+
+    (event,) = events
+    # Should still create spans even with empty candidates
+    assert len(event["spans"]) == 2
+
+
+def test_response_with_different_id_fields(
+    sentry_init, capture_events, mock_genai_client
+):
+    """Test handling of different response ID field names"""
+    sentry_init(
+        integrations=[GoogleGenAIIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    # Response with response_id and model_version
+    response_json = {
+        "candidates": [
+            {
+                "content": {
+                    "role": "model",
+                    "parts": [{"text": "Test"}],
+                },
+                "finishReason": "STOP",
+            }
+        ],
+        "responseId": "resp-456",
+        "modelVersion": "gemini-1.5-flash-001",
+    }
+
+    mock_http_response = create_mock_http_response(response_json)
+
+    with mock.patch.object(
+        mock_genai_client._api_client, "request", return_value=mock_http_response
+    ):
+        with start_transaction(name="google_genai"):
+            mock_genai_client.models.generate_content(
+                model="gemini-1.5-flash", contents="Test", config=create_test_config()
+            )
+
+    (event,) = events
+    chat_span = event["spans"][1]
+
+    assert chat_span["data"][SPANDATA.GEN_AI_RESPONSE_ID] == "resp-456"
+    assert chat_span["data"][SPANDATA.GEN_AI_RESPONSE_MODEL] == "gemini-1.5-flash-001"
+
+
+def test_tool_with_async_function(sentry_init, capture_events):
+    """Test that async tool functions are properly wrapped"""
+    sentry_init(
+        integrations=[GoogleGenAIIntegration()],
+        traces_sample_rate=1.0,
+    )
+    capture_events()
+
+    # Create an async tool function
+    async def async_tool(param: str) -> str:
+        """An async tool"""
+        return f"Async result: {param}"
+
+    # Import is skipped in sync tests, but we can test the wrapping logic
+    from sentry_sdk.integrations.google_genai.utils import wrapped_tool
+
+    # The wrapper should handle async functions
+    wrapped_async_tool = wrapped_tool(async_tool)
+    assert wrapped_async_tool != async_tool  # Should be wrapped
+    assert hasattr(wrapped_async_tool, "__wrapped__")  # Should preserve original
+
+
+def test_contents_as_none(sentry_init, capture_events, mock_genai_client):
+    """Test handling when contents parameter is None"""
+    sentry_init(
+        integrations=[GoogleGenAIIntegration(include_prompts=True)],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+    events = capture_events()
+
+    mock_http_response = create_mock_http_response(EXAMPLE_API_RESPONSE_JSON)
+
+    with mock.patch.object(
+        mock_genai_client._api_client, "request", return_value=mock_http_response
+    ):
+        with start_transaction(name="google_genai"):
+            mock_genai_client.models.generate_content(
+                model="gemini-1.5-flash", contents=None, config=create_test_config()
+            )
+
+    (event,) = events
+    invoke_span = event["spans"][0]
+
+    # Should handle None contents gracefully
+    messages = invoke_span["data"].get(SPANDATA.GEN_AI_REQUEST_MESSAGES, [])
+    # Should only have system message if any, not user message
+    assert all(msg["role"] != "user" or msg["content"] is not None for msg in messages)
+
+
+def test_tool_calls_extraction(sentry_init, capture_events, mock_genai_client):
+    """Test extraction of tool/function calls from response"""
+    sentry_init(
+        integrations=[GoogleGenAIIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    # Response with function calls
+    function_call_response_json = {
+        "candidates": [
+            {
+                "content": {
+                    "role": "model",
+                    "parts": [
+                        {"text": "I'll help you with that."},
+                        {
+                            "functionCall": {
+                                "name": "get_weather",
+                                "args": {
+                                    "location": "San Francisco",
+                                    "unit": "celsius",
+                                },
+                            }
+                        },
+                        {
+                            "functionCall": {
+                                "name": "get_time",
+                                "args": {"timezone": "PST"},
+                            }
+                        },
+                    ],
+                },
+                "finishReason": "STOP",
+            }
+        ],
+        "usageMetadata": {
+            "promptTokenCount": 20,
+            "candidatesTokenCount": 30,
+            "totalTokenCount": 50,
+        },
+    }
+
+    mock_http_response = create_mock_http_response(function_call_response_json)
+
+    with mock.patch.object(
+        mock_genai_client._api_client, "request", return_value=mock_http_response
+    ):
+        with start_transaction(name="google_genai"):
+            mock_genai_client.models.generate_content(
+                model="gemini-1.5-flash",
+                contents="What's the weather and time?",
+                config=create_test_config(),
+            )
+
+    (event,) = events
+    chat_span = event["spans"][1]  # The chat span
+
+    # Check that tool calls are extracted and stored
+    assert SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS in chat_span["data"]
+
+    # Parse the JSON string to verify content
+    tool_calls = json.loads(chat_span["data"][SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS])
+
+    assert len(tool_calls) == 2
+
+    # First tool call
+    assert tool_calls[0]["name"] == "get_weather"
+    assert tool_calls[0]["type"] == "function_call"
+    # Arguments are serialized as JSON strings
+    assert json.loads(tool_calls[0]["arguments"]) == {
+        "location": "San Francisco",
+        "unit": "celsius",
+    }
+
+    # Second tool call
+    assert tool_calls[1]["name"] == "get_time"
+    assert tool_calls[1]["type"] == "function_call"
+    # Arguments are serialized as JSON strings
+    assert json.loads(tool_calls[1]["arguments"]) == {"timezone": "PST"}
+
+
+def test_google_genai_message_truncation(
+    sentry_init, capture_events, mock_genai_client
+):
+    """Test that large messages are truncated properly in Google GenAI integration."""
+    sentry_init(
+        integrations=[GoogleGenAIIntegration(include_prompts=True)],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+    events = capture_events()
+
+    large_content = (
+        "This is a very long message that will exceed our size limits. " * 1000
+    )
+    small_content = "This is a small user message"
+
+    mock_http_response = create_mock_http_response(EXAMPLE_API_RESPONSE_JSON)
+
+    with mock.patch.object(
+        mock_genai_client._api_client, "request", return_value=mock_http_response
+    ):
+        with start_transaction(name="google_genai"):
+            mock_genai_client.models.generate_content(
+                model="gemini-1.5-flash",
+                contents=small_content,
+                config=create_test_config(
+                    system_instruction=large_content,
+                ),
+            )
+
+    (event,) = events
+    invoke_span = event["spans"][0]
+    assert SPANDATA.GEN_AI_REQUEST_MESSAGES in invoke_span["data"]
+
+    messages_data = invoke_span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES]
+    assert isinstance(messages_data, str)
+
+    parsed_messages = json.loads(messages_data)
+    assert isinstance(parsed_messages, list)
+    assert len(parsed_messages) == 1
+    assert parsed_messages[0]["role"] == "user"
+    assert small_content in parsed_messages[0]["content"]
+
+    assert (
+        event["_meta"]["spans"]["0"]["data"]["gen_ai.request.messages"][""]["len"] == 2
+    )
+
+
+# Sample embed content API response JSON
+EXAMPLE_EMBED_RESPONSE_JSON = {
+    "embeddings": [
+        {
+            "values": [0.1, 0.2, 0.3, 0.4, 0.5],  # Simplified embedding vector
+            "statistics": {
+                "tokenCount": 10,
+                "truncated": False,
+            },
+        },
+        {
+            "values": [0.2, 0.3, 0.4, 0.5, 0.6],
+            "statistics": {
+                "tokenCount": 15,
+                "truncated": False,
+            },
+        },
+    ],
+    "metadata": {
+        "billableCharacterCount": 42,
+    },
+}
+
+
+@pytest.mark.parametrize(
+    "send_default_pii, include_prompts",
+    [
+        (True, True),
+        (True, False),
+        (False, True),
+        (False, False),
+    ],
+)
+def test_embed_content(
+    sentry_init, capture_events, send_default_pii, include_prompts, mock_genai_client
+):
+    sentry_init(
+        integrations=[GoogleGenAIIntegration(include_prompts=include_prompts)],
+        traces_sample_rate=1.0,
+        send_default_pii=send_default_pii,
+    )
+    events = capture_events()
+
+    # Mock the HTTP response at the _api_client.request() level
+    mock_http_response = create_mock_http_response(EXAMPLE_EMBED_RESPONSE_JSON)
+
+    with mock.patch.object(
+        mock_genai_client._api_client,
+        "request",
+        return_value=mock_http_response,
+    ):
+        with start_transaction(name="google_genai_embeddings"):
+            mock_genai_client.models.embed_content(
+                model="text-embedding-004",
+                contents=[
+                    "What is your name?",
+                    "What is your favorite color?",
+                ],
+            )
+
+    assert len(events) == 1
+    (event,) = events
+
+    assert event["type"] == "transaction"
+    assert event["transaction"] == "google_genai_embeddings"
+
+    # Should have 1 span for embeddings
+    assert len(event["spans"]) == 1
+    (embed_span,) = event["spans"]
+
+    # Check embeddings span
+    assert embed_span["op"] == OP.GEN_AI_EMBEDDINGS
+    assert embed_span["description"] == "embeddings text-embedding-004"
+    assert embed_span["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "embeddings"
+    assert embed_span["data"][SPANDATA.GEN_AI_SYSTEM] == "gcp.gemini"
+    assert embed_span["data"][SPANDATA.GEN_AI_REQUEST_MODEL] == "text-embedding-004"
+
+    # Check input texts if PII is allowed
+    if send_default_pii and include_prompts:
+        input_texts = json.loads(embed_span["data"][SPANDATA.GEN_AI_EMBEDDINGS_INPUT])
+        assert input_texts == [
+            "What is your name?",
+            "What is your favorite color?",
+        ]
+    else:
+        assert SPANDATA.GEN_AI_EMBEDDINGS_INPUT not in embed_span["data"]
+
+    # Check usage data (sum of token counts from statistics: 10 + 15 = 25)
+    # Note: Only available in newer versions with ContentEmbeddingStatistics
+    if SPANDATA.GEN_AI_USAGE_INPUT_TOKENS in embed_span["data"]:
+        assert embed_span["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 25
+
+
+def test_embed_content_string_input(sentry_init, capture_events, mock_genai_client):
+    """Test embed_content with a single string instead of list."""
+    sentry_init(
+        integrations=[GoogleGenAIIntegration(include_prompts=True)],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+    events = capture_events()
+
+    # Mock response with single embedding
+    single_embed_response = {
+        "embeddings": [
+            {
+                "values": [0.1, 0.2, 0.3],
+                "statistics": {
+                    "tokenCount": 5,
+                    "truncated": False,
+                },
+            },
+        ],
+        "metadata": {
+            "billableCharacterCount": 10,
+        },
+    }
+    mock_http_response = create_mock_http_response(single_embed_response)
+
+    with mock.patch.object(
+        mock_genai_client._api_client, "request", return_value=mock_http_response
+    ):
+        with start_transaction(name="google_genai_embeddings"):
+            mock_genai_client.models.embed_content(
+                model="text-embedding-004",
+                contents="Single text input",
+            )
+
+    (event,) = events
+    (embed_span,) = event["spans"]
+
+    # Check that single string is handled correctly
+    input_texts = json.loads(embed_span["data"][SPANDATA.GEN_AI_EMBEDDINGS_INPUT])
+    assert input_texts == ["Single text input"]
+    # Should use token_count from statistics (5), not billable_character_count (10)
+    # Note: Only available in newer versions with ContentEmbeddingStatistics
+    if SPANDATA.GEN_AI_USAGE_INPUT_TOKENS in embed_span["data"]:
+        assert embed_span["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 5
+
+
+def test_embed_content_error_handling(sentry_init, capture_events, mock_genai_client):
+    """Test error handling in embed_content."""
+    sentry_init(
+        integrations=[GoogleGenAIIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    # Mock an error at the HTTP level
+    with mock.patch.object(
+        mock_genai_client._api_client,
+        "request",
+        side_effect=Exception("Embedding API Error"),
+    ):
+        with start_transaction(name="google_genai_embeddings"):
+            with pytest.raises(Exception, match="Embedding API Error"):
+                mock_genai_client.models.embed_content(
+                    model="text-embedding-004",
+                    contents=["This will fail"],
+                )
+
+    # Should have both transaction and error events
+    assert len(events) == 2
+    error_event, _ = events
+
+    assert error_event["level"] == "error"
+    assert error_event["exception"]["values"][0]["type"] == "Exception"
+    assert error_event["exception"]["values"][0]["value"] == "Embedding API Error"
+    assert error_event["exception"]["values"][0]["mechanism"]["type"] == "google_genai"
+
+
+def test_embed_content_without_statistics(
+    sentry_init, capture_events, mock_genai_client
+):
+    """Test embed_content response without statistics (older package versions)."""
+    sentry_init(
+        integrations=[GoogleGenAIIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    # Response without statistics (typical for older google-genai versions)
+    # Embeddings exist but don't have the statistics field
+    old_version_response = {
+        "embeddings": [
+            {
+                "values": [0.1, 0.2, 0.3],
+            },
+            {
+                "values": [0.2, 0.3, 0.4],
+            },
+        ],
+    }
+    mock_http_response = create_mock_http_response(old_version_response)
+
+    with mock.patch.object(
+        mock_genai_client._api_client, "request", return_value=mock_http_response
+    ):
+        with start_transaction(name="google_genai_embeddings"):
+            mock_genai_client.models.embed_content(
+                model="text-embedding-004",
+                contents=["Test without statistics", "Another test"],
+            )
+
+    (event,) = events
+    (embed_span,) = event["spans"]
+
+    # No usage tokens since there are no statistics in older versions
+    # This is expected and the integration should handle it gracefully
+    assert SPANDATA.GEN_AI_USAGE_INPUT_TOKENS not in embed_span["data"]
+
+
+def test_embed_content_span_origin(sentry_init, capture_events, mock_genai_client):
+    """Test that embed_content spans have correct origin."""
+    sentry_init(
+        integrations=[GoogleGenAIIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    mock_http_response = create_mock_http_response(EXAMPLE_EMBED_RESPONSE_JSON)
+
+    with mock.patch.object(
+        mock_genai_client._api_client, "request", return_value=mock_http_response
+    ):
+        with start_transaction(name="google_genai_embeddings"):
+            mock_genai_client.models.embed_content(
+                model="text-embedding-004",
+                contents=["Test origin"],
+            )
+
+    (event,) = events
+
+    assert event["contexts"]["trace"]["origin"] == "manual"
+    for span in event["spans"]:
+        assert span["origin"] == "auto.ai.google_genai"
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    "send_default_pii, include_prompts",
+    [
+        (True, True),
+        (True, False),
+        (False, True),
+        (False, False),
+    ],
+)
+async def test_async_embed_content(
+    sentry_init, capture_events, send_default_pii, include_prompts, mock_genai_client
+):
+    """Test async embed_content method."""
+    sentry_init(
+        integrations=[GoogleGenAIIntegration(include_prompts=include_prompts)],
+        traces_sample_rate=1.0,
+        send_default_pii=send_default_pii,
+    )
+    events = capture_events()
+
+    # Mock the async HTTP response
+    mock_http_response = create_mock_http_response(EXAMPLE_EMBED_RESPONSE_JSON)
+
+    with mock.patch.object(
+        mock_genai_client._api_client,
+        "async_request",
+        return_value=mock_http_response,
+    ):
+        with start_transaction(name="google_genai_embeddings_async"):
+            await mock_genai_client.aio.models.embed_content(
+                model="text-embedding-004",
+                contents=[
+                    "What is your name?",
+                    "What is your favorite color?",
+                ],
+            )
+
+    assert len(events) == 1
+    (event,) = events
+
+    assert event["type"] == "transaction"
+    assert event["transaction"] == "google_genai_embeddings_async"
+
+    # Should have 1 span for embeddings
+    assert len(event["spans"]) == 1
+    (embed_span,) = event["spans"]
+
+    # Check embeddings span
+    assert embed_span["op"] == OP.GEN_AI_EMBEDDINGS
+    assert embed_span["description"] == "embeddings text-embedding-004"
+    assert embed_span["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "embeddings"
+    assert embed_span["data"][SPANDATA.GEN_AI_SYSTEM] == "gcp.gemini"
+    assert embed_span["data"][SPANDATA.GEN_AI_REQUEST_MODEL] == "text-embedding-004"
+
+    # Check input texts if PII is allowed
+    if send_default_pii and include_prompts:
+        input_texts = json.loads(embed_span["data"][SPANDATA.GEN_AI_EMBEDDINGS_INPUT])
+        assert input_texts == [
+            "What is your name?",
+            "What is your favorite color?",
+        ]
+    else:
+        assert SPANDATA.GEN_AI_EMBEDDINGS_INPUT not in embed_span["data"]
+
+    # Check usage data (sum of token counts from statistics: 10 + 15 = 25)
+    # Note: Only available in newer versions with ContentEmbeddingStatistics
+    if SPANDATA.GEN_AI_USAGE_INPUT_TOKENS in embed_span["data"]:
+        assert embed_span["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 25
+
+
+@pytest.mark.asyncio
+async def test_async_embed_content_string_input(
+    sentry_init, capture_events, mock_genai_client
+):
+    """Test async embed_content with a single string instead of list."""
+    sentry_init(
+        integrations=[GoogleGenAIIntegration(include_prompts=True)],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+    events = capture_events()
+
+    # Mock response with single embedding
+    single_embed_response = {
+        "embeddings": [
+            {
+                "values": [0.1, 0.2, 0.3],
+                "statistics": {
+                    "tokenCount": 5,
+                    "truncated": False,
+                },
+            },
+        ],
+        "metadata": {
+            "billableCharacterCount": 10,
+        },
+    }
+    mock_http_response = create_mock_http_response(single_embed_response)
+
+    with mock.patch.object(
+        mock_genai_client._api_client, "async_request", return_value=mock_http_response
+    ):
+        with start_transaction(name="google_genai_embeddings_async"):
+            await mock_genai_client.aio.models.embed_content(
+                model="text-embedding-004",
+                contents="Single text input",
+            )
+
+    (event,) = events
+    (embed_span,) = event["spans"]
+
+    # Check that single string is handled correctly
+    input_texts = json.loads(embed_span["data"][SPANDATA.GEN_AI_EMBEDDINGS_INPUT])
+    assert input_texts == ["Single text input"]
+    # Should use token_count from statistics (5), not billable_character_count (10)
+    # Note: Only available in newer versions with ContentEmbeddingStatistics
+    if SPANDATA.GEN_AI_USAGE_INPUT_TOKENS in embed_span["data"]:
+        assert embed_span["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 5
+
+
+@pytest.mark.asyncio
+async def test_async_embed_content_error_handling(
+    sentry_init, capture_events, mock_genai_client
+):
+    """Test error handling in async embed_content."""
+    sentry_init(
+        integrations=[GoogleGenAIIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    # Mock an error at the HTTP level
+    with mock.patch.object(
+        mock_genai_client._api_client,
+        "async_request",
+        side_effect=Exception("Async Embedding API Error"),
+    ):
+        with start_transaction(name="google_genai_embeddings_async"):
+            with pytest.raises(Exception, match="Async Embedding API Error"):
+                await mock_genai_client.aio.models.embed_content(
+                    model="text-embedding-004",
+                    contents=["This will fail"],
+                )
+
+    # Should have both transaction and error events
+    assert len(events) == 2
+    error_event, _ = events
+
+    assert error_event["level"] == "error"
+    assert error_event["exception"]["values"][0]["type"] == "Exception"
+    assert error_event["exception"]["values"][0]["value"] == "Async Embedding API Error"
+    assert error_event["exception"]["values"][0]["mechanism"]["type"] == "google_genai"
+
+
+@pytest.mark.asyncio
+async def test_async_embed_content_without_statistics(
+    sentry_init, capture_events, mock_genai_client
+):
+    """Test async embed_content response without statistics (older package versions)."""
+    sentry_init(
+        integrations=[GoogleGenAIIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    # Response without statistics (typical for older google-genai versions)
+    # Embeddings exist but don't have the statistics field
+    old_version_response = {
+        "embeddings": [
+            {
+                "values": [0.1, 0.2, 0.3],
+            },
+            {
+                "values": [0.2, 0.3, 0.4],
+            },
+        ],
+    }
+    mock_http_response = create_mock_http_response(old_version_response)
+
+    with mock.patch.object(
+        mock_genai_client._api_client, "async_request", return_value=mock_http_response
+    ):
+        with start_transaction(name="google_genai_embeddings_async"):
+            await mock_genai_client.aio.models.embed_content(
+                model="text-embedding-004",
+                contents=["Test without statistics", "Another test"],
+            )
+
+    (event,) = events
+    (embed_span,) = event["spans"]
+
+    # No usage tokens since there are no statistics in older versions
+    # This is expected and the integration should handle it gracefully
+    assert SPANDATA.GEN_AI_USAGE_INPUT_TOKENS not in embed_span["data"]
+
+
+@pytest.mark.asyncio
+async def test_async_embed_content_span_origin(
+    sentry_init, capture_events, mock_genai_client
+):
+    """Test that async embed_content spans have correct origin."""
+    sentry_init(
+        integrations=[GoogleGenAIIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    mock_http_response = create_mock_http_response(EXAMPLE_EMBED_RESPONSE_JSON)
+
+    with mock.patch.object(
+        mock_genai_client._api_client, "async_request", return_value=mock_http_response
+    ):
+        with start_transaction(name="google_genai_embeddings_async"):
+            await mock_genai_client.aio.models.embed_content(
+                model="text-embedding-004",
+                contents=["Test origin"],
+            )
+
+    (event,) = events
+
+    assert event["contexts"]["trace"]["origin"] == "manual"
+    for span in event["spans"]:
+        assert span["origin"] == "auto.ai.google_genai"
diff --git a/tests/integrations/gql/test_gql.py b/tests/integrations/gql/test_gql.py
index 7ae3cfe77d..147f7a06a8 100644
--- a/tests/integrations/gql/test_gql.py
+++ b/tests/integrations/gql/test_gql.py
@@ -5,21 +5,7 @@
 from gql import Client
 from gql.transport.exceptions import TransportQueryError
 from gql.transport.requests import RequestsHTTPTransport
-from graphql import DocumentNode
 from sentry_sdk.integrations.gql import GQLIntegration
-from unittest.mock import MagicMock, patch
-
-
-class _MockClientBase(MagicMock):
-    """
-    Mocked version of GQL Client class, following same spec as GQL Client.
-    """
-
-    def __init__(self, *args, **kwargs):
-        kwargs["spec"] = Client
-        super().__init__(*args, **kwargs)
-
-    transport = MagicMock()
 
 
 @responses.activate
@@ -58,16 +44,16 @@ def _make_erroneous_query(capture_events):
     with pytest.raises(TransportQueryError):
         _execute_mock_query(response_json)
 
-    assert (
-        len(events) == 1
-    ), "the sdk captured %d events, but 1 event was expected" % len(events)
+    assert len(events) == 1, (
+        "the sdk captured %d events, but 1 event was expected" % len(events)
+    )
 
     (event,) = events
     (exception,) = event["exception"]["values"]
 
-    assert (
-        exception["type"] == "TransportQueryError"
-    ), "%s was captured, but we expected a TransportQueryError" % exception(type)
+    assert exception["type"] == "TransportQueryError", (
+        "%s was captured, but we expected a TransportQueryError" % exception(type)
+    )
 
     assert "request" in event
 
@@ -81,95 +67,6 @@ def test_gql_init(sentry_init):
     sentry_init(integrations=[GQLIntegration()])
 
 
-@patch("sentry_sdk.integrations.gql.Hub")
-def test_setup_once_patches_execute_and_patched_function_calls_original(_):
-    """
-    Unit test which ensures the following:
-        1. The GQLIntegration setup_once function patches the gql.Client.execute method
-        2. The patched gql.Client.execute method still calls the original method, and it
-           forwards its arguments to the original method.
-        3. The patched gql.Client.execute method returns the same value that the original
-           method returns.
-    """
-    original_method_return_value = MagicMock()
-
-    class OriginalMockClient(_MockClientBase):
-        """
-        This mock client always returns the mock original_method_return_value when a query
-        is executed. This can be used to simulate successful GraphQL queries.
-        """
-
-        execute = MagicMock(
-            spec=Client.execute, return_value=original_method_return_value
-        )
-
-    original_execute_method = OriginalMockClient.execute
-
-    with patch(
-        "sentry_sdk.integrations.gql.gql.Client", new=OriginalMockClient
-    ) as PatchedMockClient:  # noqa: N806
-        # Below line should patch the PatchedMockClient with Sentry SDK magic
-        GQLIntegration.setup_once()
-
-        # We expect GQLIntegration.setup_once to patch the execute method.
-        assert (
-            PatchedMockClient.execute is not original_execute_method
-        ), "execute method not patched"
-
-        # Now, let's instantiate a client and send it a query. Original execute still should get called.
-        mock_query = MagicMock(spec=DocumentNode)
-        client_instance = PatchedMockClient()
-        patched_method_return_value = client_instance.execute(mock_query)
-
-    # Here, we check that the original execute was called
-    original_execute_method.assert_called_once_with(client_instance, mock_query)
-
-    # Also, let's verify that the patched execute returns the expected value.
-    assert (
-        patched_method_return_value is original_method_return_value
-    ), "pathced execute method returns a different value than the original execute method"
-
-
-@patch("sentry_sdk.integrations.gql.event_from_exception")
-@patch("sentry_sdk.integrations.gql.Hub")
-def test_patched_gql_execute_captures_and_reraises_graphql_exception(
-    mock_hub, mock_event_from_exception
-):
-    """
-    Unit test which ensures that in the case that calling the execute method results in a
-    TransportQueryError (which gql raises when a GraphQL error occurs), the patched method
-    captures the event on the current Hub and it reraises the error.
-    """
-    mock_event_from_exception.return_value = (dict(), MagicMock())
-
-    class OriginalMockClient(_MockClientBase):
-        """
-        This mock client always raises a TransportQueryError when a GraphQL query is attempted.
-        This simulates a GraphQL query which results in errors.
-        """
-
-        execute = MagicMock(
-            spec=Client.execute, side_effect=TransportQueryError("query failed")
-        )
-
-    with patch(
-        "sentry_sdk.integrations.gql.gql.Client", new=OriginalMockClient
-    ) as PatchedMockClient:  # noqa: N806
-        # Below line should patch the PatchedMockClient with Sentry SDK magic
-        GQLIntegration.setup_once()
-
-        mock_query = MagicMock(spec=DocumentNode)
-        client_instance = PatchedMockClient()
-
-        # The error should still get raised even though we have instrumented the execute method.
-        with pytest.raises(TransportQueryError):
-            client_instance.execute(mock_query)
-
-    # However, we should have also captured the error on the hub.
-    mock_capture_event = mock_hub.current.capture_event
-    mock_capture_event.assert_called_once()
-
-
 def test_real_gql_request_no_error(sentry_init, capture_events):
     """
     Integration test verifying that the GQLIntegration works as expected with successful query.
@@ -182,12 +79,12 @@ def test_real_gql_request_no_error(sentry_init, capture_events):
 
     result = _execute_mock_query(response_json)
 
-    assert (
-        result == response_data
-    ), "client.execute returned a different value from what it received from the server"
-    assert (
-        len(events) == 0
-    ), "the sdk captured an event, even though the query was successful"
+    assert result == response_data, (
+        "client.execute returned a different value from what it received from the server"
+    )
+    assert len(events) == 0, (
+        "the sdk captured an event, even though the query was successful"
+    )
 
 
 def test_real_gql_request_with_error_no_pii(sentry_init, capture_events):
diff --git a/tests/integrations/graphene/test_graphene_py3.py b/tests/integrations/graphene/test_graphene.py
similarity index 70%
rename from tests/integrations/graphene/test_graphene_py3.py
rename to tests/integrations/graphene/test_graphene.py
index 02bc34a515..5d54bb49cb 100644
--- a/tests/integrations/graphene/test_graphene_py3.py
+++ b/tests/integrations/graphene/test_graphene.py
@@ -3,6 +3,7 @@
 from flask import Flask, request, jsonify
 from graphene import ObjectType, String, Schema
 
+from sentry_sdk.consts import OP
 from sentry_sdk.integrations.fastapi import FastApiIntegration
 from sentry_sdk.integrations.flask import FlaskIntegration
 from sentry_sdk.integrations.graphene import GrapheneIntegration
@@ -201,3 +202,82 @@ def graphql_server_sync():
     client.post("/graphql", json=query)
 
     assert len(events) == 0
+
+
+def test_graphql_span_holds_query_information(sentry_init, capture_events):
+    sentry_init(
+        integrations=[GrapheneIntegration(), FlaskIntegration()],
+        enable_tracing=True,
+        default_integrations=False,
+    )
+    events = capture_events()
+
+    schema = Schema(query=Query)
+
+    sync_app = Flask(__name__)
+
+    @sync_app.route("/graphql", methods=["POST"])
+    def graphql_server_sync():
+        data = request.get_json()
+        result = schema.execute(data["query"], operation_name=data.get("operationName"))
+        return jsonify(result.data), 200
+
+    query = {
+        "query": "query GreetingQuery { hello }",
+        "operationName": "GreetingQuery",
+    }
+    client = sync_app.test_client()
+    client.post("/graphql", json=query)
+
+    assert len(events) == 1
+
+    (event,) = events
+    assert len(event["spans"]) == 1
+
+    (span,) = event["spans"]
+    assert span["op"] == OP.GRAPHQL_QUERY
+    assert span["description"] == query["operationName"]
+    assert span["data"]["graphql.document"] == query["query"]
+    assert span["data"]["graphql.operation.name"] == query["operationName"]
+    assert span["data"]["graphql.operation.type"] == "query"
+
+
+def test_breadcrumbs_hold_query_information_on_error(sentry_init, capture_events):
+    sentry_init(
+        integrations=[
+            GrapheneIntegration(),
+        ],
+        default_integrations=False,
+    )
+    events = capture_events()
+
+    schema = Schema(query=Query)
+
+    sync_app = Flask(__name__)
+
+    @sync_app.route("/graphql", methods=["POST"])
+    def graphql_server_sync():
+        data = request.get_json()
+        result = schema.execute(data["query"], operation_name=data.get("operationName"))
+        return jsonify(result.data), 200
+
+    query = {
+        "query": "query ErrorQuery { goodbye }",
+        "operationName": "ErrorQuery",
+    }
+    client = sync_app.test_client()
+    client.post("/graphql", json=query)
+
+    assert len(events) == 1
+
+    (event,) = events
+    assert len(event["breadcrumbs"]) == 1
+
+    breadcrumbs = event["breadcrumbs"]["values"]
+    assert len(breadcrumbs) == 1
+
+    (breadcrumb,) = breadcrumbs
+    assert breadcrumb["category"] == "graphql.operation"
+    assert breadcrumb["data"]["operation_name"] == query["operationName"]
+    assert breadcrumb["data"]["operation_type"] == "query"
+    assert breadcrumb["type"] == "default"
diff --git a/tests/integrations/grpc/__init__.py b/tests/integrations/grpc/__init__.py
index 88a0a201e4..f18dce91e2 100644
--- a/tests/integrations/grpc/__init__.py
+++ b/tests/integrations/grpc/__init__.py
@@ -1,3 +1,8 @@
+import sys
+from pathlib import Path
+
 import pytest
 
+# For imports inside gRPC autogenerated code to work
+sys.path.append(str(Path(__file__).parent))
 pytest.importorskip("grpc")
diff --git a/tests/integrations/grpc/compile_test_services.sh b/tests/integrations/grpc/compile_test_services.sh
new file mode 100755
index 0000000000..777a27e6e5
--- /dev/null
+++ b/tests/integrations/grpc/compile_test_services.sh
@@ -0,0 +1,15 @@
+#!/usr/bin/env bash
+
+# Run this script from the project root to generate the python code
+
+TARGET_PATH=./tests/integrations/grpc
+
+# Create python file
+python -m grpc_tools.protoc \
+    --proto_path=$TARGET_PATH/protos/ \
+    --python_out=$TARGET_PATH/ \
+    --pyi_out=$TARGET_PATH/ \
+    --grpc_python_out=$TARGET_PATH/ \
+    $TARGET_PATH/protos/grpc_test_service.proto
+
+echo Code generation successfull
diff --git a/tests/integrations/grpc/grpc_test_service.proto b/tests/integrations/grpc/grpc_test_service.proto
deleted file mode 100644
index 43497c7129..0000000000
--- a/tests/integrations/grpc/grpc_test_service.proto
+++ /dev/null
@@ -1,11 +0,0 @@
-syntax = "proto3";
-
-package grpc_test_server;
-
-service gRPCTestService{
-  rpc TestServe(gRPCTestMessage) returns (gRPCTestMessage);
-}
-
-message gRPCTestMessage {
-  string text = 1;
-}
diff --git a/tests/integrations/grpc/grpc_test_service_pb2.py b/tests/integrations/grpc/grpc_test_service_pb2.py
index 94765dae2c..84ea7f632a 100644
--- a/tests/integrations/grpc/grpc_test_service_pb2.py
+++ b/tests/integrations/grpc/grpc_test_service_pb2.py
@@ -2,26 +2,26 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # source: grpc_test_service.proto
 """Generated protocol buffer code."""
-from google.protobuf.internal import builder as _builder
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
 from google.protobuf import symbol_database as _symbol_database
-
+from google.protobuf.internal import builder as _builder
 # @@protoc_insertion_point(imports)
 
 _sym_db = _symbol_database.Default()
 
 
-DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(
-    b'\n\x17grpc_test_service.proto\x12\x10grpc_test_server"\x1f\n\x0fgRPCTestMessage\x12\x0c\n\x04text\x18\x01 \x01(\t2d\n\x0fgRPCTestService\x12Q\n\tTestServe\x12!.grpc_test_server.gRPCTestMessage\x1a!.grpc_test_server.gRPCTestMessageb\x06proto3'
-)
 
-_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals())
-_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, "grpc_test_service_pb2", globals())
+
+DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x17grpc_test_service.proto\x12\x10grpc_test_server\"\x1f\n\x0fgRPCTestMessage\x12\x0c\n\x04text\x18\x01 \x01(\t2\xf8\x02\n\x0fgRPCTestService\x12Q\n\tTestServe\x12!.grpc_test_server.gRPCTestMessage\x1a!.grpc_test_server.gRPCTestMessage\x12Y\n\x0fTestUnaryStream\x12!.grpc_test_server.gRPCTestMessage\x1a!.grpc_test_server.gRPCTestMessage0\x01\x12\\\n\x10TestStreamStream\x12!.grpc_test_server.gRPCTestMessage\x1a!.grpc_test_server.gRPCTestMessage(\x01\x30\x01\x12Y\n\x0fTestStreamUnary\x12!.grpc_test_server.gRPCTestMessage\x1a!.grpc_test_server.gRPCTestMessage(\x01\x62\x06proto3')
+
+_globals = globals()
+_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
+_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'grpc_test_service_pb2', _globals)
 if _descriptor._USE_C_DESCRIPTORS == False:
-    DESCRIPTOR._options = None
-    _GRPCTESTMESSAGE._serialized_start = 45
-    _GRPCTESTMESSAGE._serialized_end = 76
-    _GRPCTESTSERVICE._serialized_start = 78
-    _GRPCTESTSERVICE._serialized_end = 178
+  DESCRIPTOR._options = None
+  _globals['_GRPCTESTMESSAGE']._serialized_start=45
+  _globals['_GRPCTESTMESSAGE']._serialized_end=76
+  _globals['_GRPCTESTSERVICE']._serialized_start=79
+  _globals['_GRPCTESTSERVICE']._serialized_end=455
 # @@protoc_insertion_point(module_scope)
diff --git a/tests/integrations/grpc/grpc_test_service_pb2.pyi b/tests/integrations/grpc/grpc_test_service_pb2.pyi
index 02a0b7045b..f16d8a2d65 100644
--- a/tests/integrations/grpc/grpc_test_service_pb2.pyi
+++ b/tests/integrations/grpc/grpc_test_service_pb2.pyi
@@ -1,32 +1,11 @@
-"""
-@generated by mypy-protobuf.  Do not edit manually!
-isort:skip_file
-"""
-import builtins
-import google.protobuf.descriptor
-import google.protobuf.message
-import sys
+from google.protobuf import descriptor as _descriptor
+from google.protobuf import message as _message
+from typing import ClassVar as _ClassVar, Optional as _Optional
 
-if sys.version_info >= (3, 8):
-    import typing as typing_extensions
-else:
-    import typing_extensions
+DESCRIPTOR: _descriptor.FileDescriptor
 
-DESCRIPTOR: google.protobuf.descriptor.FileDescriptor
-
-@typing_extensions.final
-class gRPCTestMessage(google.protobuf.message.Message):
-    DESCRIPTOR: google.protobuf.descriptor.Descriptor
-
-    TEXT_FIELD_NUMBER: builtins.int
-    text: builtins.str
-    def __init__(
-        self,
-        *,
-        text: builtins.str = ...,
-    ) -> None: ...
-    def ClearField(
-        self, field_name: typing_extensions.Literal["text", b"text"]
-    ) -> None: ...
-
-global___gRPCTestMessage = gRPCTestMessage
+class gRPCTestMessage(_message.Message):
+    __slots__ = ["text"]
+    TEXT_FIELD_NUMBER: _ClassVar[int]
+    text: str
+    def __init__(self, text: _Optional[str] = ...) -> None: ...
diff --git a/tests/integrations/grpc/grpc_test_service_pb2_grpc.py b/tests/integrations/grpc/grpc_test_service_pb2_grpc.py
index 73b7d94c16..ad897608ca 100644
--- a/tests/integrations/grpc/grpc_test_service_pb2_grpc.py
+++ b/tests/integrations/grpc/grpc_test_service_pb2_grpc.py
@@ -2,7 +2,7 @@
 """Client and server classes corresponding to protobuf-defined services."""
 import grpc
 
-import tests.integrations.grpc.grpc_test_service_pb2 as grpc__test__service__pb2
+import grpc_test_service_pb2 as grpc__test__service__pb2
 
 
 class gRPCTestServiceStub(object):
@@ -15,10 +15,25 @@ def __init__(self, channel):
             channel: A grpc.Channel.
         """
         self.TestServe = channel.unary_unary(
-            "/grpc_test_server.gRPCTestService/TestServe",
-            request_serializer=grpc__test__service__pb2.gRPCTestMessage.SerializeToString,
-            response_deserializer=grpc__test__service__pb2.gRPCTestMessage.FromString,
-        )
+                '/grpc_test_server.gRPCTestService/TestServe',
+                request_serializer=grpc__test__service__pb2.gRPCTestMessage.SerializeToString,
+                response_deserializer=grpc__test__service__pb2.gRPCTestMessage.FromString,
+                )
+        self.TestUnaryStream = channel.unary_stream(
+                '/grpc_test_server.gRPCTestService/TestUnaryStream',
+                request_serializer=grpc__test__service__pb2.gRPCTestMessage.SerializeToString,
+                response_deserializer=grpc__test__service__pb2.gRPCTestMessage.FromString,
+                )
+        self.TestStreamStream = channel.stream_stream(
+                '/grpc_test_server.gRPCTestService/TestStreamStream',
+                request_serializer=grpc__test__service__pb2.gRPCTestMessage.SerializeToString,
+                response_deserializer=grpc__test__service__pb2.gRPCTestMessage.FromString,
+                )
+        self.TestStreamUnary = channel.stream_unary(
+                '/grpc_test_server.gRPCTestService/TestStreamUnary',
+                request_serializer=grpc__test__service__pb2.gRPCTestMessage.SerializeToString,
+                response_deserializer=grpc__test__service__pb2.gRPCTestMessage.FromString,
+                )
 
 
 class gRPCTestServiceServicer(object):
@@ -27,53 +42,124 @@ class gRPCTestServiceServicer(object):
     def TestServe(self, request, context):
         """Missing associated documentation comment in .proto file."""
         context.set_code(grpc.StatusCode.UNIMPLEMENTED)
-        context.set_details("Method not implemented!")
-        raise NotImplementedError("Method not implemented!")
+        context.set_details('Method not implemented!')
+        raise NotImplementedError('Method not implemented!')
+
+    def TestUnaryStream(self, request, context):
+        """Missing associated documentation comment in .proto file."""
+        context.set_code(grpc.StatusCode.UNIMPLEMENTED)
+        context.set_details('Method not implemented!')
+        raise NotImplementedError('Method not implemented!')
+
+    def TestStreamStream(self, request_iterator, context):
+        """Missing associated documentation comment in .proto file."""
+        context.set_code(grpc.StatusCode.UNIMPLEMENTED)
+        context.set_details('Method not implemented!')
+        raise NotImplementedError('Method not implemented!')
+
+    def TestStreamUnary(self, request_iterator, context):
+        """Missing associated documentation comment in .proto file."""
+        context.set_code(grpc.StatusCode.UNIMPLEMENTED)
+        context.set_details('Method not implemented!')
+        raise NotImplementedError('Method not implemented!')
 
 
 def add_gRPCTestServiceServicer_to_server(servicer, server):
     rpc_method_handlers = {
-        "TestServe": grpc.unary_unary_rpc_method_handler(
-            servicer.TestServe,
-            request_deserializer=grpc__test__service__pb2.gRPCTestMessage.FromString,
-            response_serializer=grpc__test__service__pb2.gRPCTestMessage.SerializeToString,
-        ),
+            'TestServe': grpc.unary_unary_rpc_method_handler(
+                    servicer.TestServe,
+                    request_deserializer=grpc__test__service__pb2.gRPCTestMessage.FromString,
+                    response_serializer=grpc__test__service__pb2.gRPCTestMessage.SerializeToString,
+            ),
+            'TestUnaryStream': grpc.unary_stream_rpc_method_handler(
+                    servicer.TestUnaryStream,
+                    request_deserializer=grpc__test__service__pb2.gRPCTestMessage.FromString,
+                    response_serializer=grpc__test__service__pb2.gRPCTestMessage.SerializeToString,
+            ),
+            'TestStreamStream': grpc.stream_stream_rpc_method_handler(
+                    servicer.TestStreamStream,
+                    request_deserializer=grpc__test__service__pb2.gRPCTestMessage.FromString,
+                    response_serializer=grpc__test__service__pb2.gRPCTestMessage.SerializeToString,
+            ),
+            'TestStreamUnary': grpc.stream_unary_rpc_method_handler(
+                    servicer.TestStreamUnary,
+                    request_deserializer=grpc__test__service__pb2.gRPCTestMessage.FromString,
+                    response_serializer=grpc__test__service__pb2.gRPCTestMessage.SerializeToString,
+            ),
     }
     generic_handler = grpc.method_handlers_generic_handler(
-        "grpc_test_server.gRPCTestService", rpc_method_handlers
-    )
+            'grpc_test_server.gRPCTestService', rpc_method_handlers)
     server.add_generic_rpc_handlers((generic_handler,))
 
 
-# This class is part of an EXPERIMENTAL API.
+ # This class is part of an EXPERIMENTAL API.
 class gRPCTestService(object):
     """Missing associated documentation comment in .proto file."""
 
     @staticmethod
-    def TestServe(
-        request,
-        target,
-        options=(),
-        channel_credentials=None,
-        call_credentials=None,
-        insecure=False,
-        compression=None,
-        wait_for_ready=None,
-        timeout=None,
-        metadata=None,
-    ):
-        return grpc.experimental.unary_unary(
-            request,
+    def TestServe(request,
+            target,
+            options=(),
+            channel_credentials=None,
+            call_credentials=None,
+            insecure=False,
+            compression=None,
+            wait_for_ready=None,
+            timeout=None,
+            metadata=None):
+        return grpc.experimental.unary_unary(request, target, '/grpc_test_server.gRPCTestService/TestServe',
+            grpc__test__service__pb2.gRPCTestMessage.SerializeToString,
+            grpc__test__service__pb2.gRPCTestMessage.FromString,
+            options, channel_credentials,
+            insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
+
+    @staticmethod
+    def TestUnaryStream(request,
+            target,
+            options=(),
+            channel_credentials=None,
+            call_credentials=None,
+            insecure=False,
+            compression=None,
+            wait_for_ready=None,
+            timeout=None,
+            metadata=None):
+        return grpc.experimental.unary_stream(request, target, '/grpc_test_server.gRPCTestService/TestUnaryStream',
+            grpc__test__service__pb2.gRPCTestMessage.SerializeToString,
+            grpc__test__service__pb2.gRPCTestMessage.FromString,
+            options, channel_credentials,
+            insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
+
+    @staticmethod
+    def TestStreamStream(request_iterator,
+            target,
+            options=(),
+            channel_credentials=None,
+            call_credentials=None,
+            insecure=False,
+            compression=None,
+            wait_for_ready=None,
+            timeout=None,
+            metadata=None):
+        return grpc.experimental.stream_stream(request_iterator, target, '/grpc_test_server.gRPCTestService/TestStreamStream',
+            grpc__test__service__pb2.gRPCTestMessage.SerializeToString,
+            grpc__test__service__pb2.gRPCTestMessage.FromString,
+            options, channel_credentials,
+            insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
+
+    @staticmethod
+    def TestStreamUnary(request_iterator,
             target,
-            "/grpc_test_server.gRPCTestService/TestServe",
+            options=(),
+            channel_credentials=None,
+            call_credentials=None,
+            insecure=False,
+            compression=None,
+            wait_for_ready=None,
+            timeout=None,
+            metadata=None):
+        return grpc.experimental.stream_unary(request_iterator, target, '/grpc_test_server.gRPCTestService/TestStreamUnary',
             grpc__test__service__pb2.gRPCTestMessage.SerializeToString,
             grpc__test__service__pb2.gRPCTestMessage.FromString,
-            options,
-            channel_credentials,
-            insecure,
-            call_credentials,
-            compression,
-            wait_for_ready,
-            timeout,
-            metadata,
-        )
+            options, channel_credentials,
+            insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
diff --git a/tests/integrations/grpc/protos/grpc_test_service.proto b/tests/integrations/grpc/protos/grpc_test_service.proto
new file mode 100644
index 0000000000..9eba747218
--- /dev/null
+++ b/tests/integrations/grpc/protos/grpc_test_service.proto
@@ -0,0 +1,14 @@
+syntax = "proto3";
+
+package grpc_test_server;
+
+service gRPCTestService{
+  rpc TestServe(gRPCTestMessage) returns (gRPCTestMessage);
+  rpc TestUnaryStream(gRPCTestMessage) returns (stream gRPCTestMessage);
+  rpc TestStreamStream(stream gRPCTestMessage) returns (stream gRPCTestMessage);
+  rpc TestStreamUnary(stream gRPCTestMessage) returns (gRPCTestMessage);
+}
+
+message gRPCTestMessage {
+  string text = 1;
+}
diff --git a/tests/integrations/grpc/test_grpc.py b/tests/integrations/grpc/test_grpc.py
index 92883e9256..8d2698f411 100644
--- a/tests/integrations/grpc/test_grpc.py
+++ b/tests/integrations/grpc/test_grpc.py
@@ -1,37 +1,64 @@
-from __future__ import absolute_import
-
-import os
-
-from concurrent import futures
-
 import grpc
 import pytest
 
-from sentry_sdk import Hub, start_transaction
+from concurrent import futures
+from typing import List, Optional, Tuple
+from unittest.mock import Mock
+
+from sentry_sdk import start_span, start_transaction
 from sentry_sdk.consts import OP
-from sentry_sdk.integrations.grpc.client import ClientInterceptor
-from sentry_sdk.integrations.grpc.server import ServerInterceptor
+from sentry_sdk.integrations.grpc import GRPCIntegration
+from tests.conftest import ApproxDict
 from tests.integrations.grpc.grpc_test_service_pb2 import gRPCTestMessage
 from tests.integrations.grpc.grpc_test_service_pb2_grpc import (
-    gRPCTestServiceServicer,
     add_gRPCTestServiceServicer_to_server,
+    gRPCTestServiceServicer,
     gRPCTestServiceStub,
 )
 
-PORT = 50051
-PORT += os.getpid() % 100  # avoid port conflicts when running tests in parallel
+
+# Set up in-memory channel instead of network-based
+def _set_up(
+    interceptors: Optional[List[grpc.ServerInterceptor]] = None,
+) -> Tuple[grpc.Server, grpc.Channel]:
+    """
+    Sets up a gRPC server and returns both the server and a channel connected to it.
+    This eliminates network dependencies and makes tests more reliable.
+    """
+    # Create server with thread pool
+    server = grpc.server(
+        futures.ThreadPoolExecutor(max_workers=2),
+        interceptors=interceptors,
+    )
+
+    # Add our test service to the server
+    servicer = TestService()
+    add_gRPCTestServiceServicer_to_server(servicer, server)
+
+    # Use dynamic port allocation instead of hardcoded port
+    port = server.add_insecure_port("[::]:0")  # Let gRPC choose an available port
+    server.start()
+
+    # Create channel connected to our server
+    channel = grpc.insecure_channel(f"localhost:{port}")  # noqa: E231
+
+    return server, channel
+
+
+def _tear_down(server: grpc.Server):
+    server.stop(grace=None)  # Immediate shutdown
 
 
 @pytest.mark.forked
 def test_grpc_server_starts_transaction(sentry_init, capture_events_forksafe):
-    sentry_init(traces_sample_rate=1.0)
+    sentry_init(traces_sample_rate=1.0, integrations=[GRPCIntegration()])
     events = capture_events_forksafe()
 
-    server = _set_up()
+    server, channel = _set_up()
 
-    with grpc.insecure_channel(f"localhost:{PORT}") as channel:
-        stub = gRPCTestServiceStub(channel)
-        stub.TestServe(gRPCTestMessage(text="test"))
+    # Use the provided channel
+    stub = gRPCTestServiceStub(channel)
+    stub.TestServe(gRPCTestMessage(text="test"))
 
     _tear_down(server=server)
 
@@ -47,35 +74,68 @@ def test_grpc_server_starts_transaction(sentry_init, capture_events_forksafe):
     assert span["op"] == "test"
 
 
+@pytest.mark.forked
+def test_grpc_server_other_interceptors(sentry_init, capture_events_forksafe):
+    """Ensure compatibility with additional server interceptors."""
+    sentry_init(traces_sample_rate=1.0, integrations=[GRPCIntegration()])
+    events = capture_events_forksafe()
+    mock_intercept = lambda continuation, handler_call_details: continuation(
+        handler_call_details
+    )
+    mock_interceptor = Mock()
+    mock_interceptor.intercept_service.side_effect = mock_intercept
+
+    server, channel = _set_up(interceptors=[mock_interceptor])
+
+    # Use the provided channel
+    stub = gRPCTestServiceStub(channel)
+    stub.TestServe(gRPCTestMessage(text="test"))
+
+    _tear_down(server=server)
+
+    mock_interceptor.intercept_service.assert_called_once()
+
+    events.write_file.close()
+    event = events.read_event()
+    span = event["spans"][0]
+
+    assert event["type"] == "transaction"
+    assert event["transaction_info"] == {
+        "source": "custom",
+    }
+    assert event["contexts"]["trace"]["op"] == OP.GRPC_SERVER
+    assert span["op"] == "test"
+
+
 @pytest.mark.forked
 def test_grpc_server_continues_transaction(sentry_init, capture_events_forksafe):
-    sentry_init(traces_sample_rate=1.0)
+    sentry_init(traces_sample_rate=1.0, integrations=[GRPCIntegration()])
     events = capture_events_forksafe()
 
-    server = _set_up()
+    server, channel = _set_up()
 
-    with grpc.insecure_channel(f"localhost:{PORT}") as channel:
-        stub = gRPCTestServiceStub(channel)
+    # Use the provided channel
+    stub = gRPCTestServiceStub(channel)
 
-        with start_transaction() as transaction:
-            metadata = (
-                (
-                    "baggage",
-                    "sentry-trace_id={trace_id},sentry-environment=test,"
-                    "sentry-transaction=test-transaction,sentry-sample_rate=1.0".format(
-                        trace_id=transaction.trace_id
-                    ),
+    with start_transaction() as transaction:
+        metadata = (
+            (
+                "baggage",
+                "sentry-trace_id={trace_id},sentry-environment=test,"
+                "sentry-transaction=test-transaction,sentry-sample_rate=1.0".format(
+                    trace_id=transaction.trace_id
                 ),
-                (
-                    "sentry-trace",
-                    "{trace_id}-{parent_span_id}-{sampled}".format(
-                        trace_id=transaction.trace_id,
-                        parent_span_id=transaction.span_id,
-                        sampled=1,
-                    ),
+            ),
+            (
+                "sentry-trace",
+                "{trace_id}-{parent_span_id}-{sampled}".format(
+                    trace_id=transaction.trace_id,
+                    parent_span_id=transaction.span_id,
+                    sampled=1,
                 ),
-            )
-            stub.TestServe(gRPCTestMessage(text="test"), metadata=metadata)
+            ),
+        )
+        stub.TestServe(gRPCTestMessage(text="test"), metadata=metadata)
 
     _tear_down(server=server)
 
@@ -94,18 +154,16 @@ def test_grpc_server_continues_transaction(sentry_init, capture_events_forksafe)
 
 @pytest.mark.forked
 def test_grpc_client_starts_span(sentry_init, capture_events_forksafe):
-    sentry_init(traces_sample_rate=1.0)
+    sentry_init(traces_sample_rate=1.0, integrations=[GRPCIntegration()])
     events = capture_events_forksafe()
-    interceptors = [ClientInterceptor()]
 
-    server = _set_up()
+    server, channel = _set_up()
 
-    with grpc.insecure_channel(f"localhost:{PORT}") as channel:
-        channel = grpc.intercept_channel(channel, *interceptors)
-        stub = gRPCTestServiceStub(channel)
+    # Use the provided channel
+    stub = gRPCTestServiceStub(channel)
 
-        with start_transaction():
-            stub.TestServe(gRPCTestMessage(text="test"))
+    with start_transaction():
+        stub.TestServe(gRPCTestMessage(text="test"))
 
     _tear_down(server=server)
 
@@ -120,29 +178,111 @@ def test_grpc_client_starts_span(sentry_init, capture_events_forksafe):
         span["description"]
         == "unary unary call to /grpc_test_server.gRPCTestService/TestServe"
     )
-    assert span["data"] == {
-        "type": "unary unary",
-        "method": "/grpc_test_server.gRPCTestService/TestServe",
-        "code": "OK",
-    }
+    assert span["data"] == ApproxDict(
+        {
+            "type": "unary unary",
+            "method": "/grpc_test_server.gRPCTestService/TestServe",
+            "code": "OK",
+        }
+    )
+
+
+@pytest.mark.forked
+def test_grpc_client_unary_stream_starts_span(sentry_init, capture_events_forksafe):
+    sentry_init(traces_sample_rate=1.0, integrations=[GRPCIntegration()])
+    events = capture_events_forksafe()
+
+    server, channel = _set_up()
+
+    # Use the provided channel
+    stub = gRPCTestServiceStub(channel)
+
+    with start_transaction():
+        [el for el in stub.TestUnaryStream(gRPCTestMessage(text="test"))]
+
+    _tear_down(server=server)
+
+    events.write_file.close()
+    local_transaction = events.read_event()
+    span = local_transaction["spans"][0]
+
+    assert len(local_transaction["spans"]) == 1
+    assert span["op"] == OP.GRPC_CLIENT
+    assert (
+        span["description"]
+        == "unary stream call to /grpc_test_server.gRPCTestService/TestUnaryStream"
+    )
+    assert span["data"] == ApproxDict(
+        {
+            "type": "unary stream",
+            "method": "/grpc_test_server.gRPCTestService/TestUnaryStream",
+        }
+    )
+
+
+# using unittest.mock.Mock not possible because grpc verifies
+# that the interceptor is of the correct type
+class MockClientInterceptor(grpc.UnaryUnaryClientInterceptor):
+    call_counter = 0
+
+    def intercept_unary_unary(self, continuation, client_call_details, request):
+        self.__class__.call_counter += 1
+        return continuation(client_call_details, request)
+
+
+@pytest.mark.forked
+def test_grpc_client_other_interceptor(sentry_init, capture_events_forksafe):
+    """Ensure compatibility with additional client interceptors."""
+    sentry_init(traces_sample_rate=1.0, integrations=[GRPCIntegration()])
+    events = capture_events_forksafe()
+
+    server, channel = _set_up()
+
+    # Intercept the channel
+    channel = grpc.intercept_channel(channel, MockClientInterceptor())
+    stub = gRPCTestServiceStub(channel)
+
+    with start_transaction():
+        stub.TestServe(gRPCTestMessage(text="test"))
+
+    _tear_down(server=server)
+
+    assert MockClientInterceptor.call_counter == 1
+
+    events.write_file.close()
+    events.read_event()
+    local_transaction = events.read_event()
+    span = local_transaction["spans"][0]
+
+    assert len(local_transaction["spans"]) == 1
+    assert span["op"] == OP.GRPC_CLIENT
+    assert (
+        span["description"]
+        == "unary unary call to /grpc_test_server.gRPCTestService/TestServe"
+    )
+    assert span["data"] == ApproxDict(
+        {
+            "type": "unary unary",
+            "method": "/grpc_test_server.gRPCTestService/TestServe",
+            "code": "OK",
+        }
+    )
 
 
 @pytest.mark.forked
 def test_grpc_client_and_servers_interceptors_integration(
     sentry_init, capture_events_forksafe
 ):
-    sentry_init(traces_sample_rate=1.0)
+    sentry_init(traces_sample_rate=1.0, integrations=[GRPCIntegration()])
     events = capture_events_forksafe()
-    interceptors = [ClientInterceptor()]
 
-    server = _set_up()
+    server, channel = _set_up()
 
-    with grpc.insecure_channel(f"localhost:{PORT}") as channel:
-        channel = grpc.intercept_channel(channel, *interceptors)
-        stub = gRPCTestServiceStub(channel)
+    # Use the provided channel
+    stub = gRPCTestServiceStub(channel)
 
-        with start_transaction():
-            stub.TestServe(gRPCTestMessage(text="test"))
+    with start_transaction():
+        stub.TestServe(gRPCTestMessage(text="test"))
 
     _tear_down(server=server)
 
@@ -156,25 +296,67 @@ def test_grpc_client_and_servers_interceptors_integration(
     )
 
 
-def _set_up():
-    server = grpc.server(
-        futures.ThreadPoolExecutor(max_workers=2),
-        interceptors=[ServerInterceptor(find_name=_find_name)],
-    )
+@pytest.mark.forked
+def test_stream_stream(sentry_init):
+    sentry_init(traces_sample_rate=1.0, integrations=[GRPCIntegration()])
+    server, channel = _set_up()
 
-    add_gRPCTestServiceServicer_to_server(TestService, server)
-    server.add_insecure_port(f"[::]:{PORT}")
-    server.start()
+    # Use the provided channel
+    stub = gRPCTestServiceStub(channel)
+    response_iterator = stub.TestStreamStream(iter((gRPCTestMessage(text="test"),)))
+    for response in response_iterator:
+        assert response.text == "test"
 
-    return server
+    _tear_down(server=server)
 
 
-def _tear_down(server: grpc.Server):
-    server.stop(None)
+@pytest.mark.forked
+def test_stream_unary(sentry_init):
+    """
+    Test to verify stream-stream works.
+    Tracing not supported for it yet.
+    """
+    sentry_init(traces_sample_rate=1.0, integrations=[GRPCIntegration()])
+    server, channel = _set_up()
+
+    # Use the provided channel
+    stub = gRPCTestServiceStub(channel)
+    response = stub.TestStreamUnary(iter((gRPCTestMessage(text="test"),)))
+    assert response.text == "test"
+
+    _tear_down(server=server)
+
+
+@pytest.mark.forked
+def test_span_origin(sentry_init, capture_events_forksafe):
+    sentry_init(traces_sample_rate=1.0, integrations=[GRPCIntegration()])
+    events = capture_events_forksafe()
+
+    server, channel = _set_up()
 
+    # Use the provided channel
+    stub = gRPCTestServiceStub(channel)
 
-def _find_name(request):
-    return request.__class__
+    with start_transaction(name="custom_transaction"):
+        stub.TestServe(gRPCTestMessage(text="test"))
+
+    _tear_down(server=server)
+
+    events.write_file.close()
+
+    transaction_from_integration = events.read_event()
+    custom_transaction = events.read_event()
+
+    assert (
+        transaction_from_integration["contexts"]["trace"]["origin"] == "auto.grpc.grpc"
+    )
+    assert (
+        transaction_from_integration["spans"][0]["origin"]
+        == "auto.grpc.grpc.TestService"
+    )  # manually created in TestService, not the instrumentation
+
+    assert custom_transaction["contexts"]["trace"]["origin"] == "manual"
+    assert custom_transaction["spans"][0]["origin"] == "auto.grpc.grpc"
 
 
 class TestService(gRPCTestServiceServicer):
@@ -182,8 +364,26 @@ class TestService(gRPCTestServiceServicer):
 
     @staticmethod
     def TestServe(request, context):  # noqa: N802
-        hub = Hub.current
-        with hub.start_span(op="test", description="test"):
+        with start_span(
+            op="test",
+            name="test",
+            origin="auto.grpc.grpc.TestService",
+        ):
             pass
 
         return gRPCTestMessage(text=request.text)
+
+    @staticmethod
+    def TestUnaryStream(request, context):  # noqa: N802
+        for _ in range(3):
+            yield gRPCTestMessage(text=request.text)
+
+    @staticmethod
+    def TestStreamStream(request, context):  # noqa: N802
+        for r in request:
+            yield r
+
+    @staticmethod
+    def TestStreamUnary(request, context):  # noqa: N802
+        requests = [r for r in request]
+        return requests.pop()
diff --git a/tests/integrations/grpc/test_grpc_aio.py b/tests/integrations/grpc/test_grpc_aio.py
new file mode 100644
index 0000000000..96e9a4dba8
--- /dev/null
+++ b/tests/integrations/grpc/test_grpc_aio.py
@@ -0,0 +1,335 @@
+import asyncio
+
+import grpc
+import pytest
+import pytest_asyncio
+import sentry_sdk
+
+from sentry_sdk import start_span, start_transaction
+from sentry_sdk.consts import OP
+from sentry_sdk.integrations.grpc import GRPCIntegration
+from tests.conftest import ApproxDict
+from tests.integrations.grpc.grpc_test_service_pb2 import gRPCTestMessage
+from tests.integrations.grpc.grpc_test_service_pb2_grpc import (
+    add_gRPCTestServiceServicer_to_server,
+    gRPCTestServiceServicer,
+    gRPCTestServiceStub,
+)
+
+
+@pytest_asyncio.fixture(scope="function")
+async def grpc_server_and_channel(sentry_init):
+    """
+    Creates an async gRPC server and a channel connected to it.
+    Returns both for use in tests, and cleans up afterward.
+    """
+    sentry_init(traces_sample_rate=1.0, integrations=[GRPCIntegration()])
+
+    # Create server
+    server = grpc.aio.server()
+
+    # Let gRPC choose a free port instead of hardcoding it
+    port = server.add_insecure_port("[::]:0")
+
+    # Add service implementation
+    add_gRPCTestServiceServicer_to_server(TestService, server)
+
+    # Start the server
+    await asyncio.create_task(server.start())
+
+    # Create channel connected to our server
+    channel = grpc.aio.insecure_channel(f"localhost:{port}")  # noqa: E231
+
+    try:
+        yield server, channel
+    finally:
+        # Clean up resources
+        await channel.close()
+        await server.stop(None)
+
+
+@pytest.mark.asyncio
+async def test_noop_for_unimplemented_method(sentry_init, capture_events):
+    sentry_init(traces_sample_rate=1.0, integrations=[GRPCIntegration()])
+
+    # Create empty server with no services
+    server = grpc.aio.server()
+    port = server.add_insecure_port("[::]:0")  # Let gRPC choose a free port
+    await asyncio.create_task(server.start())
+
+    events = capture_events()
+
+    try:
+        async with grpc.aio.insecure_channel(
+            f"localhost:{port}"  # noqa: E231
+        ) as channel:
+            stub = gRPCTestServiceStub(channel)
+            with pytest.raises(grpc.RpcError) as exc:
+                await stub.TestServe(gRPCTestMessage(text="test"))
+            assert exc.value.details() == "Method not found!"
+    finally:
+        await server.stop(None)
+
+    assert not events
+
+
+@pytest.mark.asyncio
+async def test_grpc_server_starts_transaction(grpc_server_and_channel, capture_events):
+    _, channel = grpc_server_and_channel
+    events = capture_events()
+
+    # Use the provided channel
+    stub = gRPCTestServiceStub(channel)
+    await stub.TestServe(gRPCTestMessage(text="test"))
+
+    (event,) = events
+    span = event["spans"][0]
+
+    assert event["type"] == "transaction"
+    assert event["transaction_info"] == {
+        "source": "custom",
+    }
+    assert event["contexts"]["trace"]["op"] == OP.GRPC_SERVER
+    assert span["op"] == "test"
+
+
+@pytest.mark.asyncio
+async def test_grpc_server_continues_transaction(
+    grpc_server_and_channel, capture_events
+):
+    _, channel = grpc_server_and_channel
+    events = capture_events()
+
+    # Use the provided channel
+    stub = gRPCTestServiceStub(channel)
+
+    with sentry_sdk.start_transaction() as transaction:
+        metadata = (
+            (
+                "baggage",
+                "sentry-trace_id={trace_id},sentry-environment=test,"
+                "sentry-transaction=test-transaction,sentry-sample_rate=1.0".format(
+                    trace_id=transaction.trace_id
+                ),
+            ),
+            (
+                "sentry-trace",
+                "{trace_id}-{parent_span_id}-{sampled}".format(
+                    trace_id=transaction.trace_id,
+                    parent_span_id=transaction.span_id,
+                    sampled=1,
+                ),
+            ),
+        )
+
+        await stub.TestServe(gRPCTestMessage(text="test"), metadata=metadata)
+
+    (event, _) = events
+    span = event["spans"][0]
+
+    assert event["type"] == "transaction"
+    assert event["transaction_info"] == {
+        "source": "custom",
+    }
+    assert event["contexts"]["trace"]["op"] == OP.GRPC_SERVER
+    assert event["contexts"]["trace"]["trace_id"] == transaction.trace_id
+    assert span["op"] == "test"
+
+
+@pytest.mark.asyncio
+async def test_grpc_server_exception(grpc_server_and_channel, capture_events):
+    _, channel = grpc_server_and_channel
+    events = capture_events()
+
+    # Use the provided channel
+    stub = gRPCTestServiceStub(channel)
+    try:
+        await stub.TestServe(gRPCTestMessage(text="exception"))
+        raise AssertionError()
+    except Exception:
+        pass
+
+    (event, _) = events
+
+    assert event["exception"]["values"][0]["type"] == "TestService.TestException"
+    assert event["exception"]["values"][0]["value"] == "test"
+    assert event["exception"]["values"][0]["mechanism"]["handled"] is False
+    assert event["exception"]["values"][0]["mechanism"]["type"] == "grpc"
+
+
+@pytest.mark.asyncio
+async def test_grpc_server_abort(grpc_server_and_channel, capture_events):
+    _, channel = grpc_server_and_channel
+    events = capture_events()
+
+    # Use the provided channel
+    stub = gRPCTestServiceStub(channel)
+    try:
+        await stub.TestServe(gRPCTestMessage(text="abort"))
+        raise AssertionError()
+    except Exception:
+        pass
+
+    # Add a small delay to allow events to be collected
+    await asyncio.sleep(0.1)
+
+    assert len(events) == 1
+
+
+@pytest.mark.asyncio
+async def test_grpc_client_starts_span(
+    grpc_server_and_channel, capture_events_forksafe
+):
+    _, channel = grpc_server_and_channel
+    events = capture_events_forksafe()
+
+    # Use the provided channel
+    stub = gRPCTestServiceStub(channel)
+    with start_transaction():
+        await stub.TestServe(gRPCTestMessage(text="test"))
+
+    events.write_file.close()
+    events.read_event()
+    local_transaction = events.read_event()
+    span = local_transaction["spans"][0]
+
+    assert len(local_transaction["spans"]) == 1
+    assert span["op"] == OP.GRPC_CLIENT
+    assert (
+        span["description"]
+        == "unary unary call to /grpc_test_server.gRPCTestService/TestServe"
+    )
+    assert span["data"] == ApproxDict(
+        {
+            "type": "unary unary",
+            "method": "/grpc_test_server.gRPCTestService/TestServe",
+            "code": "OK",
+        }
+    )
+
+
+@pytest.mark.asyncio
+async def test_grpc_client_unary_stream_starts_span(
+    grpc_server_and_channel, capture_events_forksafe
+):
+    _, channel = grpc_server_and_channel
+    events = capture_events_forksafe()
+
+    # Use the provided channel
+    stub = gRPCTestServiceStub(channel)
+    with start_transaction():
+        response = stub.TestUnaryStream(gRPCTestMessage(text="test"))
+        [_ async for _ in response]
+
+    events.write_file.close()
+    local_transaction = events.read_event()
+    span = local_transaction["spans"][0]
+
+    assert len(local_transaction["spans"]) == 1
+    assert span["op"] == OP.GRPC_CLIENT
+    assert (
+        span["description"]
+        == "unary stream call to /grpc_test_server.gRPCTestService/TestUnaryStream"
+    )
+    assert span["data"] == ApproxDict(
+        {
+            "type": "unary stream",
+            "method": "/grpc_test_server.gRPCTestService/TestUnaryStream",
+        }
+    )
+
+
+@pytest.mark.asyncio
+async def test_stream_stream(grpc_server_and_channel):
+    """
+    Test to verify stream-stream works.
+    Tracing not supported for it yet.
+    """
+    _, channel = grpc_server_and_channel
+
+    # Use the provided channel
+    stub = gRPCTestServiceStub(channel)
+    response = stub.TestStreamStream((gRPCTestMessage(text="test"),))
+    async for r in response:
+        assert r.text == "test"
+
+
+@pytest.mark.asyncio
+async def test_stream_unary(grpc_server_and_channel):
+    """
+    Test to verify stream-stream works.
+    Tracing not supported for it yet.
+    """
+    _, channel = grpc_server_and_channel
+
+    # Use the provided channel
+    stub = gRPCTestServiceStub(channel)
+    response = await stub.TestStreamUnary((gRPCTestMessage(text="test"),))
+    assert response.text == "test"
+
+
+@pytest.mark.asyncio
+async def test_span_origin(grpc_server_and_channel, capture_events_forksafe):
+    _, channel = grpc_server_and_channel
+    events = capture_events_forksafe()
+
+    # Use the provided channel
+    stub = gRPCTestServiceStub(channel)
+    with start_transaction(name="custom_transaction"):
+        await stub.TestServe(gRPCTestMessage(text="test"))
+
+    events.write_file.close()
+
+    transaction_from_integration = events.read_event()
+    custom_transaction = events.read_event()
+
+    assert (
+        transaction_from_integration["contexts"]["trace"]["origin"] == "auto.grpc.grpc"
+    )
+    assert (
+        transaction_from_integration["spans"][0]["origin"]
+        == "auto.grpc.grpc.TestService.aio"
+    )  # manually created in TestService, not the instrumentation
+
+    assert custom_transaction["contexts"]["trace"]["origin"] == "manual"
+    assert custom_transaction["spans"][0]["origin"] == "auto.grpc.grpc"
+
+
+class TestService(gRPCTestServiceServicer):
+    class TestException(Exception):
+        __test__ = False
+
+        def __init__(self):
+            super().__init__("test")
+
+    @classmethod
+    async def TestServe(cls, request, context):  # noqa: N802
+        with start_span(
+            op="test",
+            name="test",
+            origin="auto.grpc.grpc.TestService.aio",
+        ):
+            pass
+
+        if request.text == "exception":
+            raise cls.TestException()
+
+        if request.text == "abort":
+            await context.abort(grpc.StatusCode.ABORTED, "Aborted!")
+
+        return gRPCTestMessage(text=request.text)
+
+    @classmethod
+    async def TestUnaryStream(cls, request, context):  # noqa: N802
+        for _ in range(3):
+            yield gRPCTestMessage(text=request.text)
+
+    @classmethod
+    async def TestStreamStream(cls, request, context):  # noqa: N802
+        async for r in request:
+            yield r
+
+    @classmethod
+    async def TestStreamUnary(cls, request, context):  # noqa: N802
+        requests = [r async for r in request]
+        return requests.pop()
diff --git a/tests/integrations/httpx/__init__.py b/tests/integrations/httpx/__init__.py
index 1afd90ea3a..e524321b8b 100644
--- a/tests/integrations/httpx/__init__.py
+++ b/tests/integrations/httpx/__init__.py
@@ -1,3 +1,9 @@
+import os
+import sys
 import pytest
 
 pytest.importorskip("httpx")
+
+# Load `httpx_helpers` into the module search path to test request source path names relative to module. See
+# `test_request_source_with_module_in_search_path`
+sys.path.insert(0, os.path.join(os.path.dirname(__file__)))
diff --git a/tests/integrations/httpx/httpx_helpers/__init__.py b/tests/integrations/httpx/httpx_helpers/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/integrations/httpx/httpx_helpers/helpers.py b/tests/integrations/httpx/httpx_helpers/helpers.py
new file mode 100644
index 0000000000..f1d4f3c98b
--- /dev/null
+++ b/tests/integrations/httpx/httpx_helpers/helpers.py
@@ -0,0 +1,6 @@
+def get_request_with_client(client, url):
+    client.get(url)
+
+
+async def async_get_request_with_client(client, url):
+    await client.get(url)
diff --git a/tests/integrations/httpx/test_httpx.py b/tests/integrations/httpx/test_httpx.py
index e141faa282..33bdc93c73 100644
--- a/tests/integrations/httpx/test_httpx.py
+++ b/tests/integrations/httpx/test_httpx.py
@@ -1,24 +1,26 @@
+import os
+import datetime
 import asyncio
+from unittest import mock
 
-import pytest
 import httpx
-import responses
+import pytest
+from contextlib import contextmanager
 
+import sentry_sdk
 from sentry_sdk import capture_message, start_transaction
 from sentry_sdk.consts import MATCH_ALL, SPANDATA
 from sentry_sdk.integrations.httpx import HttpxIntegration
-
-try:
-    from unittest import mock  # python 3.3 and above
-except ImportError:
-    import mock  # python < 3.3
+from tests.conftest import ApproxDict
 
 
 @pytest.mark.parametrize(
     "httpx_client",
     (httpx.Client(), httpx.AsyncClient()),
 )
-def test_crumb_capture_and_hint(sentry_init, capture_events, httpx_client):
+def test_crumb_capture_and_hint(sentry_init, capture_events, httpx_client, httpx_mock):
+    httpx_mock.add_response()
+
     def before_breadcrumb(crumb, hint):
         crumb["data"]["extra"] = "foo"
         return crumb
@@ -26,7 +28,6 @@ def before_breadcrumb(crumb, hint):
     sentry_init(integrations=[HttpxIntegration()], before_breadcrumb=before_breadcrumb)
 
     url = "http://example.com/"
-    responses.add(responses.GET, url, status=200)
 
     with start_transaction():
         events = capture_events()
@@ -46,32 +47,45 @@ def before_breadcrumb(crumb, hint):
         crumb = event["breadcrumbs"]["values"][0]
         assert crumb["type"] == "http"
         assert crumb["category"] == "httplib"
-        assert crumb["data"] == {
-            "url": url,
-            SPANDATA.HTTP_METHOD: "GET",
-            SPANDATA.HTTP_FRAGMENT: "",
-            SPANDATA.HTTP_QUERY: "",
-            SPANDATA.HTTP_STATUS_CODE: 200,
-            "reason": "OK",
-            "extra": "foo",
-        }
+        assert crumb["data"] == ApproxDict(
+            {
+                "url": url,
+                SPANDATA.HTTP_METHOD: "GET",
+                SPANDATA.HTTP_FRAGMENT: "",
+                SPANDATA.HTTP_QUERY: "",
+                SPANDATA.HTTP_STATUS_CODE: 200,
+                "reason": "OK",
+                "extra": "foo",
+            }
+        )
 
 
 @pytest.mark.parametrize(
     "httpx_client",
     (httpx.Client(), httpx.AsyncClient()),
 )
-def test_outgoing_trace_headers(sentry_init, httpx_client):
-    sentry_init(traces_sample_rate=1.0, integrations=[HttpxIntegration()])
+@pytest.mark.parametrize(
+    "status_code,level",
+    [
+        (200, None),
+        (301, None),
+        (403, "warning"),
+        (405, "warning"),
+        (500, "error"),
+    ],
+)
+def test_crumb_capture_client_error(
+    sentry_init, capture_events, httpx_client, httpx_mock, status_code, level
+):
+    httpx_mock.add_response(status_code=status_code)
+
+    sentry_init(integrations=[HttpxIntegration()])
 
     url = "http://example.com/"
-    responses.add(responses.GET, url, status=200)
 
-    with start_transaction(
-        name="/interactions/other-dogs/new-dog",
-        op="greeting.sniff",
-        trace_id="01234567890123456789012345678901",
-    ) as transaction:
+    with start_transaction():
+        events = capture_events()
+
         if asyncio.iscoroutinefunction(httpx_client.get):
             response = asyncio.get_event_loop().run_until_complete(
                 httpx_client.get(url)
@@ -79,13 +93,28 @@ def test_outgoing_trace_headers(sentry_init, httpx_client):
         else:
             response = httpx_client.get(url)
 
-        request_span = transaction._span_recorder.spans[-1]
-        assert response.request.headers[
-            "sentry-trace"
-        ] == "{trace_id}-{parent_span_id}-{sampled}".format(
-            trace_id=transaction.trace_id,
-            parent_span_id=request_span.span_id,
-            sampled=1,
+        assert response.status_code == status_code
+        capture_message("Testing!")
+
+        (event,) = events
+
+        crumb = event["breadcrumbs"]["values"][0]
+        assert crumb["type"] == "http"
+        assert crumb["category"] == "httplib"
+
+        if level is None:
+            assert "level" not in crumb
+        else:
+            assert crumb["level"] == level
+
+        assert crumb["data"] == ApproxDict(
+            {
+                "url": url,
+                SPANDATA.HTTP_METHOD: "GET",
+                SPANDATA.HTTP_FRAGMENT: "",
+                SPANDATA.HTTP_QUERY: "",
+                SPANDATA.HTTP_STATUS_CODE: status_code,
+            }
         )
 
 
@@ -93,15 +122,15 @@ def test_outgoing_trace_headers(sentry_init, httpx_client):
     "httpx_client",
     (httpx.Client(), httpx.AsyncClient()),
 )
-def test_outgoing_trace_headers_append_to_baggage(sentry_init, httpx_client):
+def test_outgoing_trace_headers(sentry_init, httpx_client, httpx_mock):
+    httpx_mock.add_response()
+
     sentry_init(
         traces_sample_rate=1.0,
         integrations=[HttpxIntegration()],
-        release="d08ebdb9309e1b004c6f52202de58a09c2268e42",
     )
 
     url = "http://example.com/"
-    responses.add(responses.GET, url, status=200)
 
     with start_transaction(
         name="/interactions/other-dogs/new-dog",
@@ -110,10 +139,10 @@ def test_outgoing_trace_headers_append_to_baggage(sentry_init, httpx_client):
     ) as transaction:
         if asyncio.iscoroutinefunction(httpx_client.get):
             response = asyncio.get_event_loop().run_until_complete(
-                httpx_client.get(url, headers={"baGGage": "custom=data"})
+                httpx_client.get(url)
             )
         else:
-            response = httpx_client.get(url, headers={"baGGage": "custom=data"})
+            response = httpx_client.get(url)
 
         request_span = transaction._span_recorder.spans[-1]
         assert response.request.headers[
@@ -123,10 +152,53 @@ def test_outgoing_trace_headers_append_to_baggage(sentry_init, httpx_client):
             parent_span_id=request_span.span_id,
             sampled=1,
         )
-        assert (
-            response.request.headers["baggage"]
-            == "custom=data,sentry-trace_id=01234567890123456789012345678901,sentry-environment=production,sentry-release=d08ebdb9309e1b004c6f52202de58a09c2268e42,sentry-transaction=/interactions/other-dogs/new-dog,sentry-sample_rate=1.0,sentry-sampled=true"
-        )
+
+
+@pytest.mark.parametrize(
+    "httpx_client",
+    (httpx.Client(), httpx.AsyncClient()),
+)
+def test_outgoing_trace_headers_append_to_baggage(
+    sentry_init,
+    httpx_client,
+    httpx_mock,
+):
+    httpx_mock.add_response()
+
+    sentry_init(
+        traces_sample_rate=1.0,
+        integrations=[HttpxIntegration()],
+        release="d08ebdb9309e1b004c6f52202de58a09c2268e42",
+    )
+
+    url = "http://example.com/"
+
+    # patch random.randrange to return a predictable sample_rand value
+    with mock.patch("sentry_sdk.tracing_utils.Random.randrange", return_value=500000):
+        with start_transaction(
+            name="/interactions/other-dogs/new-dog",
+            op="greeting.sniff",
+            trace_id="01234567890123456789012345678901",
+        ) as transaction:
+            if asyncio.iscoroutinefunction(httpx_client.get):
+                response = asyncio.get_event_loop().run_until_complete(
+                    httpx_client.get(url, headers={"baGGage": "custom=data"})
+                )
+            else:
+                response = httpx_client.get(url, headers={"baGGage": "custom=data"})
+
+            request_span = transaction._span_recorder.spans[-1]
+            assert response.request.headers[
+                "sentry-trace"
+            ] == "{trace_id}-{parent_span_id}-{sampled}".format(
+                trace_id=transaction.trace_id,
+                parent_span_id=request_span.span_id,
+                sampled=1,
+            )
+            assert (
+                response.request.headers["baggage"]
+                == "custom=data,sentry-trace_id=01234567890123456789012345678901,sentry-sample_rand=0.500000,sentry-environment=production,sentry-release=d08ebdb9309e1b004c6f52202de58a09c2268e42,sentry-transaction=/interactions/other-dogs/new-dog,sentry-sample_rate=1.0,sentry-sampled=true"
+            )
 
 
 @pytest.mark.parametrize(
@@ -259,10 +331,12 @@ def test_option_trace_propagation_targets(
         integrations=[HttpxIntegration()],
     )
 
-    if asyncio.iscoroutinefunction(httpx_client.get):
-        asyncio.get_event_loop().run_until_complete(httpx_client.get(url))
-    else:
-        httpx_client.get(url)
+    # Must be in a transaction to propagate headers
+    with sentry_sdk.start_transaction():
+        if asyncio.iscoroutinefunction(httpx_client.get):
+            asyncio.get_event_loop().run_until_complete(httpx_client.get(url))
+        else:
+            httpx_client.get(url)
 
     request_headers = httpx_mock.get_request().headers
 
@@ -272,13 +346,30 @@ def test_option_trace_propagation_targets(
         assert "sentry-trace" not in request_headers
 
 
+def test_do_not_propagate_outside_transaction(sentry_init, httpx_mock):
+    httpx_mock.add_response()
+
+    sentry_init(
+        traces_sample_rate=1.0,
+        trace_propagation_targets=[MATCH_ALL],
+        integrations=[HttpxIntegration()],
+    )
+
+    httpx_client = httpx.Client()
+    httpx_client.get("http://example.com/")
+
+    request_headers = httpx_mock.get_request().headers
+    assert "sentry-trace" not in request_headers
+
+
 @pytest.mark.tests_internal_exceptions
-def test_omit_url_data_if_parsing_fails(sentry_init, capture_events):
+def test_omit_url_data_if_parsing_fails(sentry_init, capture_events, httpx_mock):
+    httpx_mock.add_response()
+
     sentry_init(integrations=[HttpxIntegration()])
 
     httpx_client = httpx.Client()
     url = "http://example.com"
-    responses.add(responses.GET, url, status=200)
 
     events = capture_events()
     with mock.patch(
@@ -291,9 +382,351 @@ def test_omit_url_data_if_parsing_fails(sentry_init, capture_events):
     capture_message("Testing!")
 
     (event,) = events
-    assert event["breadcrumbs"]["values"][0]["data"] == {
-        SPANDATA.HTTP_METHOD: "GET",
-        SPANDATA.HTTP_STATUS_CODE: 200,
-        "reason": "OK",
-        # no url related data
+    assert event["breadcrumbs"]["values"][0]["data"] == ApproxDict(
+        {
+            SPANDATA.HTTP_METHOD: "GET",
+            SPANDATA.HTTP_STATUS_CODE: 200,
+            "reason": "OK",
+            # no url related data
+        }
+    )
+
+    assert "url" not in event["breadcrumbs"]["values"][0]["data"]
+    assert SPANDATA.HTTP_FRAGMENT not in event["breadcrumbs"]["values"][0]["data"]
+    assert SPANDATA.HTTP_QUERY not in event["breadcrumbs"]["values"][0]["data"]
+
+
+@pytest.mark.parametrize(
+    "httpx_client",
+    (httpx.Client(), httpx.AsyncClient()),
+)
+def test_request_source_disabled(sentry_init, capture_events, httpx_client, httpx_mock):
+    httpx_mock.add_response()
+    sentry_options = {
+        "integrations": [HttpxIntegration()],
+        "traces_sample_rate": 1.0,
+        "enable_http_request_source": False,
+        "http_request_source_threshold_ms": 0,
     }
+
+    sentry_init(**sentry_options)
+
+    events = capture_events()
+
+    url = "http://example.com/"
+
+    with start_transaction(name="test_transaction"):
+        if asyncio.iscoroutinefunction(httpx_client.get):
+            asyncio.get_event_loop().run_until_complete(httpx_client.get(url))
+        else:
+            httpx_client.get(url)
+
+    (event,) = events
+
+    span = event["spans"][-1]
+    assert span["description"].startswith("GET")
+
+    data = span.get("data", {})
+
+    assert SPANDATA.CODE_LINENO not in data
+    assert SPANDATA.CODE_NAMESPACE not in data
+    assert SPANDATA.CODE_FILEPATH not in data
+    assert SPANDATA.CODE_FUNCTION not in data
+
+
+@pytest.mark.parametrize("enable_http_request_source", [None, True])
+@pytest.mark.parametrize(
+    "httpx_client",
+    (httpx.Client(), httpx.AsyncClient()),
+)
+def test_request_source_enabled(
+    sentry_init, capture_events, enable_http_request_source, httpx_client, httpx_mock
+):
+    httpx_mock.add_response()
+    sentry_options = {
+        "integrations": [HttpxIntegration()],
+        "traces_sample_rate": 1.0,
+        "http_request_source_threshold_ms": 0,
+    }
+    if enable_http_request_source is not None:
+        sentry_options["enable_http_request_source"] = enable_http_request_source
+
+    sentry_init(**sentry_options)
+
+    events = capture_events()
+
+    url = "http://example.com/"
+
+    with start_transaction(name="test_transaction"):
+        if asyncio.iscoroutinefunction(httpx_client.get):
+            asyncio.get_event_loop().run_until_complete(httpx_client.get(url))
+        else:
+            httpx_client.get(url)
+
+    (event,) = events
+
+    span = event["spans"][-1]
+    assert span["description"].startswith("GET")
+
+    data = span.get("data", {})
+
+    assert SPANDATA.CODE_LINENO in data
+    assert SPANDATA.CODE_NAMESPACE in data
+    assert SPANDATA.CODE_FILEPATH in data
+    assert SPANDATA.CODE_FUNCTION in data
+
+
+@pytest.mark.parametrize(
+    "httpx_client",
+    (httpx.Client(), httpx.AsyncClient()),
+)
+def test_request_source(sentry_init, capture_events, httpx_client, httpx_mock):
+    httpx_mock.add_response()
+
+    sentry_init(
+        integrations=[HttpxIntegration()],
+        traces_sample_rate=1.0,
+        enable_http_request_source=True,
+        http_request_source_threshold_ms=0,
+    )
+
+    events = capture_events()
+
+    url = "http://example.com/"
+
+    with start_transaction(name="test_transaction"):
+        if asyncio.iscoroutinefunction(httpx_client.get):
+            asyncio.get_event_loop().run_until_complete(httpx_client.get(url))
+        else:
+            httpx_client.get(url)
+
+    (event,) = events
+
+    span = event["spans"][-1]
+    assert span["description"].startswith("GET")
+
+    data = span.get("data", {})
+
+    assert SPANDATA.CODE_LINENO in data
+    assert SPANDATA.CODE_NAMESPACE in data
+    assert SPANDATA.CODE_FILEPATH in data
+    assert SPANDATA.CODE_FUNCTION in data
+
+    assert type(data.get(SPANDATA.CODE_LINENO)) == int
+    assert data.get(SPANDATA.CODE_LINENO) > 0
+    assert data.get(SPANDATA.CODE_NAMESPACE) == "tests.integrations.httpx.test_httpx"
+    assert data.get(SPANDATA.CODE_FILEPATH).endswith(
+        "tests/integrations/httpx/test_httpx.py"
+    )
+
+    is_relative_path = data.get(SPANDATA.CODE_FILEPATH)[0] != os.sep
+    assert is_relative_path
+
+    assert data.get(SPANDATA.CODE_FUNCTION) == "test_request_source"
+
+
+@pytest.mark.parametrize(
+    "httpx_client",
+    (httpx.Client(), httpx.AsyncClient()),
+)
+def test_request_source_with_module_in_search_path(
+    sentry_init, capture_events, httpx_client, httpx_mock
+):
+    """
+    Test that request source is relative to the path of the module it ran in
+    """
+    httpx_mock.add_response()
+    sentry_init(
+        integrations=[HttpxIntegration()],
+        traces_sample_rate=1.0,
+        enable_http_request_source=True,
+        http_request_source_threshold_ms=0,
+    )
+
+    events = capture_events()
+
+    url = "http://example.com/"
+
+    with start_transaction(name="test_transaction"):
+        if asyncio.iscoroutinefunction(httpx_client.get):
+            from httpx_helpers.helpers import async_get_request_with_client
+
+            asyncio.get_event_loop().run_until_complete(
+                async_get_request_with_client(httpx_client, url)
+            )
+        else:
+            from httpx_helpers.helpers import get_request_with_client
+
+            get_request_with_client(httpx_client, url)
+
+    (event,) = events
+
+    span = event["spans"][-1]
+    assert span["description"].startswith("GET")
+
+    data = span.get("data", {})
+
+    assert SPANDATA.CODE_LINENO in data
+    assert SPANDATA.CODE_NAMESPACE in data
+    assert SPANDATA.CODE_FILEPATH in data
+    assert SPANDATA.CODE_FUNCTION in data
+
+    assert type(data.get(SPANDATA.CODE_LINENO)) == int
+    assert data.get(SPANDATA.CODE_LINENO) > 0
+    assert data.get(SPANDATA.CODE_NAMESPACE) == "httpx_helpers.helpers"
+    assert data.get(SPANDATA.CODE_FILEPATH) == "httpx_helpers/helpers.py"
+
+    is_relative_path = data.get(SPANDATA.CODE_FILEPATH)[0] != os.sep
+    assert is_relative_path
+
+    if asyncio.iscoroutinefunction(httpx_client.get):
+        assert data.get(SPANDATA.CODE_FUNCTION) == "async_get_request_with_client"
+    else:
+        assert data.get(SPANDATA.CODE_FUNCTION) == "get_request_with_client"
+
+
+@pytest.mark.parametrize(
+    "httpx_client",
+    (httpx.Client(), httpx.AsyncClient()),
+)
+def test_no_request_source_if_duration_too_short(
+    sentry_init, capture_events, httpx_client, httpx_mock
+):
+    httpx_mock.add_response()
+
+    sentry_init(
+        integrations=[HttpxIntegration()],
+        traces_sample_rate=1.0,
+        enable_http_request_source=True,
+        http_request_source_threshold_ms=100,
+    )
+
+    events = capture_events()
+
+    url = "http://example.com/"
+
+    with start_transaction(name="test_transaction"):
+
+        @contextmanager
+        def fake_start_span(*args, **kwargs):
+            with sentry_sdk.start_span(*args, **kwargs) as span:
+                pass
+            span.start_timestamp = datetime.datetime(2024, 1, 1, microsecond=0)
+            span.timestamp = datetime.datetime(2024, 1, 1, microsecond=99999)
+            yield span
+
+        with mock.patch(
+            "sentry_sdk.integrations.httpx.start_span",
+            fake_start_span,
+        ):
+            if asyncio.iscoroutinefunction(httpx_client.get):
+                asyncio.get_event_loop().run_until_complete(httpx_client.get(url))
+            else:
+                httpx_client.get(url)
+
+    (event,) = events
+
+    span = event["spans"][-1]
+    assert span["description"].startswith("GET")
+
+    data = span.get("data", {})
+
+    assert SPANDATA.CODE_LINENO not in data
+    assert SPANDATA.CODE_NAMESPACE not in data
+    assert SPANDATA.CODE_FILEPATH not in data
+    assert SPANDATA.CODE_FUNCTION not in data
+
+
+@pytest.mark.parametrize(
+    "httpx_client",
+    (httpx.Client(), httpx.AsyncClient()),
+)
+def test_request_source_if_duration_over_threshold(
+    sentry_init, capture_events, httpx_client, httpx_mock
+):
+    httpx_mock.add_response()
+
+    sentry_init(
+        integrations=[HttpxIntegration()],
+        traces_sample_rate=1.0,
+        enable_http_request_source=True,
+        http_request_source_threshold_ms=100,
+    )
+
+    events = capture_events()
+
+    url = "http://example.com/"
+
+    with start_transaction(name="test_transaction"):
+
+        @contextmanager
+        def fake_start_span(*args, **kwargs):
+            with sentry_sdk.start_span(*args, **kwargs) as span:
+                pass
+            span.start_timestamp = datetime.datetime(2024, 1, 1, microsecond=0)
+            span.timestamp = datetime.datetime(2024, 1, 1, microsecond=100001)
+            yield span
+
+        with mock.patch(
+            "sentry_sdk.integrations.httpx.start_span",
+            fake_start_span,
+        ):
+            if asyncio.iscoroutinefunction(httpx_client.get):
+                asyncio.get_event_loop().run_until_complete(httpx_client.get(url))
+            else:
+                httpx_client.get(url)
+
+    (event,) = events
+
+    span = event["spans"][-1]
+    assert span["description"].startswith("GET")
+
+    data = span.get("data", {})
+
+    assert SPANDATA.CODE_LINENO in data
+    assert SPANDATA.CODE_NAMESPACE in data
+    assert SPANDATA.CODE_FILEPATH in data
+    assert SPANDATA.CODE_FUNCTION in data
+
+    assert type(data.get(SPANDATA.CODE_LINENO)) == int
+    assert data.get(SPANDATA.CODE_LINENO) > 0
+    assert data.get(SPANDATA.CODE_NAMESPACE) == "tests.integrations.httpx.test_httpx"
+    assert data.get(SPANDATA.CODE_FILEPATH).endswith(
+        "tests/integrations/httpx/test_httpx.py"
+    )
+
+    is_relative_path = data.get(SPANDATA.CODE_FILEPATH)[0] != os.sep
+    assert is_relative_path
+
+    assert (
+        data.get(SPANDATA.CODE_FUNCTION)
+        == "test_request_source_if_duration_over_threshold"
+    )
+
+
+@pytest.mark.parametrize(
+    "httpx_client",
+    (httpx.Client(), httpx.AsyncClient()),
+)
+def test_span_origin(sentry_init, capture_events, httpx_client, httpx_mock):
+    httpx_mock.add_response()
+
+    sentry_init(
+        integrations=[HttpxIntegration()],
+        traces_sample_rate=1.0,
+    )
+
+    events = capture_events()
+
+    url = "http://example.com/"
+
+    with start_transaction(name="test_transaction"):
+        if asyncio.iscoroutinefunction(httpx_client.get):
+            asyncio.get_event_loop().run_until_complete(httpx_client.get(url))
+        else:
+            httpx_client.get(url)
+
+    (event,) = events
+
+    assert event["contexts"]["trace"]["origin"] == "manual"
+    assert event["spans"][0]["origin"] == "auto.http.httpx"
diff --git a/tests/integrations/huey/test_huey.py b/tests/integrations/huey/test_huey.py
index 29e4d37027..143a369348 100644
--- a/tests/integrations/huey/test_huey.py
+++ b/tests/integrations/huey/test_huey.py
@@ -3,11 +3,16 @@
 
 from sentry_sdk import start_transaction
 from sentry_sdk.integrations.huey import HueyIntegration
+from sentry_sdk.utils import parse_version
 
+from huey import __version__ as HUEY_VERSION
 from huey.api import MemoryHuey, Result
 from huey.exceptions import RetryTask
 
 
+HUEY_VERSION = parse_version(HUEY_VERSION)
+
+
 @pytest.fixture
 def init_huey(sentry_init):
     def inner():
@@ -15,7 +20,6 @@ def inner():
             integrations=[HueyIntegration()],
             traces_sample_rate=1.0,
             send_default_pii=True,
-            debug=True,
         )
 
         return MemoryHuey(name="sentry_sdk")
@@ -119,6 +123,7 @@ def retry_task(context):
 
 
 @pytest.mark.parametrize("lock_name", ["lock.a", "lock.b"], ids=["locked", "unlocked"])
+@pytest.mark.skipif(HUEY_VERSION < (2, 5), reason="is_locked was added in 2.5")
 def test_task_lock(capture_events, init_huey, lock_name):
     huey = init_huey()
 
@@ -166,3 +171,55 @@ def dummy_task():
     assert len(event["spans"])
     assert event["spans"][0]["op"] == "queue.submit.huey"
     assert event["spans"][0]["description"] == "different_task_name"
+
+
+def test_huey_propagate_trace(init_huey, capture_events):
+    huey = init_huey()
+
+    events = capture_events()
+
+    @huey.task()
+    def propagated_trace_task():
+        pass
+
+    with start_transaction() as outer_transaction:
+        execute_huey_task(huey, propagated_trace_task)
+
+    assert (
+        events[0]["transaction"] == "propagated_trace_task"
+    )  # the "inner" transaction
+    assert events[0]["contexts"]["trace"]["trace_id"] == outer_transaction.trace_id
+
+
+def test_span_origin_producer(init_huey, capture_events):
+    huey = init_huey()
+
+    @huey.task(name="different_task_name")
+    def dummy_task():
+        pass
+
+    events = capture_events()
+
+    with start_transaction():
+        dummy_task()
+
+    (event,) = events
+
+    assert event["contexts"]["trace"]["origin"] == "manual"
+    assert event["spans"][0]["origin"] == "auto.queue.huey"
+
+
+def test_span_origin_consumer(init_huey, capture_events):
+    huey = init_huey()
+
+    events = capture_events()
+
+    @huey.task()
+    def propagated_trace_task():
+        pass
+
+    execute_huey_task(huey, propagated_trace_task)
+
+    (event,) = events
+
+    assert event["contexts"]["trace"]["origin"] == "auto.queue.huey"
diff --git a/tests/integrations/huggingface_hub/__init__.py b/tests/integrations/huggingface_hub/__init__.py
new file mode 100644
index 0000000000..fe1fa0af50
--- /dev/null
+++ b/tests/integrations/huggingface_hub/__init__.py
@@ -0,0 +1,3 @@
+import pytest
+
+pytest.importorskip("huggingface_hub")
diff --git a/tests/integrations/huggingface_hub/test_huggingface_hub.py b/tests/integrations/huggingface_hub/test_huggingface_hub.py
new file mode 100644
index 0000000000..851c1f717a
--- /dev/null
+++ b/tests/integrations/huggingface_hub/test_huggingface_hub.py
@@ -0,0 +1,1021 @@
+from unittest import mock
+import pytest
+import re
+import responses
+import httpx
+
+from huggingface_hub import InferenceClient
+
+import sentry_sdk
+from sentry_sdk.utils import package_version
+from sentry_sdk.integrations.huggingface_hub import HuggingfaceHubIntegration
+
+from typing import TYPE_CHECKING
+
+try:
+    from huggingface_hub.utils._errors import HfHubHTTPError
+except ImportError:
+    from huggingface_hub.errors import HfHubHTTPError
+
+
+if TYPE_CHECKING:
+    from typing import Any
+
+
+HF_VERSION = package_version("huggingface-hub")
+
+if HF_VERSION and HF_VERSION < (0, 30, 0):
+    MODEL_ENDPOINT = "https://api-inference.huggingface.co/models/{model_name}"
+    INFERENCE_ENDPOINT = "https://api-inference.huggingface.co/models/{model_name}"
+else:
+    MODEL_ENDPOINT = "https://huggingface.co/api/models/{model_name}"
+    INFERENCE_ENDPOINT = (
+        "https://router.huggingface.co/hf-inference/models/{model_name}"
+    )
+
+
+def get_hf_provider_inference_client():
+    # The provider parameter was added in version 0.28.0 of huggingface_hub
+    return (
+        InferenceClient(model="test-model", provider="hf-inference")
+        if HF_VERSION >= (0, 28, 0)
+        else InferenceClient(model="test-model")
+    )
+
+
+def _add_mock_response(
+    httpx_mock, rsps, method, url, json=None, status=200, body=None, headers=None
+):
+    # HF v1+ uses httpx for making requests to their API, while <1 uses requests.
+    # Since we have to test both, we need mocks for both httpx and requests.
+    if HF_VERSION >= (1, 0, 0):
+        httpx_mock.add_response(
+            method=method,
+            url=url,
+            json=json,
+            content=body,
+            status_code=status,
+            headers=headers,
+            is_optional=True,
+            is_reusable=True,
+        )
+    else:
+        rsps.add(
+            method=method,
+            url=url,
+            json=json,
+            body=body,
+            status=status,
+            headers=headers,
+        )
+
+
+@pytest.fixture
+def mock_hf_text_generation_api(httpx_mock):
+    # type: () -> Any
+    """Mock HuggingFace text generation API"""
+
+    with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
+        model_name = "test-model"
+
+        _add_mock_response(
+            httpx_mock,
+            rsps,
+            "GET",
+            re.compile(
+                MODEL_ENDPOINT.format(model_name=model_name)
+                + r"(\?expand=inferenceProviderMapping)?"
+            ),
+            json={
+                "id": model_name,
+                "pipeline_tag": "text-generation",
+                "inferenceProviderMapping": {
+                    "hf-inference": {
+                        "status": "live",
+                        "providerId": model_name,
+                        "task": "text-generation",
+                    }
+                },
+            },
+            status=200,
+        )
+
+        _add_mock_response(
+            httpx_mock,
+            rsps,
+            "POST",
+            INFERENCE_ENDPOINT.format(model_name=model_name),
+            json={
+                "generated_text": "[mocked] Hello! How can i help you?",
+                "details": {
+                    "finish_reason": "length",
+                    "generated_tokens": 10,
+                    "prefill": [],
+                    "tokens": [],
+                },
+            },
+            status=200,
+        )
+
+        if HF_VERSION >= (1, 0, 0):
+            yield httpx_mock
+        else:
+            yield rsps
+
+
+@pytest.fixture
+def mock_hf_api_with_errors(httpx_mock):
+    # type: () -> Any
+    """Mock HuggingFace API that always raises errors for any request"""
+
+    with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
+        model_name = "test-model"
+
+        # Mock model info endpoint with error
+        _add_mock_response(
+            httpx_mock,
+            rsps,
+            "GET",
+            MODEL_ENDPOINT.format(model_name=model_name),
+            json={"error": "Model not found"},
+            status=404,
+        )
+
+        # Mock text generation endpoint with error
+        _add_mock_response(
+            httpx_mock,
+            rsps,
+            "POST",
+            INFERENCE_ENDPOINT.format(model_name=model_name),
+            json={"error": "Internal server error", "message": "Something went wrong"},
+            status=500,
+        )
+
+        # Mock chat completion endpoint with error
+        _add_mock_response(
+            httpx_mock,
+            rsps,
+            "POST",
+            INFERENCE_ENDPOINT.format(model_name=model_name) + "/v1/chat/completions",
+            json={"error": "Internal server error", "message": "Something went wrong"},
+            status=500,
+        )
+
+        # Catch-all pattern for any other model requests
+        _add_mock_response(
+            httpx_mock,
+            rsps,
+            "GET",
+            "https://huggingface.co/api/models/test-model-error",
+            json={"error": "Generic model error"},
+            status=500,
+        )
+
+        if HF_VERSION >= (1, 0, 0):
+            yield httpx_mock
+        else:
+            yield rsps
+
+
+@pytest.fixture
+def mock_hf_text_generation_api_streaming(httpx_mock):
+    # type: () -> Any
+    """Mock streaming HuggingFace text generation API"""
+    with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
+        model_name = "test-model"
+
+        # Mock model info endpoint
+        _add_mock_response(
+            httpx_mock,
+            rsps,
+            "GET",
+            MODEL_ENDPOINT.format(model_name=model_name),
+            json={
+                "id": model_name,
+                "pipeline_tag": "text-generation",
+                "inferenceProviderMapping": {
+                    "hf-inference": {
+                        "status": "live",
+                        "providerId": model_name,
+                        "task": "text-generation",
+                    }
+                },
+            },
+            status=200,
+        )
+
+        # Mock text generation endpoint for streaming
+        streaming_response = b'data:{"token":{"id":1, "special": false, "text": "the mocked "}}\n\ndata:{"token":{"id":2, "special": false, "text": "model response"}, "details":{"finish_reason": "length", "generated_tokens": 10, "seed": 0}}\n\n'
+
+        _add_mock_response(
+            httpx_mock,
+            rsps,
+            "POST",
+            INFERENCE_ENDPOINT.format(model_name=model_name),
+            body=streaming_response,
+            status=200,
+            headers={
+                "Content-Type": "text/event-stream",
+                "Cache-Control": "no-cache",
+                "Connection": "keep-alive",
+            },
+        )
+
+        if HF_VERSION >= (1, 0, 0):
+            yield httpx_mock
+        else:
+            yield rsps
+
+
+@pytest.fixture
+def mock_hf_chat_completion_api(httpx_mock):
+    # type: () -> Any
+    """Mock HuggingFace chat completion API"""
+    with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
+        model_name = "test-model"
+
+        # Mock model info endpoint
+        _add_mock_response(
+            httpx_mock,
+            rsps,
+            "GET",
+            MODEL_ENDPOINT.format(model_name=model_name),
+            json={
+                "id": model_name,
+                "pipeline_tag": "conversational",
+                "inferenceProviderMapping": {
+                    "hf-inference": {
+                        "status": "live",
+                        "providerId": model_name,
+                        "task": "conversational",
+                    }
+                },
+            },
+            status=200,
+        )
+
+        # Mock chat completion endpoint
+        _add_mock_response(
+            httpx_mock,
+            rsps,
+            "POST",
+            INFERENCE_ENDPOINT.format(model_name=model_name) + "/v1/chat/completions",
+            json={
+                "id": "xyz-123",
+                "created": 1234567890,
+                "model": f"{model_name}-123",
+                "system_fingerprint": "fp_123",
+                "choices": [
+                    {
+                        "index": 0,
+                        "finish_reason": "stop",
+                        "message": {
+                            "role": "assistant",
+                            "content": "[mocked] Hello! How can I help you today?",
+                        },
+                    }
+                ],
+                "usage": {
+                    "completion_tokens": 8,
+                    "prompt_tokens": 10,
+                    "total_tokens": 18,
+                },
+            },
+            status=200,
+        )
+
+        if HF_VERSION >= (1, 0, 0):
+            yield httpx_mock
+        else:
+            yield rsps
+
+
+@pytest.fixture
+def mock_hf_chat_completion_api_tools(httpx_mock):
+    # type: () -> Any
+    """Mock HuggingFace chat completion API with tool calls."""
+    with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
+        model_name = "test-model"
+
+        # Mock model info endpoint
+        _add_mock_response(
+            httpx_mock,
+            rsps,
+            "GET",
+            MODEL_ENDPOINT.format(model_name=model_name),
+            json={
+                "id": model_name,
+                "pipeline_tag": "conversational",
+                "inferenceProviderMapping": {
+                    "hf-inference": {
+                        "status": "live",
+                        "providerId": model_name,
+                        "task": "conversational",
+                    }
+                },
+            },
+            status=200,
+        )
+
+        # Mock chat completion endpoint
+        _add_mock_response(
+            httpx_mock,
+            rsps,
+            "POST",
+            INFERENCE_ENDPOINT.format(model_name=model_name) + "/v1/chat/completions",
+            json={
+                "id": "xyz-123",
+                "created": 1234567890,
+                "model": f"{model_name}-123",
+                "system_fingerprint": "fp_123",
+                "choices": [
+                    {
+                        "index": 0,
+                        "finish_reason": "tool_calls",
+                        "message": {
+                            "role": "assistant",
+                            "tool_calls": [
+                                {
+                                    "id": "call_123",
+                                    "type": "function",
+                                    "function": {
+                                        "name": "get_weather",
+                                        "arguments": {"location": "Paris"},
+                                    },
+                                }
+                            ],
+                        },
+                    }
+                ],
+                "usage": {
+                    "completion_tokens": 8,
+                    "prompt_tokens": 10,
+                    "total_tokens": 18,
+                },
+            },
+            status=200,
+        )
+
+        if HF_VERSION >= (1, 0, 0):
+            yield httpx_mock
+        else:
+            yield rsps
+
+
+@pytest.fixture
+def mock_hf_chat_completion_api_streaming(httpx_mock):
+    # type: () -> Any
+    """Mock streaming HuggingFace chat completion API"""
+    with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
+        model_name = "test-model"
+
+        # Mock model info endpoint
+        _add_mock_response(
+            httpx_mock,
+            rsps,
+            "GET",
+            MODEL_ENDPOINT.format(model_name=model_name),
+            json={
+                "id": model_name,
+                "pipeline_tag": "conversational",
+                "inferenceProviderMapping": {
+                    "hf-inference": {
+                        "status": "live",
+                        "providerId": model_name,
+                        "task": "conversational",
+                    }
+                },
+            },
+            status=200,
+        )
+
+        # Mock chat completion streaming endpoint
+        streaming_chat_response = (
+            b'data:{"id":"xyz-123","created":1234567890,"model":"test-model-123","system_fingerprint":"fp_123","choices":[{"delta":{"role":"assistant","content":"the mocked "},"index":0,"finish_reason":null}],"usage":null}\n\n'
+            b'data:{"id":"xyz-124","created":1234567890,"model":"test-model-123","system_fingerprint":"fp_123","choices":[{"delta":{"role":"assistant","content":"model response"},"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":183,"completion_tokens":14,"total_tokens":197}}\n\n'
+        )
+
+        _add_mock_response(
+            httpx_mock,
+            rsps,
+            "POST",
+            INFERENCE_ENDPOINT.format(model_name=model_name) + "/v1/chat/completions",
+            body=streaming_chat_response,
+            status=200,
+            headers={
+                "Content-Type": "text/event-stream",
+                "Cache-Control": "no-cache",
+                "Connection": "keep-alive",
+            },
+        )
+
+        if HF_VERSION >= (1, 0, 0):
+            yield httpx_mock
+        else:
+            yield rsps
+
+
+@pytest.fixture
+def mock_hf_chat_completion_api_streaming_tools(httpx_mock):
+    # type: () -> Any
+    """Mock streaming HuggingFace chat completion API with tool calls."""
+    with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
+        model_name = "test-model"
+
+        # Mock model info endpoint
+        _add_mock_response(
+            httpx_mock,
+            rsps,
+            "GET",
+            MODEL_ENDPOINT.format(model_name=model_name),
+            json={
+                "id": model_name,
+                "pipeline_tag": "conversational",
+                "inferenceProviderMapping": {
+                    "hf-inference": {
+                        "status": "live",
+                        "providerId": model_name,
+                        "task": "conversational",
+                    }
+                },
+            },
+            status=200,
+        )
+
+        # Mock chat completion streaming endpoint
+        streaming_chat_response = (
+            b'data:{"id":"xyz-123","created":1234567890,"model":"test-model-123","system_fingerprint":"fp_123","choices":[{"delta":{"role":"assistant","content":"response with tool calls follows"},"index":0,"finish_reason":null}],"usage":null}\n\n'
+            b'data:{"id":"xyz-124","created":1234567890,"model":"test-model-123","system_fingerprint":"fp_123","choices":[{"delta":{"role":"assistant","tool_calls": [{"id": "call_123","type": "function","function": {"name": "get_weather", "arguments": {"location": "Paris"}}}]},"index":0,"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":183,"completion_tokens":14,"total_tokens":197}}\n\n'
+        )
+
+        _add_mock_response(
+            httpx_mock,
+            rsps,
+            "POST",
+            INFERENCE_ENDPOINT.format(model_name=model_name) + "/v1/chat/completions",
+            body=streaming_chat_response,
+            status=200,
+            headers={
+                "Content-Type": "text/event-stream",
+                "Cache-Control": "no-cache",
+                "Connection": "keep-alive",
+            },
+        )
+
+        if HF_VERSION >= (1, 0, 0):
+            yield httpx_mock
+        else:
+            yield rsps
+
+
+@pytest.mark.httpx_mock(assert_all_requests_were_expected=False)
+@pytest.mark.parametrize("send_default_pii", [True, False])
+@pytest.mark.parametrize("include_prompts", [True, False])
+def test_text_generation(
+    sentry_init: "Any",
+    capture_events: "Any",
+    send_default_pii: "Any",
+    include_prompts: "Any",
+    mock_hf_text_generation_api: "Any",
+) -> None:
+    sentry_init(
+        traces_sample_rate=1.0,
+        send_default_pii=send_default_pii,
+        integrations=[HuggingfaceHubIntegration(include_prompts=include_prompts)],
+    )
+    events = capture_events()
+
+    client = InferenceClient(model="test-model")
+
+    with sentry_sdk.start_transaction(name="test"):
+        client.text_generation(
+            "Hello",
+            stream=False,
+            details=True,
+        )
+
+    (transaction,) = events
+
+    span = None
+    for sp in transaction["spans"]:
+        if sp["op"].startswith("gen_ai"):
+            assert span is None, "there is exactly one gen_ai span"
+            span = sp
+        else:
+            # there should be no other spans, just the gen_ai span
+            # and optionally some http.client spans from talking to the hf api
+            assert sp["op"] == "http.client"
+
+    assert span is not None
+
+    assert span["op"] == "gen_ai.generate_text"
+    assert span["description"] == "generate_text test-model"
+    assert span["origin"] == "auto.ai.huggingface_hub"
+
+    expected_data = {
+        "gen_ai.operation.name": "generate_text",
+        "gen_ai.request.model": "test-model",
+        "gen_ai.response.finish_reasons": "length",
+        "gen_ai.response.streaming": False,
+        "gen_ai.usage.total_tokens": 10,
+        "thread.id": mock.ANY,
+        "thread.name": mock.ANY,
+    }
+
+    if send_default_pii and include_prompts:
+        expected_data["gen_ai.request.messages"] = "Hello"
+        expected_data["gen_ai.response.text"] = "[mocked] Hello! How can i help you?"
+
+    if not send_default_pii or not include_prompts:
+        assert "gen_ai.request.messages" not in expected_data
+        assert "gen_ai.response.text" not in expected_data
+
+    assert span["data"] == expected_data
+
+    # text generation does not set the response model
+    assert "gen_ai.response.model" not in span["data"]
+
+
+@pytest.mark.httpx_mock(assert_all_requests_were_expected=False)
+@pytest.mark.parametrize("send_default_pii", [True, False])
+@pytest.mark.parametrize("include_prompts", [True, False])
+def test_text_generation_streaming(
+    sentry_init: "Any",
+    capture_events: "Any",
+    send_default_pii: "Any",
+    include_prompts: "Any",
+    mock_hf_text_generation_api_streaming: "Any",
+) -> None:
+    sentry_init(
+        traces_sample_rate=1.0,
+        send_default_pii=send_default_pii,
+        integrations=[HuggingfaceHubIntegration(include_prompts=include_prompts)],
+    )
+    events = capture_events()
+
+    client = InferenceClient(model="test-model")
+
+    with sentry_sdk.start_transaction(name="test"):
+        for _ in client.text_generation(
+            prompt="Hello",
+            stream=True,
+            details=True,
+        ):
+            pass
+
+    (transaction,) = events
+
+    span = None
+    for sp in transaction["spans"]:
+        if sp["op"].startswith("gen_ai"):
+            assert span is None, "there is exactly one gen_ai span"
+            span = sp
+        else:
+            # there should be no other spans, just the gen_ai span
+            # and optionally some http.client spans from talking to the hf api
+            assert sp["op"] == "http.client"
+
+    assert span is not None
+
+    assert span["op"] == "gen_ai.generate_text"
+    assert span["description"] == "generate_text test-model"
+    assert span["origin"] == "auto.ai.huggingface_hub"
+
+    expected_data = {
+        "gen_ai.operation.name": "generate_text",
+        "gen_ai.request.model": "test-model",
+        "gen_ai.response.finish_reasons": "length",
+        "gen_ai.response.streaming": True,
+        "gen_ai.usage.total_tokens": 10,
+        "thread.id": mock.ANY,
+        "thread.name": mock.ANY,
+    }
+
+    if send_default_pii and include_prompts:
+        expected_data["gen_ai.request.messages"] = "Hello"
+        expected_data["gen_ai.response.text"] = "the mocked model response"
+
+    if not send_default_pii or not include_prompts:
+        assert "gen_ai.request.messages" not in expected_data
+        assert "gen_ai.response.text" not in expected_data
+
+    assert span["data"] == expected_data
+
+    # text generation does not set the response model
+    assert "gen_ai.response.model" not in span["data"]
+
+
+@pytest.mark.httpx_mock(assert_all_requests_were_expected=False)
+@pytest.mark.parametrize("send_default_pii", [True, False])
+@pytest.mark.parametrize("include_prompts", [True, False])
+def test_chat_completion(
+    sentry_init: "Any",
+    capture_events: "Any",
+    send_default_pii: "Any",
+    include_prompts: "Any",
+    mock_hf_chat_completion_api: "Any",
+) -> None:
+    sentry_init(
+        traces_sample_rate=1.0,
+        send_default_pii=send_default_pii,
+        integrations=[HuggingfaceHubIntegration(include_prompts=include_prompts)],
+    )
+    events = capture_events()
+
+    client = get_hf_provider_inference_client()
+
+    with sentry_sdk.start_transaction(name="test"):
+        client.chat_completion(
+            messages=[{"role": "user", "content": "Hello!"}],
+            stream=False,
+        )
+
+    (transaction,) = events
+
+    span = None
+    for sp in transaction["spans"]:
+        if sp["op"].startswith("gen_ai"):
+            assert span is None, "there is exactly one gen_ai span"
+            span = sp
+        else:
+            # there should be no other spans, just the gen_ai span
+            # and optionally some http.client spans from talking to the hf api
+            assert sp["op"] == "http.client"
+
+    assert span is not None
+
+    assert span["op"] == "gen_ai.chat"
+    assert span["description"] == "chat test-model"
+    assert span["origin"] == "auto.ai.huggingface_hub"
+
+    expected_data = {
+        "gen_ai.operation.name": "chat",
+        "gen_ai.request.model": "test-model",
+        "gen_ai.response.finish_reasons": "stop",
+        "gen_ai.response.model": "test-model-123",
+        "gen_ai.response.streaming": False,
+        "gen_ai.usage.input_tokens": 10,
+        "gen_ai.usage.output_tokens": 8,
+        "gen_ai.usage.total_tokens": 18,
+        "thread.id": mock.ANY,
+        "thread.name": mock.ANY,
+    }
+
+    if send_default_pii and include_prompts:
+        expected_data["gen_ai.request.messages"] = (
+            '[{"role": "user", "content": "Hello!"}]'
+        )
+        expected_data["gen_ai.response.text"] = (
+            "[mocked] Hello! How can I help you today?"
+        )
+
+    if not send_default_pii or not include_prompts:
+        assert "gen_ai.request.messages" not in expected_data
+        assert "gen_ai.response.text" not in expected_data
+
+    assert span["data"] == expected_data
+
+
+@pytest.mark.httpx_mock(assert_all_requests_were_expected=False)
+@pytest.mark.parametrize("send_default_pii", [True, False])
+@pytest.mark.parametrize("include_prompts", [True, False])
+def test_chat_completion_streaming(
+    sentry_init: "Any",
+    capture_events: "Any",
+    send_default_pii: "Any",
+    include_prompts: "Any",
+    mock_hf_chat_completion_api_streaming: "Any",
+) -> None:
+    sentry_init(
+        traces_sample_rate=1.0,
+        send_default_pii=send_default_pii,
+        integrations=[HuggingfaceHubIntegration(include_prompts=include_prompts)],
+    )
+    events = capture_events()
+
+    client = get_hf_provider_inference_client()
+
+    with sentry_sdk.start_transaction(name="test"):
+        _ = list(
+            client.chat_completion(
+                [{"role": "user", "content": "Hello!"}],
+                stream=True,
+            )
+        )
+
+    (transaction,) = events
+
+    span = None
+    for sp in transaction["spans"]:
+        if sp["op"].startswith("gen_ai"):
+            assert span is None, "there is exactly one gen_ai span"
+            span = sp
+        else:
+            # there should be no other spans, just the gen_ai span
+            # and optionally some http.client spans from talking to the hf api
+            assert sp["op"] == "http.client"
+
+    assert span is not None
+
+    assert span["op"] == "gen_ai.chat"
+    assert span["description"] == "chat test-model"
+    assert span["origin"] == "auto.ai.huggingface_hub"
+
+    expected_data = {
+        "gen_ai.operation.name": "chat",
+        "gen_ai.request.model": "test-model",
+        "gen_ai.response.finish_reasons": "stop",
+        "gen_ai.response.model": "test-model-123",
+        "gen_ai.response.streaming": True,
+        "thread.id": mock.ANY,
+        "thread.name": mock.ANY,
+    }
+    # usage is not available in older versions of the library
+    if HF_VERSION and HF_VERSION >= (0, 26, 0):
+        expected_data["gen_ai.usage.input_tokens"] = 183
+        expected_data["gen_ai.usage.output_tokens"] = 14
+        expected_data["gen_ai.usage.total_tokens"] = 197
+
+    if send_default_pii and include_prompts:
+        expected_data["gen_ai.request.messages"] = (
+            '[{"role": "user", "content": "Hello!"}]'
+        )
+        expected_data["gen_ai.response.text"] = "the mocked model response"
+
+    if not send_default_pii or not include_prompts:
+        assert "gen_ai.request.messages" not in expected_data
+        assert "gen_ai.response.text" not in expected_data
+
+    assert span["data"] == expected_data
+
+
+@pytest.mark.httpx_mock(assert_all_requests_were_expected=False)
+def test_chat_completion_api_error(
+    sentry_init: "Any", capture_events: "Any", mock_hf_api_with_errors: "Any"
+) -> None:
+    sentry_init(traces_sample_rate=1.0)
+    events = capture_events()
+
+    client = get_hf_provider_inference_client()
+
+    with sentry_sdk.start_transaction(name="test"):
+        with pytest.raises(HfHubHTTPError):
+            client.chat_completion(
+                messages=[{"role": "user", "content": "Hello!"}],
+            )
+
+    (
+        error,
+        transaction,
+    ) = events
+
+    assert error["exception"]["values"][0]["mechanism"]["type"] == "huggingface_hub"
+    assert not error["exception"]["values"][0]["mechanism"]["handled"]
+
+    span = None
+    for sp in transaction["spans"]:
+        if sp["op"].startswith("gen_ai"):
+            assert span is None, "there is exactly one gen_ai span"
+            span = sp
+        else:
+            # there should be no other spans, just the gen_ai span
+            # and optionally some http.client spans from talking to the hf api
+            assert sp["op"] == "http.client"
+
+    assert span is not None
+
+    assert span["op"] == "gen_ai.chat"
+    assert span["description"] == "chat test-model"
+    assert span["origin"] == "auto.ai.huggingface_hub"
+    assert span["status"] == "internal_error"
+    assert span.get("tags", {}).get("status") == "internal_error"
+
+    assert (
+        error["contexts"]["trace"]["trace_id"]
+        == transaction["contexts"]["trace"]["trace_id"]
+    )
+    expected_data = {
+        "gen_ai.operation.name": "chat",
+        "gen_ai.request.model": "test-model",
+        "thread.id": mock.ANY,
+        "thread.name": mock.ANY,
+    }
+    assert span["data"] == expected_data
+
+
+@pytest.mark.httpx_mock(assert_all_requests_were_expected=False)
+def test_span_status_error(
+    sentry_init: "Any", capture_events: "Any", mock_hf_api_with_errors: "Any"
+) -> None:
+    sentry_init(traces_sample_rate=1.0)
+    events = capture_events()
+
+    client = get_hf_provider_inference_client()
+
+    with sentry_sdk.start_transaction(name="test"):
+        with pytest.raises(HfHubHTTPError):
+            client.chat_completion(
+                messages=[{"role": "user", "content": "Hello!"}],
+            )
+
+    (error, transaction) = events
+    assert error["level"] == "error"
+
+    span = None
+    for sp in transaction["spans"]:
+        if sp["op"].startswith("gen_ai"):
+            assert span is None, "there is exactly one gen_ai span"
+            span = sp
+        else:
+            # there should be no other spans, just the gen_ai span
+            # and optionally some http.client spans from talking to the hf api
+            assert sp["op"] == "http.client"
+
+    assert span is not None
+    assert span["status"] == "internal_error"
+    assert span["tags"]["status"] == "internal_error"
+
+    assert transaction["contexts"]["trace"]["status"] == "internal_error"
+
+
+@pytest.mark.httpx_mock(assert_all_requests_were_expected=False)
+@pytest.mark.parametrize("send_default_pii", [True, False])
+@pytest.mark.parametrize("include_prompts", [True, False])
+def test_chat_completion_with_tools(
+    sentry_init: "Any",
+    capture_events: "Any",
+    send_default_pii: "Any",
+    include_prompts: "Any",
+    mock_hf_chat_completion_api_tools: "Any",
+) -> None:
+    sentry_init(
+        traces_sample_rate=1.0,
+        send_default_pii=send_default_pii,
+        integrations=[HuggingfaceHubIntegration(include_prompts=include_prompts)],
+    )
+    events = capture_events()
+
+    client = get_hf_provider_inference_client()
+
+    tools = [
+        {
+            "type": "function",
+            "function": {
+                "name": "get_weather",
+                "description": "Get current weather",
+                "parameters": {
+                    "type": "object",
+                    "properties": {"location": {"type": "string"}},
+                    "required": ["location"],
+                },
+            },
+        }
+    ]
+
+    with sentry_sdk.start_transaction(name="test"):
+        client.chat_completion(
+            messages=[{"role": "user", "content": "What is the weather in Paris?"}],
+            tools=tools,
+            tool_choice="auto",
+        )
+
+    (transaction,) = events
+
+    span = None
+    for sp in transaction["spans"]:
+        if sp["op"].startswith("gen_ai"):
+            assert span is None, "there is exactly one gen_ai span"
+            span = sp
+        else:
+            # there should be no other spans, just the gen_ai span
+            # and optionally some http.client spans from talking to the hf api
+            assert sp["op"] == "http.client"
+
+    assert span is not None
+
+    assert span["op"] == "gen_ai.chat"
+    assert span["description"] == "chat test-model"
+    assert span["origin"] == "auto.ai.huggingface_hub"
+
+    expected_data = {
+        "gen_ai.operation.name": "chat",
+        "gen_ai.request.available_tools": '[{"type": "function", "function": {"name": "get_weather", "description": "Get current weather", "parameters": {"type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"]}}}]',
+        "gen_ai.request.model": "test-model",
+        "gen_ai.response.finish_reasons": "tool_calls",
+        "gen_ai.response.model": "test-model-123",
+        "gen_ai.usage.input_tokens": 10,
+        "gen_ai.usage.output_tokens": 8,
+        "gen_ai.usage.total_tokens": 18,
+        "thread.id": mock.ANY,
+        "thread.name": mock.ANY,
+    }
+
+    if send_default_pii and include_prompts:
+        expected_data["gen_ai.request.messages"] = (
+            '[{"role": "user", "content": "What is the weather in Paris?"}]'
+        )
+        expected_data["gen_ai.response.tool_calls"] = (
+            '[{"function": {"arguments": {"location": "Paris"}, "name": "get_weather", "description": "None"}, "id": "call_123", "type": "function"}]'
+        )
+
+    if not send_default_pii or not include_prompts:
+        assert "gen_ai.request.messages" not in expected_data
+        assert "gen_ai.response.text" not in expected_data
+        assert "gen_ai.response.tool_calls" not in expected_data
+
+    assert span["data"] == expected_data
+
+
+@pytest.mark.httpx_mock(assert_all_requests_were_expected=False)
+@pytest.mark.parametrize("send_default_pii", [True, False])
+@pytest.mark.parametrize("include_prompts", [True, False])
+def test_chat_completion_streaming_with_tools(
+    sentry_init: "Any",
+    capture_events: "Any",
+    send_default_pii: "Any",
+    include_prompts: "Any",
+    mock_hf_chat_completion_api_streaming_tools: "Any",
+) -> None:
+    sentry_init(
+        traces_sample_rate=1.0,
+        send_default_pii=send_default_pii,
+        integrations=[HuggingfaceHubIntegration(include_prompts=include_prompts)],
+    )
+    events = capture_events()
+
+    client = get_hf_provider_inference_client()
+
+    tools = [
+        {
+            "type": "function",
+            "function": {
+                "name": "get_weather",
+                "description": "Get current weather",
+                "parameters": {
+                    "type": "object",
+                    "properties": {"location": {"type": "string"}},
+                    "required": ["location"],
+                },
+            },
+        }
+    ]
+
+    with sentry_sdk.start_transaction(name="test"):
+        _ = list(
+            client.chat_completion(
+                messages=[{"role": "user", "content": "What is the weather in Paris?"}],
+                stream=True,
+                tools=tools,
+                tool_choice="auto",
+            )
+        )
+
+    (transaction,) = events
+
+    span = None
+    for sp in transaction["spans"]:
+        if sp["op"].startswith("gen_ai"):
+            assert span is None, "there is exactly one gen_ai span"
+            span = sp
+        else:
+            # there should be no other spans, just the gen_ai span
+            # and optionally some http.client spans from talking to the hf api
+            assert sp["op"] == "http.client"
+
+    assert span is not None
+
+    assert span["op"] == "gen_ai.chat"
+    assert span["description"] == "chat test-model"
+    assert span["origin"] == "auto.ai.huggingface_hub"
+
+    expected_data = {
+        "gen_ai.operation.name": "chat",
+        "gen_ai.request.available_tools": '[{"type": "function", "function": {"name": "get_weather", "description": "Get current weather", "parameters": {"type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"]}}}]',
+        "gen_ai.request.model": "test-model",
+        "gen_ai.response.finish_reasons": "tool_calls",
+        "gen_ai.response.model": "test-model-123",
+        "gen_ai.response.streaming": True,
+        "thread.id": mock.ANY,
+        "thread.name": mock.ANY,
+    }
+
+    if HF_VERSION and HF_VERSION >= (0, 26, 0):
+        expected_data["gen_ai.usage.input_tokens"] = 183
+        expected_data["gen_ai.usage.output_tokens"] = 14
+        expected_data["gen_ai.usage.total_tokens"] = 197
+
+    if send_default_pii and include_prompts:
+        expected_data["gen_ai.request.messages"] = (
+            '[{"role": "user", "content": "What is the weather in Paris?"}]'
+        )
+        expected_data["gen_ai.response.text"] = "response with tool calls follows"
+        expected_data["gen_ai.response.tool_calls"] = (
+            '[{"function": {"arguments": {"location": "Paris"}, "name": "get_weather"}, "id": "call_123", "type": "function", "index": "None"}]'
+        )
+
+    if not send_default_pii or not include_prompts:
+        assert "gen_ai.request.messages" not in expected_data
+        assert "gen_ai.response.text" not in expected_data
+        assert "gen_ai.response.tool_calls" not in expected_data
+
+    assert span["data"] == expected_data
diff --git a/tests/integrations/langchain/__init__.py b/tests/integrations/langchain/__init__.py
new file mode 100644
index 0000000000..a286454a56
--- /dev/null
+++ b/tests/integrations/langchain/__init__.py
@@ -0,0 +1,3 @@
+import pytest
+
+pytest.importorskip("langchain_core")
diff --git a/tests/integrations/langchain/test_langchain.py b/tests/integrations/langchain/test_langchain.py
new file mode 100644
index 0000000000..114e819bfb
--- /dev/null
+++ b/tests/integrations/langchain/test_langchain.py
@@ -0,0 +1,1749 @@
+import json
+from typing import List, Optional, Any, Iterator
+from unittest import mock
+from unittest.mock import Mock, patch
+
+import pytest
+
+from sentry_sdk.consts import SPANDATA
+
+try:
+    # Langchain >= 0.2
+    from langchain_openai import ChatOpenAI
+except ImportError:
+    # Langchain < 0.2
+    from langchain_community.chat_models import ChatOpenAI
+
+from langchain_core.callbacks import BaseCallbackManager, CallbackManagerForLLMRun
+from langchain_core.messages import BaseMessage, AIMessageChunk
+from langchain_core.outputs import ChatGenerationChunk, ChatResult
+from langchain_core.runnables import RunnableConfig
+from langchain_core.language_models.chat_models import BaseChatModel
+
+import sentry_sdk
+from sentry_sdk import start_transaction
+from sentry_sdk.integrations.langchain import (
+    LangchainIntegration,
+    SentryLangchainCallback,
+)
+
+try:
+    # langchain v1+
+    from langchain.tools import tool
+    from langchain_classic.agents import AgentExecutor, create_openai_tools_agent  # type: ignore[import-not-found]
+except ImportError:
+    # langchain  int:
+    """Returns the length of a word."""
+    return len(word)
+
+
+global stream_result_mock  # type: Mock
+global llm_type  # type: str
+
+
+class MockOpenAI(ChatOpenAI):
+    def _stream(
+        self,
+        messages: List[BaseMessage],
+        stop: Optional[List[str]] = None,
+        run_manager: Optional[CallbackManagerForLLMRun] = None,
+        **kwargs: Any,
+    ) -> Iterator[ChatGenerationChunk]:
+        for x in stream_result_mock():
+            yield x
+
+    @property
+    def _llm_type(self) -> str:
+        return llm_type
+
+
+@pytest.mark.parametrize(
+    "send_default_pii, include_prompts, use_unknown_llm_type",
+    [
+        (True, True, False),
+        (True, False, False),
+        (False, True, False),
+        (False, False, True),
+    ],
+)
+def test_langchain_agent(
+    sentry_init, capture_events, send_default_pii, include_prompts, use_unknown_llm_type
+):
+    global llm_type
+    llm_type = "acme-llm" if use_unknown_llm_type else "openai-chat"
+
+    sentry_init(
+        integrations=[
+            LangchainIntegration(
+                include_prompts=include_prompts,
+            )
+        ],
+        traces_sample_rate=1.0,
+        send_default_pii=send_default_pii,
+    )
+    events = capture_events()
+
+    prompt = ChatPromptTemplate.from_messages(
+        [
+            (
+                "system",
+                "You are very powerful assistant, but don't know current events",
+            ),
+            ("user", "{input}"),
+            MessagesPlaceholder(variable_name="agent_scratchpad"),
+        ]
+    )
+    global stream_result_mock
+    stream_result_mock = Mock(
+        side_effect=[
+            [
+                ChatGenerationChunk(
+                    type="ChatGenerationChunk",
+                    message=AIMessageChunk(
+                        content="",
+                        additional_kwargs={
+                            "tool_calls": [
+                                {
+                                    "index": 0,
+                                    "id": "call_BbeyNhCKa6kYLYzrD40NGm3b",
+                                    "function": {
+                                        "arguments": "",
+                                        "name": "get_word_length",
+                                    },
+                                    "type": "function",
+                                }
+                            ]
+                        },
+                    ),
+                ),
+                ChatGenerationChunk(
+                    type="ChatGenerationChunk",
+                    message=AIMessageChunk(
+                        content="",
+                        additional_kwargs={
+                            "tool_calls": [
+                                {
+                                    "index": 0,
+                                    "id": None,
+                                    "function": {
+                                        "arguments": '{"word": "eudca"}',
+                                        "name": None,
+                                    },
+                                    "type": None,
+                                }
+                            ]
+                        },
+                    ),
+                ),
+                ChatGenerationChunk(
+                    type="ChatGenerationChunk",
+                    message=AIMessageChunk(
+                        content="5",
+                        usage_metadata={
+                            "input_tokens": 142,
+                            "output_tokens": 50,
+                            "total_tokens": 192,
+                            "input_token_details": {"audio": 0, "cache_read": 0},
+                            "output_token_details": {"audio": 0, "reasoning": 0},
+                        },
+                    ),
+                    generation_info={"finish_reason": "function_call"},
+                ),
+            ],
+            [
+                ChatGenerationChunk(
+                    text="The word eudca has 5 letters.",
+                    type="ChatGenerationChunk",
+                    message=AIMessageChunk(
+                        content="The word eudca has 5 letters.",
+                        usage_metadata={
+                            "input_tokens": 89,
+                            "output_tokens": 28,
+                            "total_tokens": 117,
+                            "input_token_details": {"audio": 0, "cache_read": 0},
+                            "output_token_details": {"audio": 0, "reasoning": 0},
+                        },
+                    ),
+                ),
+                ChatGenerationChunk(
+                    type="ChatGenerationChunk",
+                    generation_info={"finish_reason": "stop"},
+                    message=AIMessageChunk(content=""),
+                ),
+            ],
+        ]
+    )
+    llm = MockOpenAI(
+        model_name="gpt-3.5-turbo",
+        temperature=0,
+        openai_api_key="badkey",
+    )
+    agent = create_openai_tools_agent(llm, [get_word_length], prompt)
+
+    agent_executor = AgentExecutor(agent=agent, tools=[get_word_length], verbose=True)
+
+    with start_transaction():
+        list(agent_executor.stream({"input": "How many letters in the word eudca"}))
+
+    tx = events[0]
+    assert tx["type"] == "transaction"
+    chat_spans = list(x for x in tx["spans"] if x["op"] == "gen_ai.chat")
+    tool_exec_span = next(x for x in tx["spans"] if x["op"] == "gen_ai.execute_tool")
+
+    assert len(chat_spans) == 2
+
+    # We can't guarantee anything about the "shape" of the langchain execution graph
+    assert len(list(x for x in tx["spans"] if x["op"] == "gen_ai.chat")) > 0
+
+    # Token usage is only available in newer versions of langchain (v0.2+)
+    # where usage_metadata is supported on AIMessageChunk
+    if "gen_ai.usage.input_tokens" in chat_spans[0]["data"]:
+        assert chat_spans[0]["data"]["gen_ai.usage.input_tokens"] == 142
+        assert chat_spans[0]["data"]["gen_ai.usage.output_tokens"] == 50
+        assert chat_spans[0]["data"]["gen_ai.usage.total_tokens"] == 192
+
+    if "gen_ai.usage.input_tokens" in chat_spans[1]["data"]:
+        assert chat_spans[1]["data"]["gen_ai.usage.input_tokens"] == 89
+        assert chat_spans[1]["data"]["gen_ai.usage.output_tokens"] == 28
+        assert chat_spans[1]["data"]["gen_ai.usage.total_tokens"] == 117
+
+    if send_default_pii and include_prompts:
+        assert (
+            "You are very powerful"
+            in chat_spans[0]["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES]
+        )
+        assert "5" in chat_spans[0]["data"][SPANDATA.GEN_AI_RESPONSE_TEXT]
+        assert "word" in tool_exec_span["data"][SPANDATA.GEN_AI_TOOL_INPUT]
+        assert 5 == int(tool_exec_span["data"][SPANDATA.GEN_AI_TOOL_OUTPUT])
+        assert (
+            "You are very powerful"
+            in chat_spans[1]["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES]
+        )
+        assert "5" in chat_spans[1]["data"][SPANDATA.GEN_AI_RESPONSE_TEXT]
+
+        # Verify tool calls are recorded when PII is enabled
+        assert SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS in chat_spans[0].get("data", {}), (
+            "Tool calls should be recorded when send_default_pii=True and include_prompts=True"
+        )
+        tool_calls_data = chat_spans[0]["data"][SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS]
+        assert isinstance(tool_calls_data, (list, str))  # Could be serialized
+        if isinstance(tool_calls_data, str):
+            assert "get_word_length" in tool_calls_data
+        elif isinstance(tool_calls_data, list) and len(tool_calls_data) > 0:
+            # Check if tool calls contain expected function name
+            tool_call_str = str(tool_calls_data)
+            assert "get_word_length" in tool_call_str
+    else:
+        assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in chat_spans[0].get("data", {})
+        assert SPANDATA.GEN_AI_RESPONSE_TEXT not in chat_spans[0].get("data", {})
+        assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in chat_spans[1].get("data", {})
+        assert SPANDATA.GEN_AI_RESPONSE_TEXT not in chat_spans[1].get("data", {})
+        assert SPANDATA.GEN_AI_TOOL_INPUT not in tool_exec_span.get("data", {})
+        assert SPANDATA.GEN_AI_TOOL_OUTPUT not in tool_exec_span.get("data", {})
+
+        # Verify tool calls are NOT recorded when PII is disabled
+        assert SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS not in chat_spans[0].get(
+            "data", {}
+        ), (
+            f"Tool calls should NOT be recorded when send_default_pii={send_default_pii} "
+            f"and include_prompts={include_prompts}"
+        )
+        assert SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS not in chat_spans[1].get(
+            "data", {}
+        ), (
+            f"Tool calls should NOT be recorded when send_default_pii={send_default_pii} "
+            f"and include_prompts={include_prompts}"
+        )
+
+    # Verify that available tools are always recorded regardless of PII settings
+    for chat_span in chat_spans:
+        span_data = chat_span.get("data", {})
+        if SPANDATA.GEN_AI_REQUEST_AVAILABLE_TOOLS in span_data:
+            tools_data = span_data[SPANDATA.GEN_AI_REQUEST_AVAILABLE_TOOLS]
+            assert tools_data is not None, (
+                "Available tools should always be recorded regardless of PII settings"
+            )
+
+
+def test_langchain_error(sentry_init, capture_events):
+    sentry_init(
+        integrations=[LangchainIntegration(include_prompts=True)],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+    events = capture_events()
+
+    prompt = ChatPromptTemplate.from_messages(
+        [
+            (
+                "system",
+                "You are very powerful assistant, but don't know current events",
+            ),
+            ("user", "{input}"),
+            MessagesPlaceholder(variable_name="agent_scratchpad"),
+        ]
+    )
+    global stream_result_mock
+    stream_result_mock = Mock(side_effect=ValueError("API rate limit error"))
+    llm = MockOpenAI(
+        model_name="gpt-3.5-turbo",
+        temperature=0,
+        openai_api_key="badkey",
+    )
+    agent = create_openai_tools_agent(llm, [get_word_length], prompt)
+
+    agent_executor = AgentExecutor(agent=agent, tools=[get_word_length], verbose=True)
+
+    with start_transaction(), pytest.raises(ValueError):
+        list(agent_executor.stream({"input": "How many letters in the word eudca"}))
+
+    error = events[0]
+    assert error["level"] == "error"
+
+
+def test_span_status_error(sentry_init, capture_events):
+    global llm_type
+    llm_type = "acme-llm"
+
+    sentry_init(
+        integrations=[LangchainIntegration(include_prompts=True)],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    with start_transaction(name="test"):
+        prompt = ChatPromptTemplate.from_messages(
+            [
+                (
+                    "system",
+                    "You are very powerful assistant, but don't know current events",
+                ),
+                ("user", "{input}"),
+                MessagesPlaceholder(variable_name="agent_scratchpad"),
+            ]
+        )
+        global stream_result_mock
+        stream_result_mock = Mock(side_effect=ValueError("API rate limit error"))
+        llm = MockOpenAI(
+            model_name="gpt-3.5-turbo",
+            temperature=0,
+            openai_api_key="badkey",
+        )
+        agent = create_openai_tools_agent(llm, [get_word_length], prompt)
+
+        agent_executor = AgentExecutor(
+            agent=agent, tools=[get_word_length], verbose=True
+        )
+
+        with pytest.raises(ValueError):
+            list(agent_executor.stream({"input": "How many letters in the word eudca"}))
+
+    (error, transaction) = events
+    assert error["level"] == "error"
+    assert transaction["spans"][0]["status"] == "internal_error"
+    assert transaction["spans"][0]["tags"]["status"] == "internal_error"
+    assert transaction["contexts"]["trace"]["status"] == "internal_error"
+
+
+def test_span_origin(sentry_init, capture_events):
+    sentry_init(
+        integrations=[LangchainIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    prompt = ChatPromptTemplate.from_messages(
+        [
+            (
+                "system",
+                "You are very powerful assistant, but don't know current events",
+            ),
+            ("user", "{input}"),
+            MessagesPlaceholder(variable_name="agent_scratchpad"),
+        ]
+    )
+    global stream_result_mock
+    stream_result_mock = Mock(
+        side_effect=[
+            [
+                ChatGenerationChunk(
+                    type="ChatGenerationChunk",
+                    message=AIMessageChunk(
+                        content="",
+                        additional_kwargs={
+                            "tool_calls": [
+                                {
+                                    "index": 0,
+                                    "id": "call_BbeyNhCKa6kYLYzrD40NGm3b",
+                                    "function": {
+                                        "arguments": "",
+                                        "name": "get_word_length",
+                                    },
+                                    "type": "function",
+                                }
+                            ]
+                        },
+                    ),
+                ),
+                ChatGenerationChunk(
+                    type="ChatGenerationChunk",
+                    message=AIMessageChunk(
+                        content="",
+                        additional_kwargs={
+                            "tool_calls": [
+                                {
+                                    "index": 0,
+                                    "id": None,
+                                    "function": {
+                                        "arguments": '{"word": "eudca"}',
+                                        "name": None,
+                                    },
+                                    "type": None,
+                                }
+                            ]
+                        },
+                    ),
+                ),
+                ChatGenerationChunk(
+                    type="ChatGenerationChunk",
+                    message=AIMessageChunk(
+                        content="5",
+                        usage_metadata={
+                            "input_tokens": 142,
+                            "output_tokens": 50,
+                            "total_tokens": 192,
+                            "input_token_details": {"audio": 0, "cache_read": 0},
+                            "output_token_details": {"audio": 0, "reasoning": 0},
+                        },
+                    ),
+                    generation_info={"finish_reason": "function_call"},
+                ),
+            ],
+            [
+                ChatGenerationChunk(
+                    text="The word eudca has 5 letters.",
+                    type="ChatGenerationChunk",
+                    message=AIMessageChunk(
+                        content="The word eudca has 5 letters.",
+                        usage_metadata={
+                            "input_tokens": 89,
+                            "output_tokens": 28,
+                            "total_tokens": 117,
+                            "input_token_details": {"audio": 0, "cache_read": 0},
+                            "output_token_details": {"audio": 0, "reasoning": 0},
+                        },
+                    ),
+                ),
+                ChatGenerationChunk(
+                    type="ChatGenerationChunk",
+                    generation_info={"finish_reason": "stop"},
+                    message=AIMessageChunk(content=""),
+                ),
+            ],
+        ]
+    )
+    llm = MockOpenAI(
+        model_name="gpt-3.5-turbo",
+        temperature=0,
+        openai_api_key="badkey",
+    )
+    agent = create_openai_tools_agent(llm, [get_word_length], prompt)
+
+    agent_executor = AgentExecutor(agent=agent, tools=[get_word_length], verbose=True)
+
+    with start_transaction():
+        list(agent_executor.stream({"input": "How many letters in the word eudca"}))
+
+    (event,) = events
+
+    assert event["contexts"]["trace"]["origin"] == "manual"
+    for span in event["spans"]:
+        assert span["origin"] == "auto.ai.langchain"
+
+
+def test_manual_callback_no_duplication(sentry_init):
+    """
+    Test that when a user manually provides a SentryLangchainCallback,
+    the integration doesn't create a duplicate callback.
+    """
+
+    # Track callback instances
+    tracked_callback_instances = set()
+
+    class CallbackTrackingModel(BaseChatModel):
+        """Mock model that tracks callback instances for testing."""
+
+        def _generate(
+            self,
+            messages,
+            stop=None,
+            run_manager=None,
+            **kwargs,
+        ):
+            # Track all SentryLangchainCallback instances
+            if run_manager:
+                for handler in run_manager.handlers:
+                    if isinstance(handler, SentryLangchainCallback):
+                        tracked_callback_instances.add(id(handler))
+
+                for handler in run_manager.inheritable_handlers:
+                    if isinstance(handler, SentryLangchainCallback):
+                        tracked_callback_instances.add(id(handler))
+
+            return ChatResult(
+                generations=[
+                    ChatGenerationChunk(message=AIMessageChunk(content="Hello!"))
+                ],
+                llm_output={},
+            )
+
+        @property
+        def _llm_type(self):
+            return "test_model"
+
+        @property
+        def _identifying_params(self):
+            return {}
+
+    sentry_init(integrations=[LangchainIntegration()])
+
+    # Create a manual SentryLangchainCallback
+    manual_callback = SentryLangchainCallback(
+        max_span_map_size=100, include_prompts=False
+    )
+
+    # Create RunnableConfig with the manual callback
+    config = RunnableConfig(callbacks=[manual_callback])
+
+    # Invoke the model with the config
+    llm = CallbackTrackingModel()
+    llm.invoke("Hello", config)
+
+    # Verify that only ONE SentryLangchainCallback instance was used
+    assert len(tracked_callback_instances) == 1, (
+        f"Expected exactly 1 SentryLangchainCallback instance, "
+        f"but found {len(tracked_callback_instances)}. "
+        f"This indicates callback duplication occurred."
+    )
+
+    # Verify the callback ID matches our manual callback
+    assert id(manual_callback) in tracked_callback_instances
+
+
+def test_span_map_is_instance_variable():
+    """Test that each SentryLangchainCallback instance has its own span_map."""
+    # Create two separate callback instances
+    callback1 = SentryLangchainCallback(max_span_map_size=100, include_prompts=True)
+    callback2 = SentryLangchainCallback(max_span_map_size=100, include_prompts=True)
+
+    # Verify they have different span_map instances
+    assert callback1.span_map is not callback2.span_map, (
+        "span_map should be an instance variable, not shared between instances"
+    )
+
+
+def test_langchain_callback_manager(sentry_init):
+    sentry_init(
+        integrations=[LangchainIntegration()],
+        traces_sample_rate=1.0,
+    )
+    local_manager = BaseCallbackManager(handlers=[])
+
+    with mock.patch("sentry_sdk.integrations.langchain.manager") as mock_manager_module:
+        mock_configure = mock_manager_module._configure
+
+        # Explicitly re-run setup_once, so that mock_manager_module._configure gets patched
+        LangchainIntegration.setup_once()
+
+        callback_manager_cls = Mock()
+
+        mock_manager_module._configure(
+            callback_manager_cls, local_callbacks=local_manager
+        )
+
+        assert mock_configure.call_count == 1
+
+        call_args = mock_configure.call_args
+        assert call_args.args[0] is callback_manager_cls
+
+        passed_manager = call_args.args[2]
+        assert passed_manager is not local_manager
+        assert local_manager.handlers == []
+
+        [handler] = passed_manager.handlers
+        assert isinstance(handler, SentryLangchainCallback)
+
+
+def test_langchain_callback_manager_with_sentry_callback(sentry_init):
+    sentry_init(
+        integrations=[LangchainIntegration()],
+        traces_sample_rate=1.0,
+    )
+    sentry_callback = SentryLangchainCallback(0, False)
+    local_manager = BaseCallbackManager(handlers=[sentry_callback])
+
+    with mock.patch("sentry_sdk.integrations.langchain.manager") as mock_manager_module:
+        mock_configure = mock_manager_module._configure
+
+        # Explicitly re-run setup_once, so that mock_manager_module._configure gets patched
+        LangchainIntegration.setup_once()
+
+        callback_manager_cls = Mock()
+
+        mock_manager_module._configure(
+            callback_manager_cls, local_callbacks=local_manager
+        )
+
+        assert mock_configure.call_count == 1
+
+        call_args = mock_configure.call_args
+        assert call_args.args[0] is callback_manager_cls
+
+        passed_manager = call_args.args[2]
+        assert passed_manager is local_manager
+
+        [handler] = passed_manager.handlers
+        assert handler is sentry_callback
+
+
+def test_langchain_callback_list(sentry_init):
+    sentry_init(
+        integrations=[LangchainIntegration()],
+        traces_sample_rate=1.0,
+    )
+    local_callbacks = []
+
+    with mock.patch("sentry_sdk.integrations.langchain.manager") as mock_manager_module:
+        mock_configure = mock_manager_module._configure
+
+        # Explicitly re-run setup_once, so that mock_manager_module._configure gets patched
+        LangchainIntegration.setup_once()
+
+        callback_manager_cls = Mock()
+
+        mock_manager_module._configure(
+            callback_manager_cls, local_callbacks=local_callbacks
+        )
+
+        assert mock_configure.call_count == 1
+
+        call_args = mock_configure.call_args
+        assert call_args.args[0] is callback_manager_cls
+
+        passed_callbacks = call_args.args[2]
+        assert passed_callbacks is not local_callbacks
+        assert local_callbacks == []
+
+        [handler] = passed_callbacks
+        assert isinstance(handler, SentryLangchainCallback)
+
+
+def test_langchain_callback_list_existing_callback(sentry_init):
+    sentry_init(
+        integrations=[LangchainIntegration()],
+        traces_sample_rate=1.0,
+    )
+    sentry_callback = SentryLangchainCallback(0, False)
+    local_callbacks = [sentry_callback]
+
+    with mock.patch("sentry_sdk.integrations.langchain.manager") as mock_manager_module:
+        mock_configure = mock_manager_module._configure
+
+        # Explicitly re-run setup_once, so that mock_manager_module._configure gets patched
+        LangchainIntegration.setup_once()
+
+        callback_manager_cls = Mock()
+
+        mock_manager_module._configure(
+            callback_manager_cls, local_callbacks=local_callbacks
+        )
+
+        assert mock_configure.call_count == 1
+
+        call_args = mock_configure.call_args
+        assert call_args.args[0] is callback_manager_cls
+
+        passed_callbacks = call_args.args[2]
+        assert passed_callbacks is local_callbacks
+
+        [handler] = passed_callbacks
+        assert handler is sentry_callback
+
+
+def test_tools_integration_in_spans(sentry_init, capture_events):
+    """Test that tools are properly set on spans in actual LangChain integration."""
+    global llm_type
+    llm_type = "openai-chat"
+
+    sentry_init(
+        integrations=[LangchainIntegration(include_prompts=False)],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    prompt = ChatPromptTemplate.from_messages(
+        [
+            ("system", "You are a helpful assistant"),
+            ("user", "{input}"),
+            MessagesPlaceholder(variable_name="agent_scratchpad"),
+        ]
+    )
+
+    global stream_result_mock
+    stream_result_mock = Mock(
+        side_effect=[
+            [
+                ChatGenerationChunk(
+                    type="ChatGenerationChunk",
+                    message=AIMessageChunk(content="Simple response"),
+                ),
+            ]
+        ]
+    )
+
+    llm = MockOpenAI(
+        model_name="gpt-3.5-turbo",
+        temperature=0,
+        openai_api_key="badkey",
+    )
+    agent = create_openai_tools_agent(llm, [get_word_length], prompt)
+    agent_executor = AgentExecutor(agent=agent, tools=[get_word_length], verbose=True)
+
+    with start_transaction():
+        list(agent_executor.stream({"input": "Hello"}))
+
+    # Check that events were captured and contain tools data
+    if events:
+        tx = events[0]
+        spans = tx.get("spans", [])
+
+        # Look for spans that should have tools data
+        tools_found = False
+        for span in spans:
+            span_data = span.get("data", {})
+            if SPANDATA.GEN_AI_REQUEST_AVAILABLE_TOOLS in span_data:
+                tools_found = True
+                tools_data = span_data[SPANDATA.GEN_AI_REQUEST_AVAILABLE_TOOLS]
+                # Verify tools are in the expected format
+                assert isinstance(tools_data, (str, list))  # Could be serialized
+                if isinstance(tools_data, str):
+                    # If serialized as string, should contain tool name
+                    assert "get_word_length" in tools_data
+                else:
+                    # If still a list, verify structure
+                    assert len(tools_data) >= 1
+                    names = [
+                        tool.get("name")
+                        for tool in tools_data
+                        if isinstance(tool, dict)
+                    ]
+                    assert "get_word_length" in names
+
+        # Ensure we found at least one span with tools data
+        assert tools_found, "No spans found with tools data"
+
+
+def test_langchain_integration_with_langchain_core_only(sentry_init, capture_events):
+    """Test that the langchain integration works when langchain.agents.AgentExecutor
+    is not available or langchain is not installed, but langchain-core is.
+    """
+
+    from langchain_core.outputs import LLMResult, Generation
+
+    with patch("sentry_sdk.integrations.langchain.AgentExecutor", None):
+        from sentry_sdk.integrations.langchain import (
+            LangchainIntegration,
+            SentryLangchainCallback,
+        )
+
+        sentry_init(
+            integrations=[LangchainIntegration(include_prompts=True)],
+            traces_sample_rate=1.0,
+            send_default_pii=True,
+        )
+        events = capture_events()
+
+        try:
+            LangchainIntegration.setup_once()
+        except Exception as e:
+            pytest.fail(f"setup_once() failed when AgentExecutor is None: {e}")
+
+        callback = SentryLangchainCallback(max_span_map_size=100, include_prompts=True)
+
+        run_id = "12345678-1234-1234-1234-123456789012"
+        serialized = {"_type": "openai-chat", "model_name": "gpt-3.5-turbo"}
+        prompts = ["What is the capital of France?"]
+
+        with start_transaction():
+            callback.on_llm_start(
+                serialized=serialized,
+                prompts=prompts,
+                run_id=run_id,
+                invocation_params={
+                    "temperature": 0.7,
+                    "max_tokens": 100,
+                    "model": "gpt-3.5-turbo",
+                },
+            )
+
+            response = LLMResult(
+                generations=[[Generation(text="The capital of France is Paris.")]],
+                llm_output={
+                    "token_usage": {
+                        "total_tokens": 25,
+                        "prompt_tokens": 10,
+                        "completion_tokens": 15,
+                    }
+                },
+            )
+            callback.on_llm_end(response=response, run_id=run_id)
+
+        assert len(events) > 0
+        tx = events[0]
+        assert tx["type"] == "transaction"
+
+        llm_spans = [
+            span for span in tx.get("spans", []) if span.get("op") == "gen_ai.pipeline"
+        ]
+        assert len(llm_spans) > 0
+
+        llm_span = llm_spans[0]
+        assert llm_span["description"] == "Langchain LLM call"
+        assert llm_span["data"]["gen_ai.request.model"] == "gpt-3.5-turbo"
+        assert (
+            llm_span["data"]["gen_ai.response.text"]
+            == "The capital of France is Paris."
+        )
+        assert llm_span["data"]["gen_ai.usage.total_tokens"] == 25
+        assert llm_span["data"]["gen_ai.usage.input_tokens"] == 10
+        assert llm_span["data"]["gen_ai.usage.output_tokens"] == 15
+
+
+def test_langchain_message_role_mapping(sentry_init, capture_events):
+    """Test that message roles are properly normalized in langchain integration."""
+    global llm_type
+    llm_type = "openai-chat"
+
+    sentry_init(
+        integrations=[LangchainIntegration(include_prompts=True)],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+    events = capture_events()
+
+    prompt = ChatPromptTemplate.from_messages(
+        [
+            ("system", "You are a helpful assistant"),
+            ("human", "{input}"),
+            MessagesPlaceholder(variable_name="agent_scratchpad"),
+        ]
+    )
+
+    global stream_result_mock
+    stream_result_mock = Mock(
+        side_effect=[
+            [
+                ChatGenerationChunk(
+                    type="ChatGenerationChunk",
+                    message=AIMessageChunk(content="Test response"),
+                ),
+            ]
+        ]
+    )
+
+    llm = MockOpenAI(
+        model_name="gpt-3.5-turbo",
+        temperature=0,
+        openai_api_key="badkey",
+    )
+    agent = create_openai_tools_agent(llm, [get_word_length], prompt)
+    agent_executor = AgentExecutor(agent=agent, tools=[get_word_length], verbose=True)
+
+    # Test input that should trigger message role normalization
+    test_input = "Hello, how are you?"
+
+    with start_transaction():
+        list(agent_executor.stream({"input": test_input}))
+
+    assert len(events) > 0
+    tx = events[0]
+    assert tx["type"] == "transaction"
+
+    # Find spans with gen_ai operation that should have message data
+    gen_ai_spans = [
+        span for span in tx.get("spans", []) if span.get("op", "").startswith("gen_ai")
+    ]
+
+    # Check if any span has message data with normalized roles
+    message_data_found = False
+    for span in gen_ai_spans:
+        span_data = span.get("data", {})
+        if SPANDATA.GEN_AI_REQUEST_MESSAGES in span_data:
+            message_data_found = True
+            messages_data = span_data[SPANDATA.GEN_AI_REQUEST_MESSAGES]
+
+            # Parse the message data (might be JSON string)
+            if isinstance(messages_data, str):
+                try:
+                    messages = json.loads(messages_data)
+                except json.JSONDecodeError:
+                    # If not valid JSON, skip this assertion
+                    continue
+            else:
+                messages = messages_data
+
+            # Verify that the input message is present and contains the test input
+            assert isinstance(messages, list)
+            assert len(messages) > 0
+
+            # The test input should be in one of the messages
+            input_found = False
+            for msg in messages:
+                if isinstance(msg, dict) and test_input in str(msg.get("content", "")):
+                    input_found = True
+                    break
+                elif isinstance(msg, str) and test_input in msg:
+                    input_found = True
+                    break
+
+            assert input_found, (
+                f"Test input '{test_input}' not found in messages: {messages}"
+            )
+            break
+
+    # The message role mapping functionality is primarily tested through the normalization
+    # that happens in the integration code. The fact that we can capture and process
+    # the messages without errors indicates the role mapping is working correctly.
+    assert message_data_found, "No span found with gen_ai request messages data"
+
+
+def test_langchain_message_role_normalization_units():
+    """Test the message role normalization functions directly."""
+    from sentry_sdk.ai.utils import normalize_message_role, normalize_message_roles
+
+    # Test individual role normalization
+    assert normalize_message_role("ai") == "assistant"
+    assert normalize_message_role("human") == "user"
+    assert normalize_message_role("tool_call") == "tool"
+    assert normalize_message_role("system") == "system"
+    assert normalize_message_role("user") == "user"
+    assert normalize_message_role("assistant") == "assistant"
+    assert normalize_message_role("tool") == "tool"
+
+    # Test unknown role (should remain unchanged)
+    assert normalize_message_role("unknown_role") == "unknown_role"
+
+    # Test message list normalization
+    test_messages = [
+        {"role": "human", "content": "Hello"},
+        {"role": "ai", "content": "Hi there!"},
+        {"role": "tool_call", "content": "function_call"},
+        {"role": "system", "content": "You are helpful"},
+        {"content": "Message without role"},
+        "string message",
+    ]
+
+    normalized = normalize_message_roles(test_messages)
+
+    # Verify the original messages are not modified
+    assert test_messages[0]["role"] == "human"  # Original unchanged
+    assert test_messages[1]["role"] == "ai"  # Original unchanged
+
+    # Verify the normalized messages have correct roles
+    assert normalized[0]["role"] == "user"  # human -> user
+    assert normalized[1]["role"] == "assistant"  # ai -> assistant
+    assert normalized[2]["role"] == "tool"  # tool_call -> tool
+    assert normalized[3]["role"] == "system"  # system unchanged
+    assert "role" not in normalized[4]  # Message without role unchanged
+    assert normalized[5] == "string message"  # String message unchanged
+
+
+def test_langchain_message_truncation(sentry_init, capture_events):
+    """Test that large messages are truncated properly in Langchain integration."""
+    from langchain_core.outputs import LLMResult, Generation
+
+    sentry_init(
+        integrations=[LangchainIntegration(include_prompts=True)],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+    events = capture_events()
+
+    callback = SentryLangchainCallback(max_span_map_size=100, include_prompts=True)
+
+    run_id = "12345678-1234-1234-1234-123456789012"
+    serialized = {"_type": "openai-chat", "model_name": "gpt-3.5-turbo"}
+
+    large_content = (
+        "This is a very long message that will exceed our size limits. " * 1000
+    )
+    prompts = [
+        "small message 1",
+        large_content,
+        large_content,
+        "small message 4",
+        "small message 5",
+    ]
+
+    with start_transaction():
+        callback.on_llm_start(
+            serialized=serialized,
+            prompts=prompts,
+            run_id=run_id,
+            invocation_params={
+                "temperature": 0.7,
+                "max_tokens": 100,
+                "model": "gpt-3.5-turbo",
+            },
+        )
+
+        response = LLMResult(
+            generations=[[Generation(text="The response")]],
+            llm_output={
+                "token_usage": {
+                    "total_tokens": 25,
+                    "prompt_tokens": 10,
+                    "completion_tokens": 15,
+                }
+            },
+        )
+        callback.on_llm_end(response=response, run_id=run_id)
+
+    assert len(events) > 0
+    tx = events[0]
+    assert tx["type"] == "transaction"
+
+    llm_spans = [
+        span for span in tx.get("spans", []) if span.get("op") == "gen_ai.pipeline"
+    ]
+    assert len(llm_spans) > 0
+
+    llm_span = llm_spans[0]
+    assert SPANDATA.GEN_AI_REQUEST_MESSAGES in llm_span["data"]
+
+    messages_data = llm_span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES]
+    assert isinstance(messages_data, str)
+
+    parsed_messages = json.loads(messages_data)
+    assert isinstance(parsed_messages, list)
+    assert len(parsed_messages) == 2
+    assert "small message 4" in str(parsed_messages[0])
+    assert "small message 5" in str(parsed_messages[1])
+    assert tx["_meta"]["spans"]["0"]["data"]["gen_ai.request.messages"][""]["len"] == 5
+
+
+@pytest.mark.parametrize(
+    "send_default_pii, include_prompts",
+    [
+        (True, True),
+        (True, False),
+        (False, True),
+        (False, False),
+    ],
+)
+def test_langchain_embeddings_sync(
+    sentry_init, capture_events, send_default_pii, include_prompts
+):
+    """Test that sync embedding methods (embed_documents, embed_query) are properly traced."""
+    try:
+        from langchain_openai import OpenAIEmbeddings
+    except ImportError:
+        pytest.skip("langchain_openai not installed")
+
+    sentry_init(
+        integrations=[LangchainIntegration(include_prompts=include_prompts)],
+        traces_sample_rate=1.0,
+        send_default_pii=send_default_pii,
+    )
+    events = capture_events()
+
+    # Mock the actual API call
+    with mock.patch.object(
+        OpenAIEmbeddings,
+        "embed_documents",
+        wraps=lambda self, texts: [[0.1, 0.2, 0.3] for _ in texts],
+    ) as mock_embed_documents:
+        embeddings = OpenAIEmbeddings(
+            model="text-embedding-ada-002", openai_api_key="test-key"
+        )
+
+        # Force setup to re-run to ensure our mock is wrapped
+        LangchainIntegration.setup_once()
+
+        with start_transaction(name="test_embeddings"):
+            # Test embed_documents
+            result = embeddings.embed_documents(["Hello world", "Test document"])
+
+        assert len(result) == 2
+        mock_embed_documents.assert_called_once()
+
+    # Check captured events
+    assert len(events) >= 1
+    tx = events[0]
+    assert tx["type"] == "transaction"
+
+    # Find embeddings span
+    embeddings_spans = [
+        span for span in tx.get("spans", []) if span.get("op") == "gen_ai.embeddings"
+    ]
+    assert len(embeddings_spans) == 1
+
+    embeddings_span = embeddings_spans[0]
+    assert embeddings_span["description"] == "embeddings text-embedding-ada-002"
+    assert embeddings_span["origin"] == "auto.ai.langchain"
+    assert embeddings_span["data"]["gen_ai.operation.name"] == "embeddings"
+    assert embeddings_span["data"]["gen_ai.request.model"] == "text-embedding-ada-002"
+
+    # Check if input is captured based on PII settings
+    if send_default_pii and include_prompts:
+        assert SPANDATA.GEN_AI_EMBEDDINGS_INPUT in embeddings_span["data"]
+        input_data = embeddings_span["data"][SPANDATA.GEN_AI_EMBEDDINGS_INPUT]
+        # Could be serialized as string
+        if isinstance(input_data, str):
+            assert "Hello world" in input_data
+            assert "Test document" in input_data
+        else:
+            assert "Hello world" in input_data
+            assert "Test document" in input_data
+    else:
+        assert SPANDATA.GEN_AI_EMBEDDINGS_INPUT not in embeddings_span.get("data", {})
+
+
+@pytest.mark.parametrize(
+    "send_default_pii, include_prompts",
+    [
+        (True, True),
+        (False, False),
+    ],
+)
+def test_langchain_embeddings_embed_query(
+    sentry_init, capture_events, send_default_pii, include_prompts
+):
+    """Test that embed_query method is properly traced."""
+    try:
+        from langchain_openai import OpenAIEmbeddings
+    except ImportError:
+        pytest.skip("langchain_openai not installed")
+
+    sentry_init(
+        integrations=[LangchainIntegration(include_prompts=include_prompts)],
+        traces_sample_rate=1.0,
+        send_default_pii=send_default_pii,
+    )
+    events = capture_events()
+
+    # Mock the actual API call
+    with mock.patch.object(
+        OpenAIEmbeddings,
+        "embed_query",
+        wraps=lambda self, text: [0.1, 0.2, 0.3],
+    ) as mock_embed_query:
+        embeddings = OpenAIEmbeddings(
+            model="text-embedding-ada-002", openai_api_key="test-key"
+        )
+
+        # Force setup to re-run to ensure our mock is wrapped
+        LangchainIntegration.setup_once()
+
+        with start_transaction(name="test_embeddings_query"):
+            result = embeddings.embed_query("What is the capital of France?")
+
+        assert len(result) == 3
+        mock_embed_query.assert_called_once()
+
+    # Check captured events
+    assert len(events) >= 1
+    tx = events[0]
+    assert tx["type"] == "transaction"
+
+    # Find embeddings span
+    embeddings_spans = [
+        span for span in tx.get("spans", []) if span.get("op") == "gen_ai.embeddings"
+    ]
+    assert len(embeddings_spans) == 1
+
+    embeddings_span = embeddings_spans[0]
+    assert embeddings_span["data"]["gen_ai.operation.name"] == "embeddings"
+    assert embeddings_span["data"]["gen_ai.request.model"] == "text-embedding-ada-002"
+
+    # Check if input is captured based on PII settings
+    if send_default_pii and include_prompts:
+        assert SPANDATA.GEN_AI_EMBEDDINGS_INPUT in embeddings_span["data"]
+        input_data = embeddings_span["data"][SPANDATA.GEN_AI_EMBEDDINGS_INPUT]
+        # Could be serialized as string
+        if isinstance(input_data, str):
+            assert "What is the capital of France?" in input_data
+        else:
+            assert "What is the capital of France?" in input_data
+    else:
+        assert SPANDATA.GEN_AI_EMBEDDINGS_INPUT not in embeddings_span.get("data", {})
+
+
+@pytest.mark.parametrize(
+    "send_default_pii, include_prompts",
+    [
+        (True, True),
+        (False, False),
+    ],
+)
+@pytest.mark.asyncio
+async def test_langchain_embeddings_async(
+    sentry_init, capture_events, send_default_pii, include_prompts
+):
+    """Test that async embedding methods (aembed_documents, aembed_query) are properly traced."""
+    try:
+        from langchain_openai import OpenAIEmbeddings
+    except ImportError:
+        pytest.skip("langchain_openai not installed")
+
+    sentry_init(
+        integrations=[LangchainIntegration(include_prompts=include_prompts)],
+        traces_sample_rate=1.0,
+        send_default_pii=send_default_pii,
+    )
+    events = capture_events()
+
+    async def mock_aembed_documents(self, texts):
+        return [[0.1, 0.2, 0.3] for _ in texts]
+
+    # Mock the actual API call
+    with mock.patch.object(
+        OpenAIEmbeddings,
+        "aembed_documents",
+        wraps=mock_aembed_documents,
+    ) as mock_aembed:
+        embeddings = OpenAIEmbeddings(
+            model="text-embedding-ada-002", openai_api_key="test-key"
+        )
+
+        # Force setup to re-run to ensure our mock is wrapped
+        LangchainIntegration.setup_once()
+
+        with start_transaction(name="test_async_embeddings"):
+            result = await embeddings.aembed_documents(
+                ["Async hello", "Async test document"]
+            )
+
+        assert len(result) == 2
+        mock_aembed.assert_called_once()
+
+    # Check captured events
+    assert len(events) >= 1
+    tx = events[0]
+    assert tx["type"] == "transaction"
+
+    # Find embeddings span
+    embeddings_spans = [
+        span for span in tx.get("spans", []) if span.get("op") == "gen_ai.embeddings"
+    ]
+    assert len(embeddings_spans) == 1
+
+    embeddings_span = embeddings_spans[0]
+    assert embeddings_span["description"] == "embeddings text-embedding-ada-002"
+    assert embeddings_span["origin"] == "auto.ai.langchain"
+    assert embeddings_span["data"]["gen_ai.operation.name"] == "embeddings"
+    assert embeddings_span["data"]["gen_ai.request.model"] == "text-embedding-ada-002"
+
+    # Check if input is captured based on PII settings
+    if send_default_pii and include_prompts:
+        assert SPANDATA.GEN_AI_EMBEDDINGS_INPUT in embeddings_span["data"]
+        input_data = embeddings_span["data"][SPANDATA.GEN_AI_EMBEDDINGS_INPUT]
+        # Could be serialized as string
+        if isinstance(input_data, str):
+            assert "Async hello" in input_data or "Async test document" in input_data
+        else:
+            assert "Async hello" in input_data or "Async test document" in input_data
+    else:
+        assert SPANDATA.GEN_AI_EMBEDDINGS_INPUT not in embeddings_span.get("data", {})
+
+
+@pytest.mark.asyncio
+async def test_langchain_embeddings_aembed_query(sentry_init, capture_events):
+    """Test that aembed_query method is properly traced."""
+    try:
+        from langchain_openai import OpenAIEmbeddings
+    except ImportError:
+        pytest.skip("langchain_openai not installed")
+
+    sentry_init(
+        integrations=[LangchainIntegration(include_prompts=True)],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+    events = capture_events()
+
+    async def mock_aembed_query(self, text):
+        return [0.1, 0.2, 0.3]
+
+    # Mock the actual API call
+    with mock.patch.object(
+        OpenAIEmbeddings,
+        "aembed_query",
+        wraps=mock_aembed_query,
+    ) as mock_aembed:
+        embeddings = OpenAIEmbeddings(
+            model="text-embedding-ada-002", openai_api_key="test-key"
+        )
+
+        # Force setup to re-run to ensure our mock is wrapped
+        LangchainIntegration.setup_once()
+
+        with start_transaction(name="test_async_embeddings_query"):
+            result = await embeddings.aembed_query("Async query test")
+
+        assert len(result) == 3
+        mock_aembed.assert_called_once()
+
+    # Check captured events
+    assert len(events) >= 1
+    tx = events[0]
+    assert tx["type"] == "transaction"
+
+    # Find embeddings span
+    embeddings_spans = [
+        span for span in tx.get("spans", []) if span.get("op") == "gen_ai.embeddings"
+    ]
+    assert len(embeddings_spans) == 1
+
+    embeddings_span = embeddings_spans[0]
+    assert embeddings_span["data"]["gen_ai.operation.name"] == "embeddings"
+    assert embeddings_span["data"]["gen_ai.request.model"] == "text-embedding-ada-002"
+
+    # Check if input is captured
+    assert SPANDATA.GEN_AI_EMBEDDINGS_INPUT in embeddings_span["data"]
+    input_data = embeddings_span["data"][SPANDATA.GEN_AI_EMBEDDINGS_INPUT]
+    # Could be serialized as string
+    if isinstance(input_data, str):
+        assert "Async query test" in input_data
+    else:
+        assert "Async query test" in input_data
+
+
+def test_langchain_embeddings_no_model_name(sentry_init, capture_events):
+    """Test embeddings when model name is not available."""
+    try:
+        from langchain_openai import OpenAIEmbeddings
+    except ImportError:
+        pytest.skip("langchain_openai not installed")
+
+    sentry_init(
+        integrations=[LangchainIntegration(include_prompts=False)],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    # Mock the actual API call and remove model attribute
+    with mock.patch.object(
+        OpenAIEmbeddings,
+        "embed_documents",
+        wraps=lambda self, texts: [[0.1, 0.2, 0.3] for _ in texts],
+    ):
+        embeddings = OpenAIEmbeddings(openai_api_key="test-key")
+        # Remove model attribute to test fallback
+        delattr(embeddings, "model")
+        if hasattr(embeddings, "model_name"):
+            delattr(embeddings, "model_name")
+
+        # Force setup to re-run to ensure our mock is wrapped
+        LangchainIntegration.setup_once()
+
+        with start_transaction(name="test_embeddings_no_model"):
+            embeddings.embed_documents(["Test"])
+
+    # Check captured events
+    assert len(events) >= 1
+    tx = events[0]
+    assert tx["type"] == "transaction"
+
+    # Find embeddings span
+    embeddings_spans = [
+        span for span in tx.get("spans", []) if span.get("op") == "gen_ai.embeddings"
+    ]
+    assert len(embeddings_spans) == 1
+
+    embeddings_span = embeddings_spans[0]
+    assert embeddings_span["description"] == "embeddings"
+    assert embeddings_span["data"]["gen_ai.operation.name"] == "embeddings"
+    # Model name should not be set if not available
+    assert (
+        "gen_ai.request.model" not in embeddings_span["data"]
+        or embeddings_span["data"]["gen_ai.request.model"] is None
+    )
+
+
+def test_langchain_embeddings_integration_disabled(sentry_init, capture_events):
+    """Test that embeddings are not traced when integration is disabled."""
+    try:
+        from langchain_openai import OpenAIEmbeddings
+    except ImportError:
+        pytest.skip("langchain_openai not installed")
+
+    # Initialize without LangchainIntegration
+    sentry_init(traces_sample_rate=1.0)
+    events = capture_events()
+
+    with mock.patch.object(
+        OpenAIEmbeddings,
+        "embed_documents",
+        return_value=[[0.1, 0.2, 0.3]],
+    ):
+        embeddings = OpenAIEmbeddings(
+            model="text-embedding-ada-002", openai_api_key="test-key"
+        )
+
+        with start_transaction(name="test_embeddings_disabled"):
+            embeddings.embed_documents(["Test"])
+
+    # Check that no embeddings spans were created
+    if events:
+        tx = events[0]
+        embeddings_spans = [
+            span
+            for span in tx.get("spans", [])
+            if span.get("op") == "gen_ai.embeddings"
+        ]
+        # Should be empty since integration is disabled
+        assert len(embeddings_spans) == 0
+
+
+def test_langchain_embeddings_multiple_providers(sentry_init, capture_events):
+    """Test that embeddings work with different providers."""
+    try:
+        from langchain_openai import OpenAIEmbeddings, AzureOpenAIEmbeddings
+    except ImportError:
+        pytest.skip("langchain_openai not installed")
+
+    sentry_init(
+        integrations=[LangchainIntegration(include_prompts=True)],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+    events = capture_events()
+
+    # Mock both providers
+    with mock.patch.object(
+        OpenAIEmbeddings,
+        "embed_documents",
+        wraps=lambda self, texts: [[0.1, 0.2, 0.3] for _ in texts],
+    ), mock.patch.object(
+        AzureOpenAIEmbeddings,
+        "embed_documents",
+        wraps=lambda self, texts: [[0.4, 0.5, 0.6] for _ in texts],
+    ):
+        openai_embeddings = OpenAIEmbeddings(
+            model="text-embedding-ada-002", openai_api_key="test-key"
+        )
+        azure_embeddings = AzureOpenAIEmbeddings(
+            model="text-embedding-ada-002",
+            azure_endpoint="https://test.openai.azure.com/",
+            openai_api_key="test-key",
+        )
+
+        # Force setup to re-run
+        LangchainIntegration.setup_once()
+
+        with start_transaction(name="test_multiple_providers"):
+            openai_embeddings.embed_documents(["OpenAI test"])
+            azure_embeddings.embed_documents(["Azure test"])
+
+    # Check captured events
+    assert len(events) >= 1
+    tx = events[0]
+    assert tx["type"] == "transaction"
+
+    # Find embeddings spans
+    embeddings_spans = [
+        span for span in tx.get("spans", []) if span.get("op") == "gen_ai.embeddings"
+    ]
+    # Should have 2 spans, one for each provider
+    assert len(embeddings_spans) == 2
+
+    # Verify both spans have proper data
+    for span in embeddings_spans:
+        assert span["data"]["gen_ai.operation.name"] == "embeddings"
+        assert span["data"]["gen_ai.request.model"] == "text-embedding-ada-002"
+        assert SPANDATA.GEN_AI_EMBEDDINGS_INPUT in span["data"]
+
+
+def test_langchain_embeddings_error_handling(sentry_init, capture_events):
+    """Test that errors in embeddings are properly captured."""
+    try:
+        from langchain_openai import OpenAIEmbeddings
+    except ImportError:
+        pytest.skip("langchain_openai not installed")
+
+    sentry_init(
+        integrations=[LangchainIntegration(include_prompts=True)],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+    events = capture_events()
+
+    # Mock the API call to raise an error
+    with mock.patch.object(
+        OpenAIEmbeddings,
+        "embed_documents",
+        side_effect=ValueError("API error"),
+    ):
+        embeddings = OpenAIEmbeddings(
+            model="text-embedding-ada-002", openai_api_key="test-key"
+        )
+
+        # Force setup to re-run
+        LangchainIntegration.setup_once()
+
+        with start_transaction(name="test_embeddings_error"):
+            with pytest.raises(ValueError):
+                embeddings.embed_documents(["Test"])
+
+    # The error should be captured
+    assert len(events) >= 1
+    # We should have both the transaction and potentially an error event
+    [e for e in events if e.get("level") == "error"]
+    # Note: errors might not be auto-captured depending on SDK settings,
+    # but the span should still be created
+
+
+def test_langchain_embeddings_multiple_calls(sentry_init, capture_events):
+    """Test that multiple embeddings calls within a transaction are all traced."""
+    try:
+        from langchain_openai import OpenAIEmbeddings
+    except ImportError:
+        pytest.skip("langchain_openai not installed")
+
+    sentry_init(
+        integrations=[LangchainIntegration(include_prompts=True)],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+    events = capture_events()
+
+    # Mock the actual API calls
+    with mock.patch.object(
+        OpenAIEmbeddings,
+        "embed_documents",
+        wraps=lambda self, texts: [[0.1, 0.2, 0.3] for _ in texts],
+    ), mock.patch.object(
+        OpenAIEmbeddings,
+        "embed_query",
+        wraps=lambda self, text: [0.4, 0.5, 0.6],
+    ):
+        embeddings = OpenAIEmbeddings(
+            model="text-embedding-ada-002", openai_api_key="test-key"
+        )
+
+        # Force setup to re-run
+        LangchainIntegration.setup_once()
+
+        with start_transaction(name="test_multiple_embeddings"):
+            # Call embed_documents
+            embeddings.embed_documents(["First batch", "Second batch"])
+            # Call embed_query
+            embeddings.embed_query("Single query")
+            # Call embed_documents again
+            embeddings.embed_documents(["Third batch"])
+
+    # Check captured events
+    assert len(events) >= 1
+    tx = events[0]
+    assert tx["type"] == "transaction"
+
+    # Find embeddings spans - should have 3 (2 embed_documents + 1 embed_query)
+    embeddings_spans = [
+        span for span in tx.get("spans", []) if span.get("op") == "gen_ai.embeddings"
+    ]
+    assert len(embeddings_spans) == 3
+
+    # Verify all spans have proper data
+    for span in embeddings_spans:
+        assert span["data"]["gen_ai.operation.name"] == "embeddings"
+        assert span["data"]["gen_ai.request.model"] == "text-embedding-ada-002"
+        assert SPANDATA.GEN_AI_EMBEDDINGS_INPUT in span["data"]
+
+    # Verify the input data is different for each span
+    input_data_list = [
+        span["data"][SPANDATA.GEN_AI_EMBEDDINGS_INPUT] for span in embeddings_spans
+    ]
+    # They should all be different (different inputs)
+    assert len(set(str(data) for data in input_data_list)) == 3
+
+
+def test_langchain_embeddings_span_hierarchy(sentry_init, capture_events):
+    """Test that embeddings spans are properly nested within parent spans."""
+    try:
+        from langchain_openai import OpenAIEmbeddings
+    except ImportError:
+        pytest.skip("langchain_openai not installed")
+
+    sentry_init(
+        integrations=[LangchainIntegration(include_prompts=True)],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+    events = capture_events()
+
+    # Mock the actual API call
+    with mock.patch.object(
+        OpenAIEmbeddings,
+        "embed_documents",
+        wraps=lambda self, texts: [[0.1, 0.2, 0.3] for _ in texts],
+    ):
+        embeddings = OpenAIEmbeddings(
+            model="text-embedding-ada-002", openai_api_key="test-key"
+        )
+
+        # Force setup to re-run
+        LangchainIntegration.setup_once()
+
+        with start_transaction(name="test_span_hierarchy"):
+            with sentry_sdk.start_span(op="custom", name="custom operation"):
+                embeddings.embed_documents(["Test within custom span"])
+
+    # Check captured events
+    assert len(events) >= 1
+    tx = events[0]
+    assert tx["type"] == "transaction"
+
+    # Find all spans
+    embeddings_spans = [
+        span for span in tx.get("spans", []) if span.get("op") == "gen_ai.embeddings"
+    ]
+    custom_spans = [span for span in tx.get("spans", []) if span.get("op") == "custom"]
+
+    assert len(embeddings_spans) == 1
+    assert len(custom_spans) == 1
+
+    # Both spans should exist
+    embeddings_span = embeddings_spans[0]
+    custom_span = custom_spans[0]
+
+    assert embeddings_span["data"]["gen_ai.operation.name"] == "embeddings"
+    assert custom_span["description"] == "custom operation"
+
+
+def test_langchain_embeddings_with_list_and_string_inputs(sentry_init, capture_events):
+    """Test that embeddings correctly handle both list and string inputs."""
+    try:
+        from langchain_openai import OpenAIEmbeddings
+    except ImportError:
+        pytest.skip("langchain_openai not installed")
+
+    sentry_init(
+        integrations=[LangchainIntegration(include_prompts=True)],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+    events = capture_events()
+
+    # Mock the actual API calls
+    with mock.patch.object(
+        OpenAIEmbeddings,
+        "embed_documents",
+        wraps=lambda self, texts: [[0.1, 0.2, 0.3] for _ in texts],
+    ), mock.patch.object(
+        OpenAIEmbeddings,
+        "embed_query",
+        wraps=lambda self, text: [0.4, 0.5, 0.6],
+    ):
+        embeddings = OpenAIEmbeddings(
+            model="text-embedding-ada-002", openai_api_key="test-key"
+        )
+
+        # Force setup to re-run
+        LangchainIntegration.setup_once()
+
+        with start_transaction(name="test_input_types"):
+            # embed_documents takes a list
+            embeddings.embed_documents(["List item 1", "List item 2", "List item 3"])
+            # embed_query takes a string
+            embeddings.embed_query("Single string query")
+
+    # Check captured events
+    assert len(events) >= 1
+    tx = events[0]
+    assert tx["type"] == "transaction"
+
+    # Find embeddings spans
+    embeddings_spans = [
+        span for span in tx.get("spans", []) if span.get("op") == "gen_ai.embeddings"
+    ]
+    assert len(embeddings_spans) == 2
+
+    # Both should have input data captured as lists
+    for span in embeddings_spans:
+        assert SPANDATA.GEN_AI_EMBEDDINGS_INPUT in span["data"]
+        input_data = span["data"][SPANDATA.GEN_AI_EMBEDDINGS_INPUT]
+        # Input should be normalized to list format
+        if isinstance(input_data, str):
+            # If serialized, should contain the input text
+            assert "List item" in input_data or "Single string query" in input_data, (
+                f"Expected input text in serialized data: {input_data}"
+            )
+
+
+@pytest.mark.parametrize(
+    "response_metadata_model,expected_model",
+    [
+        ("gpt-3.5-turbo", "gpt-3.5-turbo"),
+        (None, None),
+    ],
+)
+def test_langchain_response_model_extraction(
+    sentry_init,
+    capture_events,
+    response_metadata_model,
+    expected_model,
+):
+    sentry_init(
+        integrations=[LangchainIntegration(include_prompts=True)],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+    events = capture_events()
+
+    callback = SentryLangchainCallback(max_span_map_size=100, include_prompts=True)
+
+    run_id = "test-response-model-uuid"
+    serialized = {"_type": "openai-chat", "model_name": "gpt-3.5-turbo"}
+    prompts = ["Test prompt"]
+
+    with start_transaction():
+        callback.on_llm_start(
+            serialized=serialized,
+            prompts=prompts,
+            run_id=run_id,
+            invocation_params={"model": "gpt-3.5-turbo"},
+        )
+
+        response_metadata = {"model_name": response_metadata_model}
+        message = AIMessageChunk(
+            content="Test response", response_metadata=response_metadata
+        )
+
+        generation = Mock(text="Test response", message=message)
+        response = Mock(generations=[[generation]])
+        callback.on_llm_end(response=response, run_id=run_id)
+
+    assert len(events) > 0
+    tx = events[0]
+    assert tx["type"] == "transaction"
+
+    llm_spans = [
+        span for span in tx.get("spans", []) if span.get("op") == "gen_ai.pipeline"
+    ]
+    assert len(llm_spans) > 0
+
+    llm_span = llm_spans[0]
+
+    if expected_model is not None:
+        assert SPANDATA.GEN_AI_RESPONSE_MODEL in llm_span["data"]
+        assert llm_span["data"][SPANDATA.GEN_AI_RESPONSE_MODEL] == expected_model
+    else:
+        assert SPANDATA.GEN_AI_RESPONSE_MODEL not in llm_span.get("data", {})
diff --git a/tests/integrations/langgraph/__init__.py b/tests/integrations/langgraph/__init__.py
new file mode 100644
index 0000000000..b7dd1cb562
--- /dev/null
+++ b/tests/integrations/langgraph/__init__.py
@@ -0,0 +1,3 @@
+import pytest
+
+pytest.importorskip("langgraph")
diff --git a/tests/integrations/langgraph/test_langgraph.py b/tests/integrations/langgraph/test_langgraph.py
new file mode 100644
index 0000000000..99ab216957
--- /dev/null
+++ b/tests/integrations/langgraph/test_langgraph.py
@@ -0,0 +1,1389 @@
+import asyncio
+import sys
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from sentry_sdk import start_transaction
+from sentry_sdk.consts import SPANDATA, OP
+
+
+def mock_langgraph_imports():
+    """Mock langgraph modules to prevent import errors."""
+    mock_state_graph = MagicMock()
+    mock_pregel = MagicMock()
+
+    langgraph_graph_mock = MagicMock()
+    langgraph_graph_mock.StateGraph = mock_state_graph
+
+    langgraph_pregel_mock = MagicMock()
+    langgraph_pregel_mock.Pregel = mock_pregel
+
+    sys.modules["langgraph"] = MagicMock()
+    sys.modules["langgraph.graph"] = langgraph_graph_mock
+    sys.modules["langgraph.pregel"] = langgraph_pregel_mock
+
+    return mock_state_graph, mock_pregel
+
+
+mock_state_graph, mock_pregel = mock_langgraph_imports()
+
+from sentry_sdk.integrations.langgraph import (  # noqa: E402
+    LanggraphIntegration,
+    _parse_langgraph_messages,
+    _wrap_state_graph_compile,
+    _wrap_pregel_invoke,
+    _wrap_pregel_ainvoke,
+)
+
+
+class MockStateGraph:
+    def __init__(self, schema=None):
+        self.name = "test_graph"
+        self.schema = schema
+        self._compiled_graph = None
+
+    def compile(self, *args, **kwargs):
+        compiled = MockCompiledGraph(self.name)
+        compiled.graph = self
+        return compiled
+
+
+class MockCompiledGraph:
+    def __init__(self, name="test_graph"):
+        self.name = name
+        self._graph = None
+
+    def get_graph(self):
+        return MockGraphRepresentation()
+
+    def invoke(self, state, config=None):
+        return {"messages": [MockMessage("Response from graph")]}
+
+    async def ainvoke(self, state, config=None):
+        return {"messages": [MockMessage("Async response from graph")]}
+
+
+class MockGraphRepresentation:
+    def __init__(self):
+        self.nodes = {"tools": MockToolsNode()}
+
+
+class MockToolsNode:
+    def __init__(self):
+        self.data = MockToolsData()
+
+
+class MockToolsData:
+    def __init__(self):
+        self.tools_by_name = {
+            "search_tool": MockTool("search_tool"),
+            "calculator": MockTool("calculator"),
+        }
+
+
+class MockTool:
+    def __init__(self, name):
+        self.name = name
+
+
+class MockMessage:
+    def __init__(
+        self,
+        content,
+        name=None,
+        tool_calls=None,
+        function_call=None,
+        role=None,
+        type=None,
+        response_metadata=None,
+    ):
+        self.content = content
+        self.name = name
+        self.tool_calls = tool_calls
+        self.function_call = function_call
+        self.role = role
+        # The integration uses getattr(message, "type", None) for the role in _normalize_langgraph_message
+        # Set default type based on name if type not explicitly provided
+        if type is None and name in ["assistant", "ai", "user", "system", "function"]:
+            self.type = name
+        else:
+            self.type = type
+        self.response_metadata = response_metadata
+
+
+class MockPregelInstance:
+    def __init__(self, name="test_pregel"):
+        self.name = name
+        self.graph_name = name
+
+    def invoke(self, state, config=None):
+        return {"messages": [MockMessage("Pregel response")]}
+
+    async def ainvoke(self, state, config=None):
+        return {"messages": [MockMessage("Async Pregel response")]}
+
+
+def test_langgraph_integration_init():
+    """Test LanggraphIntegration initialization with different parameters."""
+    integration = LanggraphIntegration()
+    assert integration.include_prompts is True
+    assert integration.identifier == "langgraph"
+    assert integration.origin == "auto.ai.langgraph"
+
+    integration = LanggraphIntegration(include_prompts=False)
+    assert integration.include_prompts is False
+    assert integration.identifier == "langgraph"
+    assert integration.origin == "auto.ai.langgraph"
+
+
+@pytest.mark.parametrize(
+    "send_default_pii, include_prompts",
+    [
+        (True, True),
+        (True, False),
+        (False, True),
+        (False, False),
+    ],
+)
+def test_state_graph_compile(
+    sentry_init, capture_events, send_default_pii, include_prompts
+):
+    """Test StateGraph.compile() wrapper creates proper create_agent span."""
+    sentry_init(
+        integrations=[LanggraphIntegration(include_prompts=include_prompts)],
+        traces_sample_rate=1.0,
+        send_default_pii=send_default_pii,
+    )
+    events = capture_events()
+    graph = MockStateGraph()
+
+    def original_compile(self, *args, **kwargs):
+        return MockCompiledGraph(self.name)
+
+    with patch("sentry_sdk.integrations.langgraph.StateGraph"):
+        with start_transaction():
+            wrapped_compile = _wrap_state_graph_compile(original_compile)
+            compiled_graph = wrapped_compile(
+                graph, model="test-model", checkpointer=None
+            )
+
+    assert compiled_graph is not None
+    assert compiled_graph.name == "test_graph"
+
+    tx = events[0]
+    assert tx["type"] == "transaction"
+
+    agent_spans = [span for span in tx["spans"] if span["op"] == OP.GEN_AI_CREATE_AGENT]
+    assert len(agent_spans) == 1
+
+    agent_span = agent_spans[0]
+    assert agent_span["description"] == "create_agent test_graph"
+    assert agent_span["origin"] == "auto.ai.langgraph"
+    assert agent_span["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "create_agent"
+    assert agent_span["data"][SPANDATA.GEN_AI_AGENT_NAME] == "test_graph"
+    assert agent_span["data"][SPANDATA.GEN_AI_REQUEST_MODEL] == "test-model"
+    assert SPANDATA.GEN_AI_REQUEST_AVAILABLE_TOOLS in agent_span["data"]
+
+    tools_data = agent_span["data"][SPANDATA.GEN_AI_REQUEST_AVAILABLE_TOOLS]
+    assert tools_data == ["search_tool", "calculator"]
+    assert len(tools_data) == 2
+    assert "search_tool" in tools_data
+    assert "calculator" in tools_data
+
+
+@pytest.mark.parametrize(
+    "send_default_pii, include_prompts",
+    [
+        (True, True),
+        (True, False),
+        (False, True),
+        (False, False),
+    ],
+)
+def test_pregel_invoke(sentry_init, capture_events, send_default_pii, include_prompts):
+    """Test Pregel.invoke() wrapper creates proper invoke_agent span."""
+    sentry_init(
+        integrations=[LanggraphIntegration(include_prompts=include_prompts)],
+        traces_sample_rate=1.0,
+        send_default_pii=send_default_pii,
+    )
+    events = capture_events()
+
+    test_state = {
+        "messages": [
+            MockMessage("Hello, can you help me?", name="user"),
+            MockMessage("Of course! How can I assist you?", name="assistant"),
+        ]
+    }
+
+    pregel = MockPregelInstance("test_graph")
+
+    expected_assistant_response = "I'll help you with that task!"
+    expected_tool_calls = [
+        {
+            "id": "call_test_123",
+            "type": "function",
+            "function": {"name": "search_tool", "arguments": '{"query": "help"}'},
+        }
+    ]
+
+    def original_invoke(self, *args, **kwargs):
+        input_messages = args[0].get("messages", [])
+        new_messages = input_messages + [
+            MockMessage(
+                content=expected_assistant_response,
+                name="assistant",
+                tool_calls=expected_tool_calls,
+            )
+        ]
+        return {"messages": new_messages}
+
+    with start_transaction():
+        wrapped_invoke = _wrap_pregel_invoke(original_invoke)
+        result = wrapped_invoke(pregel, test_state)
+
+    assert result is not None
+
+    tx = events[0]
+    assert tx["type"] == "transaction"
+
+    invoke_spans = [
+        span for span in tx["spans"] if span["op"] == OP.GEN_AI_INVOKE_AGENT
+    ]
+    assert len(invoke_spans) == 1
+
+    invoke_span = invoke_spans[0]
+    assert invoke_span["description"] == "invoke_agent test_graph"
+    assert invoke_span["origin"] == "auto.ai.langgraph"
+    assert invoke_span["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "invoke_agent"
+    assert invoke_span["data"][SPANDATA.GEN_AI_PIPELINE_NAME] == "test_graph"
+    assert invoke_span["data"][SPANDATA.GEN_AI_AGENT_NAME] == "test_graph"
+
+    if send_default_pii and include_prompts:
+        assert SPANDATA.GEN_AI_REQUEST_MESSAGES in invoke_span["data"]
+        assert SPANDATA.GEN_AI_RESPONSE_TEXT in invoke_span["data"]
+
+        request_messages = invoke_span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES]
+
+        if isinstance(request_messages, str):
+            import json
+
+            request_messages = json.loads(request_messages)
+        assert len(request_messages) == 2
+        assert request_messages[0]["content"] == "Hello, can you help me?"
+        assert request_messages[1]["content"] == "Of course! How can I assist you?"
+
+        response_text = invoke_span["data"][SPANDATA.GEN_AI_RESPONSE_TEXT]
+        assert response_text == expected_assistant_response
+
+        assert SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS in invoke_span["data"]
+        tool_calls_data = invoke_span["data"][SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS]
+        if isinstance(tool_calls_data, str):
+            import json
+
+            tool_calls_data = json.loads(tool_calls_data)
+
+        assert len(tool_calls_data) == 1
+        assert tool_calls_data[0]["id"] == "call_test_123"
+        assert tool_calls_data[0]["function"]["name"] == "search_tool"
+    else:
+        assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in invoke_span.get("data", {})
+        assert SPANDATA.GEN_AI_RESPONSE_TEXT not in invoke_span.get("data", {})
+        assert SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS not in invoke_span.get("data", {})
+
+
+@pytest.mark.parametrize(
+    "send_default_pii, include_prompts",
+    [
+        (True, True),
+        (True, False),
+        (False, True),
+        (False, False),
+    ],
+)
+def test_pregel_ainvoke(sentry_init, capture_events, send_default_pii, include_prompts):
+    """Test Pregel.ainvoke() async wrapper creates proper invoke_agent span."""
+    sentry_init(
+        integrations=[LanggraphIntegration(include_prompts=include_prompts)],
+        traces_sample_rate=1.0,
+        send_default_pii=send_default_pii,
+    )
+    events = capture_events()
+    test_state = {"messages": [MockMessage("What's the weather like?", name="user")]}
+    pregel = MockPregelInstance("async_graph")
+
+    expected_assistant_response = "It's sunny and 72°F today!"
+    expected_tool_calls = [
+        {
+            "id": "call_weather_456",
+            "type": "function",
+            "function": {"name": "get_weather", "arguments": '{"location": "current"}'},
+        }
+    ]
+
+    async def original_ainvoke(self, *args, **kwargs):
+        input_messages = args[0].get("messages", [])
+        new_messages = input_messages + [
+            MockMessage(
+                content=expected_assistant_response,
+                name="assistant",
+                tool_calls=expected_tool_calls,
+            )
+        ]
+        return {"messages": new_messages}
+
+    async def run_test():
+        with start_transaction():
+            wrapped_ainvoke = _wrap_pregel_ainvoke(original_ainvoke)
+            result = await wrapped_ainvoke(pregel, test_state)
+            return result
+
+    result = asyncio.run(run_test())
+    assert result is not None
+
+    tx = events[0]
+    assert tx["type"] == "transaction"
+
+    invoke_spans = [
+        span for span in tx["spans"] if span["op"] == OP.GEN_AI_INVOKE_AGENT
+    ]
+    assert len(invoke_spans) == 1
+
+    invoke_span = invoke_spans[0]
+    assert invoke_span["description"] == "invoke_agent async_graph"
+    assert invoke_span["origin"] == "auto.ai.langgraph"
+    assert invoke_span["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "invoke_agent"
+    assert invoke_span["data"][SPANDATA.GEN_AI_PIPELINE_NAME] == "async_graph"
+    assert invoke_span["data"][SPANDATA.GEN_AI_AGENT_NAME] == "async_graph"
+
+    if send_default_pii and include_prompts:
+        assert SPANDATA.GEN_AI_REQUEST_MESSAGES in invoke_span["data"]
+        assert SPANDATA.GEN_AI_RESPONSE_TEXT in invoke_span["data"]
+
+        response_text = invoke_span["data"][SPANDATA.GEN_AI_RESPONSE_TEXT]
+        assert response_text == expected_assistant_response
+
+        assert SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS in invoke_span["data"]
+        tool_calls_data = invoke_span["data"][SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS]
+        if isinstance(tool_calls_data, str):
+            import json
+
+            tool_calls_data = json.loads(tool_calls_data)
+
+        assert len(tool_calls_data) == 1
+        assert tool_calls_data[0]["id"] == "call_weather_456"
+        assert tool_calls_data[0]["function"]["name"] == "get_weather"
+    else:
+        assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in invoke_span.get("data", {})
+        assert SPANDATA.GEN_AI_RESPONSE_TEXT not in invoke_span.get("data", {})
+        assert SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS not in invoke_span.get("data", {})
+
+
+def test_pregel_invoke_error(sentry_init, capture_events):
+    """Test error handling during graph execution."""
+    sentry_init(
+        integrations=[LanggraphIntegration(include_prompts=True)],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+    events = capture_events()
+    test_state = {"messages": [MockMessage("This will fail")]}
+    pregel = MockPregelInstance("error_graph")
+
+    def original_invoke(self, *args, **kwargs):
+        raise Exception("Graph execution failed")
+
+    with start_transaction(), pytest.raises(Exception, match="Graph execution failed"):
+        wrapped_invoke = _wrap_pregel_invoke(original_invoke)
+        wrapped_invoke(pregel, test_state)
+
+    tx = events[0]
+    invoke_spans = [
+        span for span in tx["spans"] if span["op"] == OP.GEN_AI_INVOKE_AGENT
+    ]
+    assert len(invoke_spans) == 1
+
+    invoke_span = invoke_spans[0]
+    assert invoke_span.get("status") == "internal_error"
+    assert invoke_span.get("tags", {}).get("status") == "internal_error"
+
+
+def test_pregel_ainvoke_error(sentry_init, capture_events):
+    """Test error handling during async graph execution."""
+    sentry_init(
+        integrations=[LanggraphIntegration(include_prompts=True)],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+    events = capture_events()
+    test_state = {"messages": [MockMessage("This will fail async")]}
+    pregel = MockPregelInstance("async_error_graph")
+
+    async def original_ainvoke(self, *args, **kwargs):
+        raise Exception("Async graph execution failed")
+
+    async def run_error_test():
+        with start_transaction(), pytest.raises(
+            Exception, match="Async graph execution failed"
+        ):
+            wrapped_ainvoke = _wrap_pregel_ainvoke(original_ainvoke)
+            await wrapped_ainvoke(pregel, test_state)
+
+    asyncio.run(run_error_test())
+
+    tx = events[0]
+    invoke_spans = [
+        span for span in tx["spans"] if span["op"] == OP.GEN_AI_INVOKE_AGENT
+    ]
+    assert len(invoke_spans) == 1
+
+    invoke_span = invoke_spans[0]
+    assert invoke_span.get("status") == "internal_error"
+    assert invoke_span.get("tags", {}).get("status") == "internal_error"
+
+
+def test_span_origin(sentry_init, capture_events):
+    """Test that span origins are correctly set."""
+    sentry_init(
+        integrations=[LanggraphIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    graph = MockStateGraph()
+
+    def original_compile(self, *args, **kwargs):
+        return MockCompiledGraph(self.name)
+
+    with start_transaction():
+        from sentry_sdk.integrations.langgraph import _wrap_state_graph_compile
+
+        wrapped_compile = _wrap_state_graph_compile(original_compile)
+        wrapped_compile(graph)
+
+    tx = events[0]
+    assert tx["contexts"]["trace"]["origin"] == "manual"
+
+    for span in tx["spans"]:
+        assert span["origin"] == "auto.ai.langgraph"
+
+
+@pytest.mark.parametrize("graph_name", ["my_graph", None, ""])
+def test_pregel_invoke_with_different_graph_names(
+    sentry_init, capture_events, graph_name
+):
+    """Test Pregel.invoke() with different graph name scenarios."""
+    sentry_init(
+        integrations=[LanggraphIntegration()],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+    events = capture_events()
+
+    pregel = MockPregelInstance(graph_name) if graph_name else MockPregelInstance()
+    if not graph_name:
+        delattr(pregel, "name")
+        delattr(pregel, "graph_name")
+
+    def original_invoke(self, *args, **kwargs):
+        return {"result": "test"}
+
+    with start_transaction():
+        wrapped_invoke = _wrap_pregel_invoke(original_invoke)
+        wrapped_invoke(pregel, {"messages": []})
+
+    tx = events[0]
+    invoke_spans = [
+        span for span in tx["spans"] if span["op"] == OP.GEN_AI_INVOKE_AGENT
+    ]
+    assert len(invoke_spans) == 1
+
+    invoke_span = invoke_spans[0]
+
+    if graph_name and graph_name.strip():
+        assert invoke_span["description"] == "invoke_agent my_graph"
+        assert invoke_span["data"][SPANDATA.GEN_AI_PIPELINE_NAME] == graph_name
+        assert invoke_span["data"][SPANDATA.GEN_AI_AGENT_NAME] == graph_name
+    else:
+        assert invoke_span["description"] == "invoke_agent"
+        assert SPANDATA.GEN_AI_PIPELINE_NAME not in invoke_span.get("data", {})
+        assert SPANDATA.GEN_AI_AGENT_NAME not in invoke_span.get("data", {})
+
+
+def test_pregel_invoke_span_includes_usage_data(sentry_init, capture_events):
+    """
+    Test that invoke_agent spans include aggregated usage data from context_wrapper.
+    This verifies the new functionality added to track token usage in invoke_agent spans.
+    """
+    sentry_init(
+        integrations=[LanggraphIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    test_state = {
+        "messages": [
+            MockMessage("Hello, can you help me?", name="user"),
+            MockMessage("Of course! How can I assist you?", name="assistant"),
+        ]
+    }
+
+    pregel = MockPregelInstance("test_graph")
+
+    expected_assistant_response = "I'll help you with that task!"
+    expected_tool_calls = [
+        {
+            "id": "call_test_123",
+            "type": "function",
+            "function": {"name": "search_tool", "arguments": '{"query": "help"}'},
+        }
+    ]
+
+    def original_invoke(self, *args, **kwargs):
+        input_messages = args[0].get("messages", [])
+        new_messages = input_messages + [
+            MockMessage(
+                content=expected_assistant_response,
+                name="assistant",
+                tool_calls=expected_tool_calls,
+                response_metadata={
+                    "token_usage": {
+                        "total_tokens": 30,
+                        "prompt_tokens": 10,
+                        "completion_tokens": 20,
+                    },
+                    "model_name": "gpt-4.1-2025-04-14",
+                },
+            )
+        ]
+        return {"messages": new_messages}
+
+    with start_transaction():
+        wrapped_invoke = _wrap_pregel_invoke(original_invoke)
+        result = wrapped_invoke(pregel, test_state)
+
+    assert result is not None
+
+    tx = events[0]
+    assert tx["type"] == "transaction"
+
+    invoke_spans = [
+        span for span in tx["spans"] if span["op"] == OP.GEN_AI_INVOKE_AGENT
+    ]
+    assert len(invoke_spans) == 1
+
+    invoke_agent_span = invoke_spans[0]
+
+    # Verify invoke_agent span has usage data
+    assert invoke_agent_span["description"] == "invoke_agent test_graph"
+    assert "gen_ai.usage.input_tokens" in invoke_agent_span["data"]
+    assert "gen_ai.usage.output_tokens" in invoke_agent_span["data"]
+    assert "gen_ai.usage.total_tokens" in invoke_agent_span["data"]
+
+    # The usage should match the mock_usage values (aggregated across all calls)
+    assert invoke_agent_span["data"]["gen_ai.usage.input_tokens"] == 10
+    assert invoke_agent_span["data"]["gen_ai.usage.output_tokens"] == 20
+    assert invoke_agent_span["data"]["gen_ai.usage.total_tokens"] == 30
+
+
+def test_pregel_ainvoke_span_includes_usage_data(sentry_init, capture_events):
+    """
+    Test that invoke_agent spans include aggregated usage data from context_wrapper.
+    This verifies the new functionality added to track token usage in invoke_agent spans.
+    """
+    sentry_init(
+        integrations=[LanggraphIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    test_state = {
+        "messages": [
+            MockMessage("Hello, can you help me?", name="user"),
+            MockMessage("Of course! How can I assist you?", name="assistant"),
+        ]
+    }
+
+    pregel = MockPregelInstance("test_graph")
+
+    expected_assistant_response = "I'll help you with that task!"
+    expected_tool_calls = [
+        {
+            "id": "call_test_123",
+            "type": "function",
+            "function": {"name": "search_tool", "arguments": '{"query": "help"}'},
+        }
+    ]
+
+    async def original_ainvoke(self, *args, **kwargs):
+        input_messages = args[0].get("messages", [])
+        new_messages = input_messages + [
+            MockMessage(
+                content=expected_assistant_response,
+                name="assistant",
+                tool_calls=expected_tool_calls,
+                response_metadata={
+                    "token_usage": {
+                        "total_tokens": 30,
+                        "prompt_tokens": 10,
+                        "completion_tokens": 20,
+                    },
+                    "model_name": "gpt-4.1-2025-04-14",
+                },
+            )
+        ]
+        return {"messages": new_messages}
+
+    async def run_test():
+        with start_transaction():
+            wrapped_ainvoke = _wrap_pregel_ainvoke(original_ainvoke)
+            result = await wrapped_ainvoke(pregel, test_state)
+            return result
+
+    result = asyncio.run(run_test())
+    assert result is not None
+
+    tx = events[0]
+    assert tx["type"] == "transaction"
+
+    invoke_spans = [
+        span for span in tx["spans"] if span["op"] == OP.GEN_AI_INVOKE_AGENT
+    ]
+    assert len(invoke_spans) == 1
+
+    invoke_agent_span = invoke_spans[0]
+
+    # Verify invoke_agent span has usage data
+    assert invoke_agent_span["description"] == "invoke_agent test_graph"
+    assert "gen_ai.usage.input_tokens" in invoke_agent_span["data"]
+    assert "gen_ai.usage.output_tokens" in invoke_agent_span["data"]
+    assert "gen_ai.usage.total_tokens" in invoke_agent_span["data"]
+
+    # The usage should match the mock_usage values (aggregated across all calls)
+    assert invoke_agent_span["data"]["gen_ai.usage.input_tokens"] == 10
+    assert invoke_agent_span["data"]["gen_ai.usage.output_tokens"] == 20
+    assert invoke_agent_span["data"]["gen_ai.usage.total_tokens"] == 30
+
+
+def test_pregel_invoke_multiple_llm_calls_aggregate_usage(sentry_init, capture_events):
+    """
+    Test that invoke_agent spans show aggregated usage across multiple LLM calls
+    (e.g., when tools are used and multiple API calls are made).
+    """
+    sentry_init(
+        integrations=[LanggraphIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    test_state = {
+        "messages": [
+            MockMessage("Hello, can you help me?", name="user"),
+            MockMessage("Of course! How can I assist you?", name="assistant"),
+        ]
+    }
+
+    pregel = MockPregelInstance("test_graph")
+
+    expected_assistant_response = "I'll help you with that task!"
+    expected_tool_calls = [
+        {
+            "id": "call_test_123",
+            "type": "function",
+            "function": {"name": "search_tool", "arguments": '{"query": "help"}'},
+        }
+    ]
+
+    def original_invoke(self, *args, **kwargs):
+        input_messages = args[0].get("messages", [])
+        new_messages = input_messages + [
+            MockMessage(
+                content=expected_assistant_response,
+                name="assistant",
+                tool_calls=expected_tool_calls,
+                response_metadata={
+                    "token_usage": {
+                        "total_tokens": 15,
+                        "prompt_tokens": 10,
+                        "completion_tokens": 5,
+                    },
+                },
+            ),
+            MockMessage(
+                content=expected_assistant_response,
+                name="assistant",
+                tool_calls=expected_tool_calls,
+                response_metadata={
+                    "token_usage": {
+                        "total_tokens": 35,
+                        "prompt_tokens": 20,
+                        "completion_tokens": 15,
+                    },
+                },
+            ),
+        ]
+        return {"messages": new_messages}
+
+    with start_transaction():
+        wrapped_invoke = _wrap_pregel_invoke(original_invoke)
+        result = wrapped_invoke(pregel, test_state)
+
+    assert result is not None
+
+    tx = events[0]
+    assert tx["type"] == "transaction"
+
+    invoke_spans = [
+        span for span in tx["spans"] if span["op"] == OP.GEN_AI_INVOKE_AGENT
+    ]
+    assert len(invoke_spans) == 1
+    invoke_agent_span = invoke_spans[0]
+
+    # Verify invoke_agent span has aggregated usage from both API calls
+    # Total: 10 + 20 = 30 input tokens, 5 + 15 = 20 output tokens, 15 + 35 = 50 total
+    assert invoke_agent_span["data"]["gen_ai.usage.input_tokens"] == 30
+    assert invoke_agent_span["data"]["gen_ai.usage.output_tokens"] == 20
+    assert invoke_agent_span["data"]["gen_ai.usage.total_tokens"] == 50
+
+
+def test_pregel_ainvoke_multiple_llm_calls_aggregate_usage(sentry_init, capture_events):
+    """
+    Test that invoke_agent spans show aggregated usage across multiple LLM calls
+    (e.g., when tools are used and multiple API calls are made).
+    """
+    sentry_init(
+        integrations=[LanggraphIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    test_state = {
+        "messages": [
+            MockMessage("Hello, can you help me?", name="user"),
+            MockMessage("Of course! How can I assist you?", name="assistant"),
+        ]
+    }
+
+    pregel = MockPregelInstance("test_graph")
+
+    expected_assistant_response = "I'll help you with that task!"
+    expected_tool_calls = [
+        {
+            "id": "call_test_123",
+            "type": "function",
+            "function": {"name": "search_tool", "arguments": '{"query": "help"}'},
+        }
+    ]
+
+    async def original_ainvoke(self, *args, **kwargs):
+        input_messages = args[0].get("messages", [])
+        new_messages = input_messages + [
+            MockMessage(
+                content=expected_assistant_response,
+                name="assistant",
+                tool_calls=expected_tool_calls,
+                response_metadata={
+                    "token_usage": {
+                        "total_tokens": 15,
+                        "prompt_tokens": 10,
+                        "completion_tokens": 5,
+                    },
+                },
+            ),
+            MockMessage(
+                content=expected_assistant_response,
+                name="assistant",
+                tool_calls=expected_tool_calls,
+                response_metadata={
+                    "token_usage": {
+                        "total_tokens": 35,
+                        "prompt_tokens": 20,
+                        "completion_tokens": 15,
+                    },
+                },
+            ),
+        ]
+        return {"messages": new_messages}
+
+    async def run_test():
+        with start_transaction():
+            wrapped_ainvoke = _wrap_pregel_ainvoke(original_ainvoke)
+            result = await wrapped_ainvoke(pregel, test_state)
+            return result
+
+    result = asyncio.run(run_test())
+    assert result is not None
+
+    tx = events[0]
+    assert tx["type"] == "transaction"
+
+    invoke_spans = [
+        span for span in tx["spans"] if span["op"] == OP.GEN_AI_INVOKE_AGENT
+    ]
+    assert len(invoke_spans) == 1
+    invoke_agent_span = invoke_spans[0]
+
+    # Verify invoke_agent span has aggregated usage from both API calls
+    # Total: 10 + 20 = 30 input tokens, 5 + 15 = 20 output tokens, 15 + 35 = 50 total
+    assert invoke_agent_span["data"]["gen_ai.usage.input_tokens"] == 30
+    assert invoke_agent_span["data"]["gen_ai.usage.output_tokens"] == 20
+    assert invoke_agent_span["data"]["gen_ai.usage.total_tokens"] == 50
+
+
+def test_pregel_invoke_span_includes_response_model(sentry_init, capture_events):
+    """
+    Test that invoke_agent spans include the response model.
+    When an agent makes multiple LLM calls, it should report the last model used.
+    """
+    sentry_init(
+        integrations=[LanggraphIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    test_state = {
+        "messages": [
+            MockMessage("Hello, can you help me?", name="user"),
+            MockMessage("Of course! How can I assist you?", name="assistant"),
+        ]
+    }
+
+    pregel = MockPregelInstance("test_graph")
+
+    expected_assistant_response = "I'll help you with that task!"
+    expected_tool_calls = [
+        {
+            "id": "call_test_123",
+            "type": "function",
+            "function": {"name": "search_tool", "arguments": '{"query": "help"}'},
+        }
+    ]
+
+    def original_invoke(self, *args, **kwargs):
+        input_messages = args[0].get("messages", [])
+        new_messages = input_messages + [
+            MockMessage(
+                content=expected_assistant_response,
+                name="assistant",
+                tool_calls=expected_tool_calls,
+                response_metadata={
+                    "token_usage": {
+                        "total_tokens": 30,
+                        "prompt_tokens": 10,
+                        "completion_tokens": 20,
+                    },
+                    "model_name": "gpt-4.1-2025-04-14",
+                },
+            )
+        ]
+        return {"messages": new_messages}
+
+    with start_transaction():
+        wrapped_invoke = _wrap_pregel_invoke(original_invoke)
+        result = wrapped_invoke(pregel, test_state)
+
+    assert result is not None
+
+    tx = events[0]
+    assert tx["type"] == "transaction"
+
+    invoke_spans = [
+        span for span in tx["spans"] if span["op"] == OP.GEN_AI_INVOKE_AGENT
+    ]
+    assert len(invoke_spans) == 1
+
+    invoke_agent_span = invoke_spans[0]
+
+    # Verify invoke_agent span has response model
+    assert invoke_agent_span["description"] == "invoke_agent test_graph"
+    assert "gen_ai.response.model" in invoke_agent_span["data"]
+    assert invoke_agent_span["data"]["gen_ai.response.model"] == "gpt-4.1-2025-04-14"
+
+
+def test_pregel_ainvoke_span_includes_response_model(sentry_init, capture_events):
+    """
+    Test that invoke_agent spans include the response model.
+    When an agent makes multiple LLM calls, it should report the last model used.
+    """
+    sentry_init(
+        integrations=[LanggraphIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    test_state = {
+        "messages": [
+            MockMessage("Hello, can you help me?", name="user"),
+            MockMessage("Of course! How can I assist you?", name="assistant"),
+        ]
+    }
+
+    pregel = MockPregelInstance("test_graph")
+
+    expected_assistant_response = "I'll help you with that task!"
+    expected_tool_calls = [
+        {
+            "id": "call_test_123",
+            "type": "function",
+            "function": {"name": "search_tool", "arguments": '{"query": "help"}'},
+        }
+    ]
+
+    async def original_ainvoke(self, *args, **kwargs):
+        input_messages = args[0].get("messages", [])
+        new_messages = input_messages + [
+            MockMessage(
+                content=expected_assistant_response,
+                name="assistant",
+                tool_calls=expected_tool_calls,
+                response_metadata={
+                    "token_usage": {
+                        "total_tokens": 30,
+                        "prompt_tokens": 10,
+                        "completion_tokens": 20,
+                    },
+                    "model_name": "gpt-4.1-2025-04-14",
+                },
+            )
+        ]
+        return {"messages": new_messages}
+
+    async def run_test():
+        with start_transaction():
+            wrapped_ainvoke = _wrap_pregel_ainvoke(original_ainvoke)
+            result = await wrapped_ainvoke(pregel, test_state)
+            return result
+
+    result = asyncio.run(run_test())
+    assert result is not None
+
+    tx = events[0]
+    assert tx["type"] == "transaction"
+
+    invoke_spans = [
+        span for span in tx["spans"] if span["op"] == OP.GEN_AI_INVOKE_AGENT
+    ]
+    assert len(invoke_spans) == 1
+
+    invoke_agent_span = invoke_spans[0]
+
+    # Verify invoke_agent span has response model
+    assert invoke_agent_span["description"] == "invoke_agent test_graph"
+    assert "gen_ai.response.model" in invoke_agent_span["data"]
+    assert invoke_agent_span["data"]["gen_ai.response.model"] == "gpt-4.1-2025-04-14"
+
+
+def test_pregel_invoke_span_uses_last_response_model(sentry_init, capture_events):
+    """
+    Test that when an agent makes multiple LLM calls (e.g., with tools),
+    the invoke_agent span reports the last response model used.
+    """
+    sentry_init(
+        integrations=[LanggraphIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    test_state = {
+        "messages": [
+            MockMessage("Hello, can you help me?", name="user"),
+            MockMessage("Of course! How can I assist you?", name="assistant"),
+        ]
+    }
+
+    pregel = MockPregelInstance("test_graph")
+
+    expected_assistant_response = "I'll help you with that task!"
+    expected_tool_calls = [
+        {
+            "id": "call_test_123",
+            "type": "function",
+            "function": {"name": "search_tool", "arguments": '{"query": "help"}'},
+        }
+    ]
+
+    def original_invoke(self, *args, **kwargs):
+        input_messages = args[0].get("messages", [])
+        new_messages = input_messages + [
+            MockMessage(
+                content=expected_assistant_response,
+                name="assistant",
+                tool_calls=expected_tool_calls,
+                response_metadata={
+                    "token_usage": {
+                        "total_tokens": 15,
+                        "prompt_tokens": 10,
+                        "completion_tokens": 5,
+                    },
+                    "model_name": "gpt-4-0613",
+                },
+            ),
+            MockMessage(
+                content=expected_assistant_response,
+                name="assistant",
+                tool_calls=expected_tool_calls,
+                response_metadata={
+                    "token_usage": {
+                        "total_tokens": 35,
+                        "prompt_tokens": 20,
+                        "completion_tokens": 15,
+                    },
+                    "model_name": "gpt-4.1-2025-04-14",
+                },
+            ),
+        ]
+        return {"messages": new_messages}
+
+    with start_transaction():
+        wrapped_invoke = _wrap_pregel_invoke(original_invoke)
+        result = wrapped_invoke(pregel, test_state)
+
+    assert result is not None
+
+    tx = events[0]
+    assert tx["type"] == "transaction"
+
+    invoke_spans = [
+        span for span in tx["spans"] if span["op"] == OP.GEN_AI_INVOKE_AGENT
+    ]
+    assert len(invoke_spans) == 1
+
+    invoke_agent_span = invoke_spans[0]
+
+    # Verify invoke_agent span uses the LAST response model
+    assert "gen_ai.response.model" in invoke_agent_span["data"]
+    assert invoke_agent_span["data"]["gen_ai.response.model"] == "gpt-4.1-2025-04-14"
+
+
+def test_pregel_ainvoke_span_uses_last_response_model(sentry_init, capture_events):
+    """
+    Test that when an agent makes multiple LLM calls (e.g., with tools),
+    the invoke_agent span reports the last response model used.
+    """
+    sentry_init(
+        integrations=[LanggraphIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    test_state = {
+        "messages": [
+            MockMessage("Hello, can you help me?", name="user"),
+            MockMessage("Of course! How can I assist you?", name="assistant"),
+        ]
+    }
+
+    pregel = MockPregelInstance("test_graph")
+
+    expected_assistant_response = "I'll help you with that task!"
+    expected_tool_calls = [
+        {
+            "id": "call_test_123",
+            "type": "function",
+            "function": {"name": "search_tool", "arguments": '{"query": "help"}'},
+        }
+    ]
+
+    async def original_ainvoke(self, *args, **kwargs):
+        input_messages = args[0].get("messages", [])
+        new_messages = input_messages + [
+            MockMessage(
+                content=expected_assistant_response,
+                name="assistant",
+                tool_calls=expected_tool_calls,
+                response_metadata={
+                    "token_usage": {
+                        "total_tokens": 15,
+                        "prompt_tokens": 10,
+                        "completion_tokens": 5,
+                    },
+                    "model_name": "gpt-4-0613",
+                },
+            ),
+            MockMessage(
+                content=expected_assistant_response,
+                name="assistant",
+                tool_calls=expected_tool_calls,
+                response_metadata={
+                    "token_usage": {
+                        "total_tokens": 35,
+                        "prompt_tokens": 20,
+                        "completion_tokens": 15,
+                    },
+                    "model_name": "gpt-4.1-2025-04-14",
+                },
+            ),
+        ]
+        return {"messages": new_messages}
+
+    async def run_test():
+        with start_transaction():
+            wrapped_ainvoke = _wrap_pregel_ainvoke(original_ainvoke)
+            result = await wrapped_ainvoke(pregel, test_state)
+            return result
+
+    result = asyncio.run(run_test())
+    assert result is not None
+
+    tx = events[0]
+    assert tx["type"] == "transaction"
+
+    invoke_spans = [
+        span for span in tx["spans"] if span["op"] == OP.GEN_AI_INVOKE_AGENT
+    ]
+    assert len(invoke_spans) == 1
+
+    invoke_agent_span = invoke_spans[0]
+
+    # Verify invoke_agent span uses the LAST response model
+    assert "gen_ai.response.model" in invoke_agent_span["data"]
+    assert invoke_agent_span["data"]["gen_ai.response.model"] == "gpt-4.1-2025-04-14"
+
+
+def test_complex_message_parsing():
+    """Test message parsing with complex message structures."""
+    messages = [
+        MockMessage(content="User query", name="user"),
+        MockMessage(
+            content="Assistant response with tools",
+            name="assistant",
+            tool_calls=[
+                {
+                    "id": "call_1",
+                    "type": "function",
+                    "function": {"name": "search", "arguments": "{}"},
+                },
+                {
+                    "id": "call_2",
+                    "type": "function",
+                    "function": {"name": "calculate", "arguments": '{"x": 5}'},
+                },
+            ],
+        ),
+        MockMessage(
+            content="Function call response",
+            name="function",
+            function_call={"name": "search", "arguments": '{"query": "test"}'},
+        ),
+    ]
+
+    state = {"messages": messages}
+    result = _parse_langgraph_messages(state)
+
+    assert result is not None
+    assert len(result) == 3
+
+    assert result[0]["content"] == "User query"
+    assert result[0]["name"] == "user"
+    assert "tool_calls" not in result[0]
+    assert "function_call" not in result[0]
+
+    assert result[1]["content"] == "Assistant response with tools"
+    assert result[1]["name"] == "assistant"
+    assert len(result[1]["tool_calls"]) == 2
+
+    assert result[2]["content"] == "Function call response"
+    assert result[2]["name"] == "function"
+    assert result[2]["function_call"]["name"] == "search"
+
+
+def test_extraction_functions_complex_scenario(sentry_init, capture_events):
+    """Test extraction functions with complex scenarios including multiple messages and edge cases."""
+    sentry_init(
+        integrations=[LanggraphIntegration(include_prompts=True)],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+    events = capture_events()
+
+    pregel = MockPregelInstance("complex_graph")
+    test_state = {"messages": [MockMessage("Complex request", name="user")]}
+
+    def original_invoke(self, *args, **kwargs):
+        input_messages = args[0].get("messages", [])
+        new_messages = input_messages + [
+            MockMessage(
+                content="I'll help with multiple tasks",
+                name="assistant",
+                tool_calls=[
+                    {
+                        "id": "call_multi_1",
+                        "type": "function",
+                        "function": {
+                            "name": "search",
+                            "arguments": '{"query": "complex"}',
+                        },
+                    },
+                    {
+                        "id": "call_multi_2",
+                        "type": "function",
+                        "function": {
+                            "name": "calculate",
+                            "arguments": '{"expr": "2+2"}',
+                        },
+                    },
+                ],
+            ),
+            MockMessage("", name="assistant"),
+            MockMessage("Final response", name="ai", type="ai"),
+        ]
+        return {"messages": new_messages}
+
+    with start_transaction():
+        wrapped_invoke = _wrap_pregel_invoke(original_invoke)
+        result = wrapped_invoke(pregel, test_state)
+
+    assert result is not None
+
+    tx = events[0]
+    invoke_spans = [
+        span for span in tx["spans"] if span["op"] == OP.GEN_AI_INVOKE_AGENT
+    ]
+    assert len(invoke_spans) == 1
+
+    invoke_span = invoke_spans[0]
+    assert SPANDATA.GEN_AI_RESPONSE_TEXT in invoke_span["data"]
+    response_text = invoke_span["data"][SPANDATA.GEN_AI_RESPONSE_TEXT]
+    assert response_text == "Final response"
+
+    assert SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS in invoke_span["data"]
+    import json
+
+    tool_calls_data = invoke_span["data"][SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS]
+    if isinstance(tool_calls_data, str):
+        tool_calls_data = json.loads(tool_calls_data)
+
+    assert len(tool_calls_data) == 2
+    assert tool_calls_data[0]["id"] == "call_multi_1"
+    assert tool_calls_data[0]["function"]["name"] == "search"
+    assert tool_calls_data[1]["id"] == "call_multi_2"
+    assert tool_calls_data[1]["function"]["name"] == "calculate"
+
+
+def test_langgraph_message_role_mapping(sentry_init, capture_events):
+    """Test that Langgraph integration properly maps message roles like 'ai' to 'assistant'"""
+    sentry_init(
+        integrations=[LanggraphIntegration(include_prompts=True)],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+    events = capture_events()
+
+    # Mock a langgraph message with mixed roles
+    class MockMessage:
+        def __init__(self, content, message_type="human"):
+            self.content = content
+            self.type = message_type
+
+    # Create mock state with messages having different roles
+    state_data = {
+        "messages": [
+            MockMessage("System prompt", "system"),
+            MockMessage("Hello", "human"),
+            MockMessage("Hi there!", "ai"),  # Should be mapped to "assistant"
+            MockMessage("How can I help?", "assistant"),  # Should stay "assistant"
+        ]
+    }
+
+    compiled_graph = MockCompiledGraph("test_graph")
+    pregel = MockPregelInstance(compiled_graph)
+
+    with start_transaction(name="langgraph tx"):
+        # Use the wrapped invoke function directly
+        from sentry_sdk.integrations.langgraph import _wrap_pregel_invoke
+
+        wrapped_invoke = _wrap_pregel_invoke(
+            lambda self, state_data: {"result": "success"}
+        )
+        wrapped_invoke(pregel, state_data)
+
+    (event,) = events
+    span = event["spans"][0]
+
+    # Verify that the span was created correctly
+    assert span["op"] == "gen_ai.invoke_agent"
+
+    # If messages were captured, verify role mapping
+    if SPANDATA.GEN_AI_REQUEST_MESSAGES in span["data"]:
+        import json
+
+        stored_messages = json.loads(span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES])
+
+        # Find messages with specific content to verify role mapping
+        ai_message = next(
+            (msg for msg in stored_messages if msg.get("content") == "Hi there!"), None
+        )
+        assistant_message = next(
+            (msg for msg in stored_messages if msg.get("content") == "How can I help?"),
+            None,
+        )
+
+        if ai_message:
+            # "ai" should have been mapped to "assistant"
+            assert ai_message["role"] == "assistant"
+
+        if assistant_message:
+            # "assistant" should stay "assistant"
+            assert assistant_message["role"] == "assistant"
+
+        # Verify no "ai" roles remain
+        roles = [msg["role"] for msg in stored_messages if "role" in msg]
+        assert "ai" not in roles
+
+
+def test_langgraph_message_truncation(sentry_init, capture_events):
+    """Test that large messages are truncated properly in Langgraph integration."""
+    import json
+
+    sentry_init(
+        integrations=[LanggraphIntegration(include_prompts=True)],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+    events = capture_events()
+
+    large_content = (
+        "This is a very long message that will exceed our size limits. " * 1000
+    )
+    test_state = {
+        "messages": [
+            MockMessage("small message 1", name="user"),
+            MockMessage(large_content, name="assistant"),
+            MockMessage(large_content, name="user"),
+            MockMessage("small message 4", name="assistant"),
+            MockMessage("small message 5", name="user"),
+        ]
+    }
+
+    pregel = MockPregelInstance("test_graph")
+
+    def original_invoke(self, *args, **kwargs):
+        return {"messages": args[0].get("messages", [])}
+
+    with start_transaction():
+        wrapped_invoke = _wrap_pregel_invoke(original_invoke)
+        result = wrapped_invoke(pregel, test_state)
+
+    assert result is not None
+    assert len(events) > 0
+    tx = events[0]
+    assert tx["type"] == "transaction"
+
+    invoke_spans = [
+        span for span in tx.get("spans", []) if span.get("op") == OP.GEN_AI_INVOKE_AGENT
+    ]
+    assert len(invoke_spans) > 0
+
+    invoke_span = invoke_spans[0]
+    assert SPANDATA.GEN_AI_REQUEST_MESSAGES in invoke_span["data"]
+
+    messages_data = invoke_span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES]
+    assert isinstance(messages_data, str)
+
+    parsed_messages = json.loads(messages_data)
+    assert isinstance(parsed_messages, list)
+    assert len(parsed_messages) == 2
+    assert "small message 4" in str(parsed_messages[0])
+    assert "small message 5" in str(parsed_messages[1])
+    assert tx["_meta"]["spans"]["0"]["data"]["gen_ai.request.messages"][""]["len"] == 5
diff --git a/tests/integrations/launchdarkly/__init__.py b/tests/integrations/launchdarkly/__init__.py
new file mode 100644
index 0000000000..06e09884c8
--- /dev/null
+++ b/tests/integrations/launchdarkly/__init__.py
@@ -0,0 +1,3 @@
+import pytest
+
+pytest.importorskip("ldclient")
diff --git a/tests/integrations/launchdarkly/test_launchdarkly.py b/tests/integrations/launchdarkly/test_launchdarkly.py
new file mode 100644
index 0000000000..e588b596d3
--- /dev/null
+++ b/tests/integrations/launchdarkly/test_launchdarkly.py
@@ -0,0 +1,251 @@
+import concurrent.futures as cf
+import sys
+
+import ldclient
+import pytest
+
+from ldclient import LDClient
+from ldclient.config import Config
+from ldclient.context import Context
+from ldclient.integrations.test_data import TestData
+
+import sentry_sdk
+from sentry_sdk.integrations import DidNotEnable
+from sentry_sdk.integrations.launchdarkly import LaunchDarklyIntegration
+from sentry_sdk import start_span, start_transaction
+from tests.conftest import ApproxDict
+
+
+@pytest.mark.parametrize(
+    "use_global_client",
+    (False, True),
+)
+def test_launchdarkly_integration(
+    sentry_init, use_global_client, capture_events, uninstall_integration
+):
+    td = TestData.data_source()
+    td.update(td.flag("hello").variation_for_all(True))
+    td.update(td.flag("world").variation_for_all(True))
+    # Disable background requests as we aren't using a server.
+    config = Config(
+        "sdk-key", update_processor_class=td, diagnostic_opt_out=True, send_events=False
+    )
+
+    uninstall_integration(LaunchDarklyIntegration.identifier)
+    if use_global_client:
+        ldclient.set_config(config)
+        sentry_init(integrations=[LaunchDarklyIntegration()])
+        client = ldclient.get()
+    else:
+        client = LDClient(config=config)
+        sentry_init(integrations=[LaunchDarklyIntegration(ld_client=client)])
+
+    # Evaluate
+    client.variation("hello", Context.create("my-org", "organization"), False)
+    client.variation("world", Context.create("user1", "user"), False)
+    client.variation("other", Context.create("user2", "user"), False)
+
+    events = capture_events()
+    sentry_sdk.capture_exception(Exception("something wrong!"))
+
+    assert len(events) == 1
+    assert events[0]["contexts"]["flags"] == {
+        "values": [
+            {"flag": "hello", "result": True},
+            {"flag": "world", "result": True},
+            {"flag": "other", "result": False},
+        ]
+    }
+
+
+def test_launchdarkly_integration_threaded(
+    sentry_init, capture_events, uninstall_integration
+):
+    td = TestData.data_source()
+    td.update(td.flag("hello").variation_for_all(True))
+    td.update(td.flag("world").variation_for_all(True))
+    client = LDClient(
+        config=Config(
+            "sdk-key",
+            update_processor_class=td,
+            diagnostic_opt_out=True,  # Disable background requests as we aren't using a server.
+            send_events=False,
+        )
+    )
+    context = Context.create("user1")
+
+    uninstall_integration(LaunchDarklyIntegration.identifier)
+    sentry_init(integrations=[LaunchDarklyIntegration(ld_client=client)])
+    events = capture_events()
+
+    def task(flag_key):
+        # Creates a new isolation scope for the thread.
+        # This means the evaluations in each task are captured separately.
+        with sentry_sdk.isolation_scope():
+            client.variation(flag_key, context, False)
+            # use a tag to identify to identify events later on
+            sentry_sdk.set_tag("task_id", flag_key)
+            sentry_sdk.capture_exception(Exception("something wrong!"))
+
+    # Capture an eval before we split isolation scopes.
+    client.variation("hello", context, False)
+
+    with cf.ThreadPoolExecutor(max_workers=2) as pool:
+        pool.map(task, ["world", "other"])
+
+    # Capture error in original scope
+    sentry_sdk.set_tag("task_id", "0")
+    sentry_sdk.capture_exception(Exception("something wrong!"))
+
+    assert len(events) == 3
+    events.sort(key=lambda e: e["tags"]["task_id"])
+
+    assert events[0]["contexts"]["flags"] == {
+        "values": [
+            {"flag": "hello", "result": True},
+        ]
+    }
+    assert events[1]["contexts"]["flags"] == {
+        "values": [
+            {"flag": "hello", "result": True},
+            {"flag": "other", "result": False},
+        ]
+    }
+    assert events[2]["contexts"]["flags"] == {
+        "values": [
+            {"flag": "hello", "result": True},
+            {"flag": "world", "result": True},
+        ]
+    }
+
+
+@pytest.mark.skipif(sys.version_info < (3, 7), reason="requires python3.7 or higher")
+def test_launchdarkly_integration_asyncio(
+    sentry_init, capture_events, uninstall_integration
+):
+    """Assert concurrently evaluated flags do not pollute one another."""
+
+    asyncio = pytest.importorskip("asyncio")
+
+    td = TestData.data_source()
+    td.update(td.flag("hello").variation_for_all(True))
+    td.update(td.flag("world").variation_for_all(True))
+    client = LDClient(
+        config=Config(
+            "sdk-key",
+            update_processor_class=td,
+            diagnostic_opt_out=True,  # Disable background requests as we aren't using a server.
+            send_events=False,
+        )
+    )
+    context = Context.create("user1")
+
+    uninstall_integration(LaunchDarklyIntegration.identifier)
+    sentry_init(integrations=[LaunchDarklyIntegration(ld_client=client)])
+    events = capture_events()
+
+    async def task(flag_key):
+        with sentry_sdk.isolation_scope():
+            client.variation(flag_key, context, False)
+            # use a tag to identify to identify events later on
+            sentry_sdk.set_tag("task_id", flag_key)
+            sentry_sdk.capture_exception(Exception("something wrong!"))
+
+    async def runner():
+        return asyncio.gather(task("world"), task("other"))
+
+    # Capture an eval before we split isolation scopes.
+    client.variation("hello", context, False)
+
+    asyncio.run(runner())
+
+    # Capture error in original scope
+    sentry_sdk.set_tag("task_id", "0")
+    sentry_sdk.capture_exception(Exception("something wrong!"))
+
+    assert len(events) == 3
+    events.sort(key=lambda e: e["tags"]["task_id"])
+
+    assert events[0]["contexts"]["flags"] == {
+        "values": [
+            {"flag": "hello", "result": True},
+        ]
+    }
+    assert events[1]["contexts"]["flags"] == {
+        "values": [
+            {"flag": "hello", "result": True},
+            {"flag": "other", "result": False},
+        ]
+    }
+    assert events[2]["contexts"]["flags"] == {
+        "values": [
+            {"flag": "hello", "result": True},
+            {"flag": "world", "result": True},
+        ]
+    }
+
+
+def test_launchdarkly_integration_did_not_enable(monkeypatch):
+    # Client is not passed in and set_config wasn't called.
+    # TODO: Bad practice to access internals like this. We can skip this test, or remove this
+    #  case entirely (force user to pass in a client instance).
+    ldclient._reset_client()
+    try:
+        ldclient.__lock.lock()
+        ldclient.__config = None
+    finally:
+        ldclient.__lock.unlock()
+
+    with pytest.raises(DidNotEnable):
+        LaunchDarklyIntegration()
+
+    td = TestData.data_source()
+    # Disable background requests as we aren't using a server.
+    # Required because we corrupt the internal state above.
+    config = Config(
+        "sdk-key", update_processor_class=td, diagnostic_opt_out=True, send_events=False
+    )
+    # Client not initialized.
+    client = LDClient(config=config)
+    monkeypatch.setattr(client, "is_initialized", lambda: False)
+    with pytest.raises(DidNotEnable):
+        LaunchDarklyIntegration(ld_client=client)
+
+
+@pytest.mark.parametrize(
+    "use_global_client",
+    (False, True),
+)
+def test_launchdarkly_span_integration(
+    sentry_init, use_global_client, capture_events, uninstall_integration
+):
+    td = TestData.data_source()
+    td.update(td.flag("hello").variation_for_all(True))
+    # Disable background requests as we aren't using a server.
+    config = Config(
+        "sdk-key", update_processor_class=td, diagnostic_opt_out=True, send_events=False
+    )
+
+    uninstall_integration(LaunchDarklyIntegration.identifier)
+    if use_global_client:
+        ldclient.set_config(config)
+        sentry_init(traces_sample_rate=1.0, integrations=[LaunchDarklyIntegration()])
+        client = ldclient.get()
+    else:
+        client = LDClient(config=config)
+        sentry_init(
+            traces_sample_rate=1.0,
+            integrations=[LaunchDarklyIntegration(ld_client=client)],
+        )
+
+    events = capture_events()
+
+    with start_transaction(name="hi"):
+        with start_span(op="foo", name="bar"):
+            client.variation("hello", Context.create("my-org", "organization"), False)
+            client.variation("other", Context.create("my-org", "organization"), False)
+
+    (event,) = events
+    assert event["spans"][0]["data"] == ApproxDict(
+        {"flag.evaluation.hello": True, "flag.evaluation.other": False}
+    )
diff --git a/tests/integrations/litellm/__init__.py b/tests/integrations/litellm/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/integrations/litellm/test_litellm.py b/tests/integrations/litellm/test_litellm.py
new file mode 100644
index 0000000000..1b925fb61f
--- /dev/null
+++ b/tests/integrations/litellm/test_litellm.py
@@ -0,0 +1,755 @@
+import json
+import pytest
+import time
+from unittest import mock
+from datetime import datetime
+
+try:
+    from unittest.mock import AsyncMock
+except ImportError:
+
+    class AsyncMock(mock.MagicMock):
+        async def __call__(self, *args, **kwargs):
+            return super(AsyncMock, self).__call__(*args, **kwargs)
+
+
+try:
+    import litellm
+except ImportError:
+    pytest.skip("litellm not installed", allow_module_level=True)
+
+import sentry_sdk
+from sentry_sdk import start_transaction
+from sentry_sdk.consts import OP, SPANDATA
+from sentry_sdk.integrations.litellm import (
+    LiteLLMIntegration,
+    _input_callback,
+    _success_callback,
+    _failure_callback,
+)
+from sentry_sdk.utils import package_version
+
+
+LITELLM_VERSION = package_version("litellm")
+
+
+@pytest.fixture
+def clear_litellm_cache():
+    """
+    Clear litellm's client cache and reset integration state to ensure test isolation.
+
+    The LiteLLM integration uses setup_once() which only runs once per Python process.
+    This fixture ensures the integration is properly re-initialized for each test.
+    """
+
+    # Stop all existing mocks
+    mock.patch.stopall()
+
+    # Clear client cache
+    if (
+        hasattr(litellm, "in_memory_llm_clients_cache")
+        and litellm.in_memory_llm_clients_cache
+    ):
+        litellm.in_memory_llm_clients_cache.flush_cache()
+
+    yield
+
+    # Clean up after test as well
+    mock.patch.stopall()
+    if (
+        hasattr(litellm, "in_memory_llm_clients_cache")
+        and litellm.in_memory_llm_clients_cache
+    ):
+        litellm.in_memory_llm_clients_cache.flush_cache()
+
+
+# Mock response objects
+class MockMessage:
+    def __init__(self, role="assistant", content="Test response"):
+        self.role = role
+        self.content = content
+        self.tool_calls = None
+
+    def model_dump(self):
+        return {"role": self.role, "content": self.content}
+
+
+class MockChoice:
+    def __init__(self, message=None):
+        self.message = message or MockMessage()
+        self.index = 0
+        self.finish_reason = "stop"
+
+
+class MockUsage:
+    def __init__(self, prompt_tokens=10, completion_tokens=20, total_tokens=30):
+        self.prompt_tokens = prompt_tokens
+        self.completion_tokens = completion_tokens
+        self.total_tokens = total_tokens
+
+
+class MockCompletionResponse:
+    def __init__(
+        self,
+        model="gpt-3.5-turbo",
+        choices=None,
+        usage=None,
+    ):
+        self.id = "chatcmpl-test"
+        self.model = model
+        self.choices = choices or [MockChoice()]
+        self.usage = usage or MockUsage()
+        self.object = "chat.completion"
+        self.created = 1234567890
+
+
+class MockEmbeddingData:
+    def __init__(self, embedding=None):
+        self.embedding = embedding or [0.1, 0.2, 0.3]
+        self.index = 0
+        self.object = "embedding"
+
+
+class MockEmbeddingResponse:
+    def __init__(self, model="text-embedding-ada-002", data=None, usage=None):
+        self.model = model
+        self.data = data or [MockEmbeddingData()]
+        self.usage = usage or MockUsage(
+            prompt_tokens=5, completion_tokens=0, total_tokens=5
+        )
+        self.object = "list"
+
+    def model_dump(self):
+        return {
+            "model": self.model,
+            "data": [
+                {"embedding": d.embedding, "index": d.index, "object": d.object}
+                for d in self.data
+            ],
+            "usage": {
+                "prompt_tokens": self.usage.prompt_tokens,
+                "completion_tokens": self.usage.completion_tokens,
+                "total_tokens": self.usage.total_tokens,
+            },
+            "object": self.object,
+        }
+
+
+@pytest.mark.parametrize(
+    "send_default_pii, include_prompts",
+    [
+        (True, True),
+        (True, False),
+        (False, True),
+        (False, False),
+    ],
+)
+def test_nonstreaming_chat_completion(
+    sentry_init, capture_events, send_default_pii, include_prompts
+):
+    sentry_init(
+        integrations=[LiteLLMIntegration(include_prompts=include_prompts)],
+        traces_sample_rate=1.0,
+        send_default_pii=send_default_pii,
+    )
+    events = capture_events()
+
+    messages = [{"role": "user", "content": "Hello!"}]
+    mock_response = MockCompletionResponse()
+
+    with start_transaction(name="litellm test"):
+        # Simulate what litellm does: call input callback, then success callback
+        kwargs = {
+            "model": "gpt-3.5-turbo",
+            "messages": messages,
+        }
+
+        _input_callback(kwargs)
+        _success_callback(
+            kwargs,
+            mock_response,
+            datetime.now(),
+            datetime.now(),
+        )
+
+    assert len(events) == 1
+    (event,) = events
+
+    assert event["type"] == "transaction"
+    assert event["transaction"] == "litellm test"
+
+    assert len(event["spans"]) == 1
+    (span,) = event["spans"]
+
+    assert span["op"] == OP.GEN_AI_CHAT
+    assert span["description"] == "chat gpt-3.5-turbo"
+    assert span["data"][SPANDATA.GEN_AI_REQUEST_MODEL] == "gpt-3.5-turbo"
+    assert span["data"][SPANDATA.GEN_AI_RESPONSE_MODEL] == "gpt-3.5-turbo"
+    assert span["data"][SPANDATA.GEN_AI_SYSTEM] == "openai"
+    assert span["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "chat"
+
+    if send_default_pii and include_prompts:
+        assert SPANDATA.GEN_AI_REQUEST_MESSAGES in span["data"]
+        assert SPANDATA.GEN_AI_RESPONSE_TEXT in span["data"]
+    else:
+        assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["data"]
+        assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["data"]
+
+    assert span["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10
+    assert span["data"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 20
+    assert span["data"][SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS] == 30
+
+
+@pytest.mark.parametrize(
+    "send_default_pii, include_prompts",
+    [
+        (True, True),
+        (True, False),
+        (False, True),
+        (False, False),
+    ],
+)
+def test_streaming_chat_completion(
+    sentry_init, capture_events, send_default_pii, include_prompts
+):
+    sentry_init(
+        integrations=[LiteLLMIntegration(include_prompts=include_prompts)],
+        traces_sample_rate=1.0,
+        send_default_pii=send_default_pii,
+    )
+    events = capture_events()
+
+    messages = [{"role": "user", "content": "Hello!"}]
+    mock_response = MockCompletionResponse()
+
+    with start_transaction(name="litellm test"):
+        kwargs = {
+            "model": "gpt-3.5-turbo",
+            "messages": messages,
+            "stream": True,
+        }
+
+        _input_callback(kwargs)
+        _success_callback(
+            kwargs,
+            mock_response,
+            datetime.now(),
+            datetime.now(),
+        )
+
+    assert len(events) == 1
+    (event,) = events
+
+    assert event["type"] == "transaction"
+    assert len(event["spans"]) == 1
+    (span,) = event["spans"]
+
+    assert span["op"] == OP.GEN_AI_CHAT
+    assert span["data"][SPANDATA.GEN_AI_RESPONSE_STREAMING] is True
+
+
+def test_embeddings_create(sentry_init, capture_events, clear_litellm_cache):
+    """
+    Test that litellm.embedding() calls are properly instrumented.
+
+    This test calls the actual litellm.embedding() function (not just callbacks)
+    to ensure proper integration testing.
+    """
+    sentry_init(
+        integrations=[LiteLLMIntegration(include_prompts=True)],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+    events = capture_events()
+
+    mock_response = MockEmbeddingResponse()
+
+    # Mock within the test to ensure proper ordering with cache clearing
+    with mock.patch(
+        "litellm.openai_chat_completions.make_sync_openai_embedding_request"
+    ) as mock_http:
+        # The function returns (headers, response)
+        mock_http.return_value = ({}, mock_response)
+
+        with start_transaction(name="litellm test"):
+            response = litellm.embedding(
+                model="text-embedding-ada-002",
+                input="Hello, world!",
+                api_key="test-key",  # Provide a fake API key to avoid authentication errors
+            )
+            # Allow time for callbacks to complete (they may run in separate threads)
+            time.sleep(0.1)
+
+        # Response is processed by litellm, so just check it exists
+        assert response is not None
+        assert len(events) == 1
+        (event,) = events
+
+        assert event["type"] == "transaction"
+        assert len(event["spans"]) == 1
+        (span,) = event["spans"]
+
+        assert span["op"] == OP.GEN_AI_EMBEDDINGS
+        assert span["description"] == "embeddings text-embedding-ada-002"
+        assert span["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "embeddings"
+        assert span["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 5
+        assert span["data"][SPANDATA.GEN_AI_REQUEST_MODEL] == "text-embedding-ada-002"
+        # Check that embeddings input is captured (it's JSON serialized)
+        embeddings_input = span["data"][SPANDATA.GEN_AI_EMBEDDINGS_INPUT]
+        assert json.loads(embeddings_input) == ["Hello, world!"]
+
+
+def test_embeddings_create_with_list_input(
+    sentry_init, capture_events, clear_litellm_cache
+):
+    """Test embedding with list input."""
+    sentry_init(
+        integrations=[LiteLLMIntegration(include_prompts=True)],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+    events = capture_events()
+
+    mock_response = MockEmbeddingResponse()
+
+    # Mock within the test to ensure proper ordering with cache clearing
+    with mock.patch(
+        "litellm.openai_chat_completions.make_sync_openai_embedding_request"
+    ) as mock_http:
+        # The function returns (headers, response)
+        mock_http.return_value = ({}, mock_response)
+
+        with start_transaction(name="litellm test"):
+            response = litellm.embedding(
+                model="text-embedding-ada-002",
+                input=["First text", "Second text", "Third text"],
+                api_key="test-key",  # Provide a fake API key to avoid authentication errors
+            )
+            # Allow time for callbacks to complete (they may run in separate threads)
+            time.sleep(0.1)
+
+        # Response is processed by litellm, so just check it exists
+        assert response is not None
+        assert len(events) == 1
+        (event,) = events
+
+        assert event["type"] == "transaction"
+        assert len(event["spans"]) == 1
+        (span,) = event["spans"]
+
+        assert span["op"] == OP.GEN_AI_EMBEDDINGS
+        assert span["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "embeddings"
+        # Check that list of embeddings input is captured (it's JSON serialized)
+        embeddings_input = span["data"][SPANDATA.GEN_AI_EMBEDDINGS_INPUT]
+        assert json.loads(embeddings_input) == [
+            "First text",
+            "Second text",
+            "Third text",
+        ]
+
+
+def test_embeddings_no_pii(sentry_init, capture_events, clear_litellm_cache):
+    """Test that PII is not captured when disabled."""
+    sentry_init(
+        integrations=[LiteLLMIntegration(include_prompts=True)],
+        traces_sample_rate=1.0,
+        send_default_pii=False,  # PII disabled
+    )
+    events = capture_events()
+
+    mock_response = MockEmbeddingResponse()
+
+    # Mock within the test to ensure proper ordering with cache clearing
+    with mock.patch(
+        "litellm.openai_chat_completions.make_sync_openai_embedding_request"
+    ) as mock_http:
+        # The function returns (headers, response)
+        mock_http.return_value = ({}, mock_response)
+
+        with start_transaction(name="litellm test"):
+            response = litellm.embedding(
+                model="text-embedding-ada-002",
+                input="Hello, world!",
+                api_key="test-key",  # Provide a fake API key to avoid authentication errors
+            )
+            # Allow time for callbacks to complete (they may run in separate threads)
+            time.sleep(0.1)
+
+        # Response is processed by litellm, so just check it exists
+        assert response is not None
+        assert len(events) == 1
+        (event,) = events
+
+        assert event["type"] == "transaction"
+        assert len(event["spans"]) == 1
+        (span,) = event["spans"]
+
+        assert span["op"] == OP.GEN_AI_EMBEDDINGS
+        # Check that embeddings input is NOT captured when PII is disabled
+        assert SPANDATA.GEN_AI_EMBEDDINGS_INPUT not in span["data"]
+
+
+def test_exception_handling(sentry_init, capture_events):
+    sentry_init(
+        integrations=[LiteLLMIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    messages = [{"role": "user", "content": "Hello!"}]
+
+    with start_transaction(name="litellm test"):
+        kwargs = {
+            "model": "gpt-3.5-turbo",
+            "messages": messages,
+        }
+
+        _input_callback(kwargs)
+        _failure_callback(
+            kwargs,
+            Exception("API rate limit reached"),
+            datetime.now(),
+            datetime.now(),
+        )
+
+    # Should have error event and transaction
+    assert len(events) >= 1
+    # Find the error event
+    error_events = [e for e in events if e.get("level") == "error"]
+    assert len(error_events) == 1
+
+
+def test_span_origin(sentry_init, capture_events):
+    sentry_init(
+        integrations=[LiteLLMIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    messages = [{"role": "user", "content": "Hello!"}]
+    mock_response = MockCompletionResponse()
+
+    with start_transaction(name="litellm test"):
+        kwargs = {
+            "model": "gpt-3.5-turbo",
+            "messages": messages,
+        }
+
+        _input_callback(kwargs)
+        _success_callback(
+            kwargs,
+            mock_response,
+            datetime.now(),
+            datetime.now(),
+        )
+
+    (event,) = events
+
+    assert event["contexts"]["trace"]["origin"] == "manual"
+    assert event["spans"][0]["origin"] == "auto.ai.litellm"
+
+
+def test_multiple_providers(sentry_init, capture_events):
+    """Test that the integration correctly identifies different providers."""
+    sentry_init(
+        integrations=[LiteLLMIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    messages = [{"role": "user", "content": "Hello!"}]
+
+    # Test with different model prefixes
+    test_cases = [
+        ("gpt-3.5-turbo", "openai"),
+        ("claude-3-opus-20240229", "anthropic"),
+        ("gemini/gemini-pro", "gemini"),
+    ]
+
+    for model, _ in test_cases:
+        mock_response = MockCompletionResponse(model=model)
+        with start_transaction(name=f"test {model}"):
+            kwargs = {
+                "model": model,
+                "messages": messages,
+            }
+
+            _input_callback(kwargs)
+            _success_callback(
+                kwargs,
+                mock_response,
+                datetime.now(),
+                datetime.now(),
+            )
+
+    assert len(events) == len(test_cases)
+
+    for i in range(len(test_cases)):
+        span = events[i]["spans"][0]
+        # The provider should be detected by litellm.get_llm_provider
+        assert SPANDATA.GEN_AI_SYSTEM in span["data"]
+
+
+def test_additional_parameters(sentry_init, capture_events):
+    """Test that additional parameters are captured."""
+    sentry_init(
+        integrations=[LiteLLMIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    messages = [{"role": "user", "content": "Hello!"}]
+    mock_response = MockCompletionResponse()
+
+    with start_transaction(name="litellm test"):
+        kwargs = {
+            "model": "gpt-3.5-turbo",
+            "messages": messages,
+            "temperature": 0.7,
+            "max_tokens": 100,
+            "top_p": 0.9,
+            "frequency_penalty": 0.5,
+            "presence_penalty": 0.5,
+        }
+
+        _input_callback(kwargs)
+        _success_callback(
+            kwargs,
+            mock_response,
+            datetime.now(),
+            datetime.now(),
+        )
+
+    (event,) = events
+    (span,) = event["spans"]
+
+    assert span["data"][SPANDATA.GEN_AI_REQUEST_TEMPERATURE] == 0.7
+    assert span["data"][SPANDATA.GEN_AI_REQUEST_MAX_TOKENS] == 100
+    assert span["data"][SPANDATA.GEN_AI_REQUEST_TOP_P] == 0.9
+    assert span["data"][SPANDATA.GEN_AI_REQUEST_FREQUENCY_PENALTY] == 0.5
+    assert span["data"][SPANDATA.GEN_AI_REQUEST_PRESENCE_PENALTY] == 0.5
+
+
+def test_litellm_specific_parameters(sentry_init, capture_events):
+    """Test that LiteLLM-specific parameters are captured."""
+    sentry_init(
+        integrations=[LiteLLMIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    messages = [{"role": "user", "content": "Hello!"}]
+    mock_response = MockCompletionResponse()
+
+    with start_transaction(name="litellm test"):
+        kwargs = {
+            "model": "gpt-3.5-turbo",
+            "messages": messages,
+            "api_base": "https://custom-api.example.com",
+            "api_version": "2023-01-01",
+            "custom_llm_provider": "custom_provider",
+        }
+
+        _input_callback(kwargs)
+        _success_callback(
+            kwargs,
+            mock_response,
+            datetime.now(),
+            datetime.now(),
+        )
+
+    (event,) = events
+    (span,) = event["spans"]
+
+    assert span["data"]["gen_ai.litellm.api_base"] == "https://custom-api.example.com"
+    assert span["data"]["gen_ai.litellm.api_version"] == "2023-01-01"
+    assert span["data"]["gen_ai.litellm.custom_llm_provider"] == "custom_provider"
+
+
+def test_no_integration(sentry_init, capture_events):
+    """Test that when integration is not enabled, callbacks don't break."""
+    sentry_init(
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    messages = [{"role": "user", "content": "Hello!"}]
+    mock_response = MockCompletionResponse()
+
+    with start_transaction(name="litellm test"):
+        # When the integration isn't enabled, the callbacks should exit early
+        kwargs = {
+            "model": "gpt-3.5-turbo",
+            "messages": messages,
+        }
+
+        # These should not crash, just do nothing
+        _input_callback(kwargs)
+        _success_callback(
+            kwargs,
+            mock_response,
+            datetime.now(),
+            datetime.now(),
+        )
+
+    (event,) = events
+    # Should still have the transaction, but no child spans since integration is off
+    assert event["type"] == "transaction"
+    assert len(event.get("spans", [])) == 0
+
+
+def test_response_without_usage(sentry_init, capture_events):
+    """Test handling of responses without usage information."""
+    sentry_init(
+        integrations=[LiteLLMIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    messages = [{"role": "user", "content": "Hello!"}]
+
+    # Create a mock response without usage
+    mock_response = type(
+        "obj",
+        (object,),
+        {
+            "model": "gpt-3.5-turbo",
+            "choices": [MockChoice()],
+        },
+    )()
+
+    with start_transaction(name="litellm test"):
+        kwargs = {
+            "model": "gpt-3.5-turbo",
+            "messages": messages,
+        }
+
+        _input_callback(kwargs)
+        _success_callback(
+            kwargs,
+            mock_response,
+            datetime.now(),
+            datetime.now(),
+        )
+
+    (event,) = events
+    (span,) = event["spans"]
+
+    # Span should still be created even without usage info
+    assert span["op"] == OP.GEN_AI_CHAT
+    assert span["description"] == "chat gpt-3.5-turbo"
+
+
+def test_integration_setup(sentry_init):
+    """Test that the integration sets up the callbacks correctly."""
+    sentry_init(
+        integrations=[LiteLLMIntegration()],
+        traces_sample_rate=1.0,
+    )
+
+    # Check that callbacks are registered
+    assert _input_callback in (litellm.input_callback or [])
+    assert _success_callback in (litellm.success_callback or [])
+    assert _failure_callback in (litellm.failure_callback or [])
+
+
+def test_message_dict_extraction(sentry_init, capture_events):
+    """Test that response messages are properly extracted with dict() fallback."""
+    sentry_init(
+        integrations=[LiteLLMIntegration(include_prompts=True)],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+    events = capture_events()
+
+    messages = [{"role": "user", "content": "Hello!"}]
+
+    # Create a message that has dict() method instead of model_dump()
+    class DictMessage:
+        def __init__(self):
+            self.role = "assistant"
+            self.content = "Response"
+            self.tool_calls = None
+
+        def dict(self):
+            return {"role": self.role, "content": self.content}
+
+    mock_response = MockCompletionResponse(choices=[MockChoice(message=DictMessage())])
+
+    with start_transaction(name="litellm test"):
+        kwargs = {
+            "model": "gpt-3.5-turbo",
+            "messages": messages,
+        }
+
+        _input_callback(kwargs)
+        _success_callback(
+            kwargs,
+            mock_response,
+            datetime.now(),
+            datetime.now(),
+        )
+
+    (event,) = events
+    (span,) = event["spans"]
+
+    # Should have extracted the response message
+    assert SPANDATA.GEN_AI_RESPONSE_TEXT in span["data"]
+
+
+def test_litellm_message_truncation(sentry_init, capture_events):
+    """Test that large messages are truncated properly in LiteLLM integration."""
+    sentry_init(
+        integrations=[LiteLLMIntegration(include_prompts=True)],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+    events = capture_events()
+
+    large_content = (
+        "This is a very long message that will exceed our size limits. " * 1000
+    )
+    messages = [
+        {"role": "user", "content": "small message 1"},
+        {"role": "assistant", "content": large_content},
+        {"role": "user", "content": large_content},
+        {"role": "assistant", "content": "small message 4"},
+        {"role": "user", "content": "small message 5"},
+    ]
+    mock_response = MockCompletionResponse()
+
+    with start_transaction(name="litellm test"):
+        kwargs = {
+            "model": "gpt-3.5-turbo",
+            "messages": messages,
+        }
+
+        _input_callback(kwargs)
+        _success_callback(
+            kwargs,
+            mock_response,
+            datetime.now(),
+            datetime.now(),
+        )
+
+    assert len(events) > 0
+    tx = events[0]
+    assert tx["type"] == "transaction"
+
+    chat_spans = [
+        span for span in tx.get("spans", []) if span.get("op") == OP.GEN_AI_CHAT
+    ]
+    assert len(chat_spans) > 0
+
+    chat_span = chat_spans[0]
+    assert SPANDATA.GEN_AI_REQUEST_MESSAGES in chat_span["data"]
+
+    messages_data = chat_span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES]
+    assert isinstance(messages_data, str)
+
+    parsed_messages = json.loads(messages_data)
+    assert isinstance(parsed_messages, list)
+    assert len(parsed_messages) == 2
+    assert "small message 4" in str(parsed_messages[0])
+    assert "small message 5" in str(parsed_messages[1])
+    assert tx["_meta"]["spans"]["0"]["data"]["gen_ai.request.messages"][""]["len"] == 5
diff --git a/tests/integrations/litestar/__init__.py b/tests/integrations/litestar/__init__.py
new file mode 100644
index 0000000000..3a4a6235de
--- /dev/null
+++ b/tests/integrations/litestar/__init__.py
@@ -0,0 +1,3 @@
+import pytest
+
+pytest.importorskip("litestar")
diff --git a/tests/integrations/litestar/test_litestar.py b/tests/integrations/litestar/test_litestar.py
new file mode 100644
index 0000000000..b064c17112
--- /dev/null
+++ b/tests/integrations/litestar/test_litestar.py
@@ -0,0 +1,493 @@
+from __future__ import annotations
+import functools
+
+from litestar.exceptions import HTTPException
+import pytest
+
+from sentry_sdk import capture_message
+from sentry_sdk.integrations.litestar import LitestarIntegration
+
+from typing import Any
+
+from litestar import Litestar, get, Controller
+from litestar.logging.config import LoggingConfig
+from litestar.middleware import AbstractMiddleware
+from litestar.middleware.logging import LoggingMiddlewareConfig
+from litestar.middleware.rate_limit import RateLimitConfig
+from litestar.middleware.session.server_side import ServerSideSessionConfig
+from litestar.testing import TestClient
+
+from tests.integrations.conftest import parametrize_test_configurable_status_codes
+
+
+def litestar_app_factory(middleware=None, debug=True, exception_handlers=None):
+    class MyController(Controller):
+        path = "/controller"
+
+        @get("/error")
+        async def controller_error(self) -> None:
+            raise Exception("Whoa")
+
+    @get("/some_url")
+    async def homepage_handler() -> "dict[str, Any]":
+        1 / 0
+        return {"status": "ok"}
+
+    @get("/custom_error", name="custom_name")
+    async def custom_error() -> Any:
+        raise Exception("Too Hot")
+
+    @get("/message")
+    async def message() -> "dict[str, Any]":
+        capture_message("hi")
+        return {"status": "ok"}
+
+    @get("/message/{message_id:str}")
+    async def message_with_id() -> "dict[str, Any]":
+        capture_message("hi")
+        return {"status": "ok"}
+
+    logging_config = LoggingConfig()
+
+    app = Litestar(
+        route_handlers=[
+            homepage_handler,
+            custom_error,
+            message,
+            message_with_id,
+            MyController,
+        ],
+        debug=debug,
+        middleware=middleware,
+        logging_config=logging_config,
+        exception_handlers=exception_handlers,
+    )
+
+    return app
+
+
+@pytest.mark.parametrize(
+    "test_url,expected_error,expected_message,expected_tx_name",
+    [
+        (
+            "/some_url",
+            ZeroDivisionError,
+            "division by zero",
+            "tests.integrations.litestar.test_litestar.litestar_app_factory..homepage_handler",
+        ),
+        (
+            "/custom_error",
+            Exception,
+            "Too Hot",
+            "custom_name",
+        ),
+        (
+            "/controller/error",
+            Exception,
+            "Whoa",
+            "tests.integrations.litestar.test_litestar.litestar_app_factory..MyController.controller_error",
+        ),
+    ],
+)
+def test_catch_exceptions(
+    sentry_init,
+    capture_exceptions,
+    capture_events,
+    test_url,
+    expected_error,
+    expected_message,
+    expected_tx_name,
+):
+    sentry_init(integrations=[LitestarIntegration()])
+    litestar_app = litestar_app_factory()
+    exceptions = capture_exceptions()
+    events = capture_events()
+
+    client = TestClient(litestar_app)
+    try:
+        client.get(test_url)
+    except Exception:
+        pass
+
+    (exc,) = exceptions
+    assert isinstance(exc, expected_error)
+    assert str(exc) == expected_message
+
+    (event,) = events
+    assert expected_tx_name in event["transaction"]
+    assert event["exception"]["values"][0]["mechanism"]["type"] == "litestar"
+
+
+def test_middleware_spans(sentry_init, capture_events):
+    sentry_init(
+        traces_sample_rate=1.0,
+        integrations=[LitestarIntegration()],
+    )
+
+    logging_config = LoggingMiddlewareConfig()
+    session_config = ServerSideSessionConfig()
+    rate_limit_config = RateLimitConfig(rate_limit=("hour", 5))
+
+    litestar_app = litestar_app_factory(
+        middleware=[
+            session_config.middleware,
+            logging_config.middleware,
+            rate_limit_config.middleware,
+        ]
+    )
+    events = capture_events()
+
+    client = TestClient(
+        litestar_app, raise_server_exceptions=False, base_url="http://testserver.local"
+    )
+    client.get("/message")
+
+    (_, transaction_event) = events
+
+    expected = {"SessionMiddleware", "LoggingMiddleware", "RateLimitMiddleware"}
+    found = set()
+
+    litestar_spans = (
+        span
+        for span in transaction_event["spans"]
+        if span["op"] == "middleware.litestar"
+    )
+
+    for span in litestar_spans:
+        assert span["description"] in expected
+        assert span["description"] not in found
+        found.add(span["description"])
+        assert span["description"] == span["tags"]["litestar.middleware_name"]
+
+
+def test_middleware_callback_spans(sentry_init, capture_events):
+    class SampleMiddleware(AbstractMiddleware):
+        async def __call__(self, scope, receive, send) -> None:
+            async def do_stuff(message):
+                if message["type"] == "http.response.start":
+                    # do something here.
+                    pass
+                await send(message)
+
+            await self.app(scope, receive, do_stuff)
+
+    sentry_init(
+        traces_sample_rate=1.0,
+        integrations=[LitestarIntegration()],
+    )
+    litestar_app = litestar_app_factory(middleware=[SampleMiddleware])
+    events = capture_events()
+
+    client = TestClient(litestar_app, raise_server_exceptions=False)
+    client.get("/message")
+
+    (_, transaction_events) = events
+
+    expected_litestar_spans = [
+        {
+            "op": "middleware.litestar",
+            "description": "SampleMiddleware",
+            "tags": {"litestar.middleware_name": "SampleMiddleware"},
+        },
+        {
+            "op": "middleware.litestar.send",
+            "description": "SentryAsgiMiddleware._run_app.._sentry_wrapped_send",
+            "tags": {"litestar.middleware_name": "SampleMiddleware"},
+        },
+        {
+            "op": "middleware.litestar.send",
+            "description": "SentryAsgiMiddleware._run_app.._sentry_wrapped_send",
+            "tags": {"litestar.middleware_name": "SampleMiddleware"},
+        },
+    ]
+
+    def is_matching_span(expected_span, actual_span):
+        return (
+            expected_span["op"] == actual_span["op"]
+            and expected_span["description"] == actual_span["description"]
+            and expected_span["tags"] == actual_span["tags"]
+        )
+
+    actual_litestar_spans = list(
+        span
+        for span in transaction_events["spans"]
+        if "middleware.litestar" in span["op"]
+    )
+    assert len(actual_litestar_spans) == 3
+
+    for expected_span in expected_litestar_spans:
+        assert any(
+            is_matching_span(expected_span, actual_span)
+            for actual_span in actual_litestar_spans
+        )
+
+
+def test_middleware_receive_send(sentry_init, capture_events):
+    class SampleReceiveSendMiddleware(AbstractMiddleware):
+        async def __call__(self, scope, receive, send):
+            message = await receive()
+            assert message
+            assert message["type"] == "http.request"
+
+            send_output = await send({"type": "something-unimportant"})
+            assert send_output is None
+
+            await self.app(scope, receive, send)
+
+    sentry_init(
+        traces_sample_rate=1.0,
+        integrations=[LitestarIntegration()],
+    )
+    litestar_app = litestar_app_factory(middleware=[SampleReceiveSendMiddleware])
+
+    client = TestClient(litestar_app, raise_server_exceptions=False)
+    # See SampleReceiveSendMiddleware.__call__ above for assertions of correct behavior
+    client.get("/message")
+
+
+def test_middleware_partial_receive_send(sentry_init, capture_events):
+    class SamplePartialReceiveSendMiddleware(AbstractMiddleware):
+        async def __call__(self, scope, receive, send):
+            message = await receive()
+            assert message
+            assert message["type"] == "http.request"
+
+            send_output = await send({"type": "something-unimportant"})
+            assert send_output is None
+
+            async def my_receive(*args, **kwargs):
+                pass
+
+            async def my_send(*args, **kwargs):
+                pass
+
+            partial_receive = functools.partial(my_receive)
+            partial_send = functools.partial(my_send)
+
+            await self.app(scope, partial_receive, partial_send)
+
+    sentry_init(
+        traces_sample_rate=1.0,
+        integrations=[LitestarIntegration()],
+    )
+    litestar_app = litestar_app_factory(middleware=[SamplePartialReceiveSendMiddleware])
+    events = capture_events()
+
+    client = TestClient(litestar_app, raise_server_exceptions=False)
+    # See SamplePartialReceiveSendMiddleware.__call__ above for assertions of correct behavior
+    client.get("/message")
+
+    (_, transaction_events) = events
+
+    expected_litestar_spans = [
+        {
+            "op": "middleware.litestar",
+            "description": "SamplePartialReceiveSendMiddleware",
+            "tags": {"litestar.middleware_name": "SamplePartialReceiveSendMiddleware"},
+        },
+        {
+            "op": "middleware.litestar.receive",
+            "description": "TestClientTransport.create_receive..receive",
+            "tags": {"litestar.middleware_name": "SamplePartialReceiveSendMiddleware"},
+        },
+        {
+            "op": "middleware.litestar.send",
+            "description": "SentryAsgiMiddleware._run_app.._sentry_wrapped_send",
+            "tags": {"litestar.middleware_name": "SamplePartialReceiveSendMiddleware"},
+        },
+    ]
+
+    def is_matching_span(expected_span, actual_span):
+        return (
+            expected_span["op"] == actual_span["op"]
+            and actual_span["description"].startswith(expected_span["description"])
+            and expected_span["tags"] == actual_span["tags"]
+        )
+
+    actual_litestar_spans = list(
+        span
+        for span in transaction_events["spans"]
+        if "middleware.litestar" in span["op"]
+    )
+    assert len(actual_litestar_spans) == 3
+
+    for expected_span in expected_litestar_spans:
+        assert any(
+            is_matching_span(expected_span, actual_span)
+            for actual_span in actual_litestar_spans
+        )
+
+
+def test_span_origin(sentry_init, capture_events):
+    sentry_init(
+        integrations=[LitestarIntegration()],
+        traces_sample_rate=1.0,
+    )
+
+    logging_config = LoggingMiddlewareConfig()
+    session_config = ServerSideSessionConfig()
+    rate_limit_config = RateLimitConfig(rate_limit=("hour", 5))
+
+    litestar_app = litestar_app_factory(
+        middleware=[
+            session_config.middleware,
+            logging_config.middleware,
+            rate_limit_config.middleware,
+        ]
+    )
+    events = capture_events()
+
+    client = TestClient(
+        litestar_app, raise_server_exceptions=False, base_url="http://testserver.local"
+    )
+    client.get("/message")
+
+    (_, event) = events
+
+    assert event["contexts"]["trace"]["origin"] == "auto.http.litestar"
+    for span in event["spans"]:
+        assert span["origin"] == "auto.http.litestar"
+
+
+@pytest.mark.parametrize(
+    "is_send_default_pii",
+    [
+        True,
+        False,
+    ],
+    ids=[
+        "send_default_pii=True",
+        "send_default_pii=False",
+    ],
+)
+def test_litestar_scope_user_on_exception_event(
+    sentry_init, capture_exceptions, capture_events, is_send_default_pii
+):
+    class TestUserMiddleware(AbstractMiddleware):
+        async def __call__(self, scope, receive, send):
+            scope["user"] = {
+                "email": "lennon@thebeatles.com",
+                "username": "john",
+                "id": "1",
+            }
+            await self.app(scope, receive, send)
+
+    sentry_init(
+        integrations=[LitestarIntegration()], send_default_pii=is_send_default_pii
+    )
+    litestar_app = litestar_app_factory(middleware=[TestUserMiddleware])
+    exceptions = capture_exceptions()
+    events = capture_events()
+
+    # This request intentionally raises an exception
+    client = TestClient(litestar_app)
+    try:
+        client.get("/some_url")
+    except Exception:
+        pass
+
+    assert len(exceptions) == 1
+    assert len(events) == 1
+    (event,) = events
+
+    if is_send_default_pii:
+        assert "user" in event
+        assert event["user"] == {
+            "email": "lennon@thebeatles.com",
+            "username": "john",
+            "id": "1",
+        }
+    else:
+        assert "user" not in event
+
+
+@parametrize_test_configurable_status_codes
+def test_configurable_status_codes_handler(
+    sentry_init,
+    capture_events,
+    failed_request_status_codes,
+    status_code,
+    expected_error,
+):
+    integration_kwargs = (
+        {"failed_request_status_codes": failed_request_status_codes}
+        if failed_request_status_codes is not None
+        else {}
+    )
+    sentry_init(integrations=[LitestarIntegration(**integration_kwargs)])
+
+    events = capture_events()
+
+    @get("/error")
+    async def error() -> None:
+        raise HTTPException(status_code=status_code)
+
+    app = Litestar([error])
+    client = TestClient(app)
+    client.get("/error")
+
+    assert len(events) == int(expected_error)
+
+
+@parametrize_test_configurable_status_codes
+def test_configurable_status_codes_middleware(
+    sentry_init,
+    capture_events,
+    failed_request_status_codes,
+    status_code,
+    expected_error,
+):
+    integration_kwargs = (
+        {"failed_request_status_codes": failed_request_status_codes}
+        if failed_request_status_codes is not None
+        else {}
+    )
+    sentry_init(integrations=[LitestarIntegration(**integration_kwargs)])
+
+    events = capture_events()
+
+    def create_raising_middleware(app):
+        async def raising_middleware(scope, receive, send):
+            raise HTTPException(status_code=status_code)
+
+        return raising_middleware
+
+    @get("/error")
+    async def error() -> None: ...
+
+    app = Litestar([error], middleware=[create_raising_middleware])
+    client = TestClient(app)
+    client.get("/error")
+
+    assert len(events) == int(expected_error)
+
+
+def test_catch_non_http_exceptions_in_middleware(
+    sentry_init,
+    capture_events,
+):
+    sentry_init(integrations=[LitestarIntegration()])
+
+    events = capture_events()
+
+    def create_raising_middleware(app):
+        async def raising_middleware(scope, receive, send):
+            raise RuntimeError("Too Hot")
+
+        return raising_middleware
+
+    @get("/error")
+    async def error() -> None: ...
+
+    app = Litestar([error], middleware=[create_raising_middleware])
+    client = TestClient(app)
+
+    try:
+        client.get("/error")
+    except RuntimeError:
+        pass
+
+    assert len(events) == 1
+    event_exception = events[0]["exception"]["values"][0]
+    assert event_exception["type"] == "RuntimeError"
+    assert event_exception["value"] == "Too Hot"
diff --git a/tests/integrations/logging/test_logging.py b/tests/integrations/logging/test_logging.py
index 92d0674c09..7b144f4b55 100644
--- a/tests/integrations/logging/test_logging.py
+++ b/tests/integrations/logging/test_logging.py
@@ -1,11 +1,12 @@
-# coding: utf-8
-import sys
-
-import pytest
 import logging
 import warnings
 
+import pytest
+
+from sentry_sdk import get_client
+from sentry_sdk.consts import VERSION
 from sentry_sdk.integrations.logging import LoggingIntegration, ignore_logger
+from tests.test_logs import envelopes_to_logs
 
 other_logger = logging.getLogger("testfoo")
 logger = logging.getLogger(__name__)
@@ -28,6 +29,7 @@ def test_logging_works_with_many_loggers(sentry_init, capture_events, logger):
     assert event["level"] == "fatal"
     assert not event["logentry"]["params"]
     assert event["logentry"]["message"] == "LOL"
+    assert event["logentry"]["formatted"] == "LOL"
     assert any(crumb["message"] == "bread" for crumb in event["breadcrumbs"]["values"])
 
 
@@ -79,12 +81,18 @@ def test_logging_extra_data_integer_keys(sentry_init, capture_events):
     assert event["extra"] == {"1": 1}
 
 
-@pytest.mark.xfail(sys.version_info[:2] == (3, 4), reason="buggy logging module")
-def test_logging_stack(sentry_init, capture_events):
+@pytest.mark.parametrize(
+    "enable_stack_trace_kwarg",
+    (
+        pytest.param({"exc_info": True}, id="exc_info"),
+        pytest.param({"stack_info": True}, id="stack_info"),
+    ),
+)
+def test_logging_stack_trace(sentry_init, capture_events, enable_stack_trace_kwarg):
     sentry_init(integrations=[LoggingIntegration()], default_integrations=False)
     events = capture_events()
 
-    logger.error("first", exc_info=True)
+    logger.error("first", **enable_stack_trace_kwarg)
     logger.error("second")
 
     (
@@ -108,6 +116,7 @@ def test_logging_level(sentry_init, capture_events):
     (event,) = events
     assert event["level"] == "error"
     assert event["logentry"]["message"] == "hi"
+    assert event["logentry"]["formatted"] == "hi"
 
     del events[:]
 
@@ -128,9 +137,7 @@ def test_custom_log_level_names(sentry_init, capture_events):
     }
 
     # set custom log level names
-    # fmt: off
-    logging.addLevelName(logging.DEBUG, u"custom level debüg: ")
-    # fmt: on
+    logging.addLevelName(logging.DEBUG, "custom level debüg: ")
     logging.addLevelName(logging.INFO, "")
     logging.addLevelName(logging.WARN, "custom level warn: ")
     logging.addLevelName(logging.WARNING, "custom level warning: ")
@@ -150,6 +157,7 @@ def test_custom_log_level_names(sentry_init, capture_events):
         assert events
         assert events[0]["level"] == sentry_level
         assert events[0]["logentry"]["message"] == "Trying level %s"
+        assert events[0]["logentry"]["formatted"] == f"Trying level {logging_level}"
         assert events[0]["logentry"]["params"] == [logging_level]
 
         del events[:]
@@ -175,6 +183,7 @@ def filter(self, record):
 
     (event,) = events
     assert event["logentry"]["message"] == "hi"
+    assert event["logentry"]["formatted"] == "hi"
 
 
 def test_logging_captured_warnings(sentry_init, capture_events, recwarn):
@@ -196,10 +205,16 @@ def test_logging_captured_warnings(sentry_init, capture_events, recwarn):
     assert events[0]["level"] == "warning"
     # Captured warnings start with the path where the warning was raised
     assert "UserWarning: first" in events[0]["logentry"]["message"]
+    assert "UserWarning: first" in events[0]["logentry"]["formatted"]
+    # For warnings, the message and formatted message are the same
+    assert events[0]["logentry"]["message"] == events[0]["logentry"]["formatted"]
     assert events[0]["logentry"]["params"] == []
 
     assert events[1]["level"] == "warning"
     assert "UserWarning: second" in events[1]["logentry"]["message"]
+    assert "UserWarning: second" in events[1]["logentry"]["formatted"]
+    # For warnings, the message and formatted message are the same
+    assert events[1]["logentry"]["message"] == events[1]["logentry"]["formatted"]
     assert events[1]["logentry"]["params"] == []
 
     # Using recwarn suppresses the "third" warning in the test output
@@ -218,6 +233,18 @@ def test_ignore_logger(sentry_init, capture_events):
     assert not events
 
 
+def test_ignore_logger_whitespace_padding(sentry_init, capture_events):
+    """Here we test insensitivity to whitespace padding of ignored loggers"""
+    sentry_init(integrations=[LoggingIntegration()], default_integrations=False)
+    events = capture_events()
+
+    ignore_logger("testfoo")
+
+    padded_logger = logging.getLogger("       testfoo   ")
+    padded_logger.error("hi")
+    assert not events
+
+
 def test_ignore_logger_wildcard(sentry_init, capture_events):
     sentry_init(integrations=[LoggingIntegration()], default_integrations=False)
     events = capture_events()
@@ -232,3 +259,337 @@ def test_ignore_logger_wildcard(sentry_init, capture_events):
 
     (event,) = events
     assert event["logentry"]["message"] == "hi"
+    assert event["logentry"]["formatted"] == "hi"
+
+
+def test_logging_dictionary_interpolation(sentry_init, capture_events):
+    """Here we test an entire dictionary being interpolated into the log message."""
+    sentry_init(integrations=[LoggingIntegration()], default_integrations=False)
+    events = capture_events()
+
+    logger.error("this is a log with a dictionary %s", {"foo": "bar"})
+
+    (event,) = events
+    assert event["logentry"]["message"] == "this is a log with a dictionary %s"
+    assert (
+        event["logentry"]["formatted"]
+        == "this is a log with a dictionary {'foo': 'bar'}"
+    )
+    assert event["logentry"]["params"] == {"foo": "bar"}
+
+
+def test_logging_dictionary_args(sentry_init, capture_events):
+    """Here we test items from a dictionary being interpolated into the log message."""
+    sentry_init(integrations=[LoggingIntegration()], default_integrations=False)
+    events = capture_events()
+
+    logger.error(
+        "the value of foo is %(foo)s, and the value of bar is %(bar)s",
+        {"foo": "bar", "bar": "baz"},
+    )
+
+    (event,) = events
+    assert (
+        event["logentry"]["message"]
+        == "the value of foo is %(foo)s, and the value of bar is %(bar)s"
+    )
+    assert (
+        event["logentry"]["formatted"]
+        == "the value of foo is bar, and the value of bar is baz"
+    )
+    assert event["logentry"]["params"] == {"foo": "bar", "bar": "baz"}
+
+
+def test_sentry_logs_warning(sentry_init, capture_envelopes):
+    """
+    The python logger module should create 'warn' sentry logs if the flag is on.
+    """
+    sentry_init(enable_logs=True)
+    envelopes = capture_envelopes()
+
+    python_logger = logging.Logger("test-logger")
+    python_logger.warning("this is %s a template %s", "1", "2")
+
+    get_client().flush()
+    logs = envelopes_to_logs(envelopes)
+    attrs = logs[0]["attributes"]
+    assert attrs["sentry.message.template"] == "this is %s a template %s"
+    assert "code.file.path" in attrs
+    assert "code.line.number" in attrs
+    assert attrs["logger.name"] == "test-logger"
+    assert attrs["sentry.environment"] == "production"
+    assert attrs["sentry.message.parameter.0"] == "1"
+    assert attrs["sentry.message.parameter.1"] == "2"
+    assert attrs["sentry.origin"] == "auto.log.stdlib"
+    assert logs[0]["severity_number"] == 13
+    assert logs[0]["severity_text"] == "warn"
+
+
+def test_sentry_logs_debug(sentry_init, capture_envelopes):
+    """
+    The python logger module should not create 'debug' sentry logs if the flag is on by default
+    """
+    sentry_init(enable_logs=True)
+    envelopes = capture_envelopes()
+
+    python_logger = logging.Logger("test-logger")
+    python_logger.debug("this is %s a template %s", "1", "2")
+    get_client().flush()
+
+    assert len(envelopes) == 0
+
+
+def test_no_log_infinite_loop(sentry_init, capture_envelopes):
+    """
+    If 'debug' mode is true, and you set a low log level in the logging integration, there should be no infinite loops.
+    """
+    sentry_init(
+        enable_logs=True,
+        integrations=[LoggingIntegration(sentry_logs_level=logging.DEBUG)],
+        debug=True,
+    )
+    envelopes = capture_envelopes()
+
+    python_logger = logging.Logger("test-logger")
+    python_logger.debug("this is %s a template %s", "1", "2")
+    get_client().flush()
+
+    assert len(envelopes) == 1
+
+
+def test_logging_errors(sentry_init, capture_envelopes):
+    """
+    The python logger module should be able to log errors without erroring
+    """
+    sentry_init(enable_logs=True)
+    envelopes = capture_envelopes()
+
+    python_logger = logging.Logger("test-logger")
+    python_logger.error(Exception("test exc 1"))
+    python_logger.error("error is %s", Exception("test exc 2"))
+    get_client().flush()
+
+    error_event_1 = envelopes[0].items[0].payload.json
+    assert error_event_1["level"] == "error"
+    error_event_2 = envelopes[1].items[0].payload.json
+    assert error_event_2["level"] == "error"
+
+    logs = envelopes_to_logs(envelopes)
+    assert logs[0]["severity_text"] == "error"
+    assert "sentry.message.template" not in logs[0]["attributes"]
+    assert "sentry.message.parameter.0" not in logs[0]["attributes"]
+    assert "code.line.number" in logs[0]["attributes"]
+
+    assert logs[1]["severity_text"] == "error"
+    assert logs[1]["attributes"]["sentry.message.template"] == "error is %s"
+    assert logs[1]["attributes"]["sentry.message.parameter.0"] in (
+        "Exception('test exc 2')",
+        "Exception('test exc 2',)",  # py3.6
+    )
+    assert "code.line.number" in logs[1]["attributes"]
+
+    assert len(logs) == 2
+
+
+def test_log_strips_project_root(sentry_init, capture_envelopes):
+    """
+    The python logger should strip project roots from the log record path
+    """
+    sentry_init(
+        enable_logs=True,
+        project_root="/custom/test",
+    )
+    envelopes = capture_envelopes()
+
+    python_logger = logging.Logger("test-logger")
+    python_logger.handle(
+        logging.LogRecord(
+            name="test-logger",
+            level=logging.WARN,
+            pathname="/custom/test/blah/path.py",
+            lineno=123,
+            msg="This is a test log with a custom pathname",
+            args=(),
+            exc_info=None,
+        )
+    )
+    get_client().flush()
+
+    logs = envelopes_to_logs(envelopes)
+    assert len(logs) == 1
+    attrs = logs[0]["attributes"]
+    assert attrs["code.file.path"] == "blah/path.py"
+
+
+def test_logger_with_all_attributes(sentry_init, capture_envelopes):
+    """
+    The python logger should be able to log all attributes, including extra data.
+    """
+    sentry_init(enable_logs=True)
+    envelopes = capture_envelopes()
+
+    python_logger = logging.Logger("test-logger")
+    python_logger.warning(
+        "log #%d",
+        1,
+        extra={"foo": "bar", "numeric": 42, "more_complex": {"nested": "data"}},
+    )
+    get_client().flush()
+
+    logs = envelopes_to_logs(envelopes)
+
+    assert "span_id" in logs[0]
+    assert isinstance(logs[0]["span_id"], str)
+
+    attributes = logs[0]["attributes"]
+
+    assert "process.pid" in attributes
+    assert isinstance(attributes["process.pid"], int)
+    del attributes["process.pid"]
+
+    assert "sentry.release" in attributes
+    assert isinstance(attributes["sentry.release"], str)
+    del attributes["sentry.release"]
+
+    assert "server.address" in attributes
+    assert isinstance(attributes["server.address"], str)
+    del attributes["server.address"]
+
+    assert "thread.id" in attributes
+    assert isinstance(attributes["thread.id"], int)
+    del attributes["thread.id"]
+
+    assert "code.file.path" in attributes
+    assert isinstance(attributes["code.file.path"], str)
+    del attributes["code.file.path"]
+
+    assert "code.function.name" in attributes
+    assert isinstance(attributes["code.function.name"], str)
+    del attributes["code.function.name"]
+
+    assert "code.line.number" in attributes
+    assert isinstance(attributes["code.line.number"], int)
+    del attributes["code.line.number"]
+
+    assert "process.executable.name" in attributes
+    assert isinstance(attributes["process.executable.name"], str)
+    del attributes["process.executable.name"]
+
+    assert "thread.name" in attributes
+    assert isinstance(attributes["thread.name"], str)
+    del attributes["thread.name"]
+
+    assert attributes.pop("sentry.sdk.name").startswith("sentry.python")
+
+    # Assert on the remaining non-dynamic attributes.
+    assert attributes == {
+        "foo": "bar",
+        "numeric": 42,
+        "more_complex": "{'nested': 'data'}",
+        "logger.name": "test-logger",
+        "sentry.origin": "auto.log.stdlib",
+        "sentry.message.template": "log #%d",
+        "sentry.message.parameter.0": 1,
+        "sentry.environment": "production",
+        "sentry.sdk.version": VERSION,
+        "sentry.severity_number": 13,
+        "sentry.severity_text": "warn",
+    }
+
+
+def test_sentry_logs_named_parameters(sentry_init, capture_envelopes):
+    """
+    The python logger module should capture named parameters from dictionary arguments in Sentry logs.
+    """
+    sentry_init(enable_logs=True)
+    envelopes = capture_envelopes()
+
+    python_logger = logging.Logger("test-logger")
+    python_logger.info(
+        "%(source)s call completed, %(input_tk)i input tk, %(output_tk)i output tk (model %(model)s, cost $%(cost).4f)",
+        {
+            "source": "test_source",
+            "input_tk": 100,
+            "output_tk": 50,
+            "model": "gpt-4",
+            "cost": 0.0234,
+        },
+    )
+
+    get_client().flush()
+    logs = envelopes_to_logs(envelopes)
+
+    assert len(logs) == 1
+    attrs = logs[0]["attributes"]
+
+    # Check that the template is captured
+    assert (
+        attrs["sentry.message.template"]
+        == "%(source)s call completed, %(input_tk)i input tk, %(output_tk)i output tk (model %(model)s, cost $%(cost).4f)"
+    )
+
+    # Check that dictionary arguments are captured as named parameters
+    assert attrs["sentry.message.parameter.source"] == "test_source"
+    assert attrs["sentry.message.parameter.input_tk"] == 100
+    assert attrs["sentry.message.parameter.output_tk"] == 50
+    assert attrs["sentry.message.parameter.model"] == "gpt-4"
+    assert attrs["sentry.message.parameter.cost"] == 0.0234
+
+    # Check other standard attributes
+    assert attrs["logger.name"] == "test-logger"
+    assert attrs["sentry.origin"] == "auto.log.stdlib"
+    assert logs[0]["severity_number"] == 9  # info level
+    assert logs[0]["severity_text"] == "info"
+
+
+def test_sentry_logs_named_parameters_complex_values(sentry_init, capture_envelopes):
+    """
+    The python logger module should handle complex values in named parameters using safe_repr.
+    """
+    sentry_init(enable_logs=True)
+    envelopes = capture_envelopes()
+
+    python_logger = logging.Logger("test-logger")
+    complex_object = {"nested": {"data": [1, 2, 3]}, "tuple": (4, 5, 6)}
+    python_logger.warning(
+        "Processing %(simple)s with %(complex)s data",
+        {
+            "simple": "simple_value",
+            "complex": complex_object,
+        },
+    )
+
+    get_client().flush()
+    logs = envelopes_to_logs(envelopes)
+
+    assert len(logs) == 1
+    attrs = logs[0]["attributes"]
+
+    # Check that simple values are kept as-is
+    assert attrs["sentry.message.parameter.simple"] == "simple_value"
+
+    # Check that complex values are converted using safe_repr
+    assert "sentry.message.parameter.complex" in attrs
+    complex_param = attrs["sentry.message.parameter.complex"]
+    assert isinstance(complex_param, str)
+    assert "nested" in complex_param
+    assert "data" in complex_param
+
+
+def test_sentry_logs_no_parameters_no_template(sentry_init, capture_envelopes):
+    """
+    There shouldn't be a template if there are no parameters.
+    """
+    sentry_init(enable_logs=True)
+    envelopes = capture_envelopes()
+
+    python_logger = logging.Logger("test-logger")
+    python_logger.warning("Warning about something without any parameters.")
+
+    get_client().flush()
+    logs = envelopes_to_logs(envelopes)
+
+    assert len(logs) == 1
+
+    attrs = logs[0]["attributes"]
+    assert "sentry.message.template" not in attrs
diff --git a/tests/integrations/loguru/test_loguru.py b/tests/integrations/loguru/test_loguru.py
index 48133aab85..66cc336de5 100644
--- a/tests/integrations/loguru/test_loguru.py
+++ b/tests/integrations/loguru/test_loguru.py
@@ -1,25 +1,31 @@
+from unittest.mock import MagicMock, patch
+import re
+
 import pytest
 from loguru import logger
+from loguru._recattrs import RecordFile, RecordLevel
 
 import sentry_sdk
+from sentry_sdk.consts import VERSION
 from sentry_sdk.integrations.loguru import LoguruIntegration, LoggingLevels
+from tests.test_logs import envelopes_to_logs
 
 logger.remove(0)  # don't print to console
 
 
 @pytest.mark.parametrize(
-    "level,created_event",
+    "level,created_event,expected_sentry_level",
     [
         # None - no breadcrumb
         # False - no event
         # True - event created
-        (LoggingLevels.TRACE, None),
-        (LoggingLevels.DEBUG, None),
-        (LoggingLevels.INFO, False),
-        (LoggingLevels.SUCCESS, False),
-        (LoggingLevels.WARNING, False),
-        (LoggingLevels.ERROR, True),
-        (LoggingLevels.CRITICAL, True),
+        (LoggingLevels.TRACE, None, "debug"),
+        (LoggingLevels.DEBUG, None, "debug"),
+        (LoggingLevels.INFO, False, "info"),
+        (LoggingLevels.SUCCESS, False, "info"),
+        (LoggingLevels.WARNING, False, "warning"),
+        (LoggingLevels.ERROR, True, "error"),
+        (LoggingLevels.CRITICAL, True, "critical"),
     ],
 )
 @pytest.mark.parametrize("disable_breadcrumbs", [True, False])
@@ -29,9 +35,15 @@ def test_just_log(
     capture_events,
     level,
     created_event,
+    expected_sentry_level,
     disable_breadcrumbs,
     disable_events,
+    uninstall_integration,
+    request,
 ):
+    uninstall_integration("loguru")
+    request.addfinalizer(logger.remove)
+
     sentry_init(
         integrations=[
             LoguruIntegration(
@@ -45,23 +57,23 @@ def test_just_log(
 
     getattr(logger, level.name.lower())("test")
 
-    formatted_message = (
-        " | "
-        + "{:9}".format(level.name.upper())
-        + "| tests.integrations.loguru.test_loguru:test_just_log:46 - test"
+    expected_pattern = (
+        r" \| "
+        + r"{:9}".format(level.name.upper())
+        + r"\| tests\.integrations\.loguru\.test_loguru:test_just_log:\d+ - test"
     )
 
     if not created_event:
         assert not events
 
-        breadcrumbs = sentry_sdk.Hub.current.scope._breadcrumbs
+        breadcrumbs = sentry_sdk.get_isolation_scope()._breadcrumbs
         if (
             not disable_breadcrumbs and created_event is not None
         ):  # not None == not TRACE or DEBUG level
             (breadcrumb,) = breadcrumbs
-            assert breadcrumb["level"] == level.name.lower()
+            assert breadcrumb["level"] == expected_sentry_level
             assert breadcrumb["category"] == "tests.integrations.loguru.test_loguru"
-            assert breadcrumb["message"][23:] == formatted_message
+            assert re.fullmatch(expected_pattern, breadcrumb["message"][23:])
         else:
             assert not breadcrumbs
 
@@ -72,12 +84,15 @@ def test_just_log(
         return
 
     (event,) = events
-    assert event["level"] == (level.name.lower())
+    assert event["level"] == expected_sentry_level
     assert event["logger"] == "tests.integrations.loguru.test_loguru"
-    assert event["logentry"]["message"][23:] == formatted_message
+    assert re.fullmatch(expected_pattern, event["logentry"]["message"][23:])
+
 
+def test_breadcrumb_format(sentry_init, capture_events, uninstall_integration, request):
+    uninstall_integration("loguru")
+    request.addfinalizer(logger.remove)
 
-def test_breadcrumb_format(sentry_init, capture_events):
     sentry_init(
         integrations=[
             LoguruIntegration(
@@ -92,12 +107,15 @@ def test_breadcrumb_format(sentry_init, capture_events):
     logger.info("test")
     formatted_message = "test"
 
-    breadcrumbs = sentry_sdk.Hub.current.scope._breadcrumbs
+    breadcrumbs = sentry_sdk.get_isolation_scope()._breadcrumbs
     (breadcrumb,) = breadcrumbs
     assert breadcrumb["message"] == formatted_message
 
 
-def test_event_format(sentry_init, capture_events):
+def test_event_format(sentry_init, capture_events, uninstall_integration, request):
+    uninstall_integration("loguru")
+    request.addfinalizer(logger.remove)
+
     sentry_init(
         integrations=[
             LoguruIntegration(
@@ -115,3 +133,455 @@ def test_event_format(sentry_init, capture_events):
 
     (event,) = events
     assert event["logentry"]["message"] == formatted_message
+
+
+def test_sentry_logs_warning(
+    sentry_init, capture_envelopes, uninstall_integration, request
+):
+    uninstall_integration("loguru")
+    request.addfinalizer(logger.remove)
+
+    sentry_init(enable_logs=True)
+    envelopes = capture_envelopes()
+
+    logger.warning("this is {} a {}", "just", "template")
+
+    sentry_sdk.get_client().flush()
+    logs = envelopes_to_logs(envelopes)
+
+    attrs = logs[0]["attributes"]
+    assert "code.file.path" in attrs
+    assert "code.line.number" in attrs
+    assert attrs["logger.name"] == "tests.integrations.loguru.test_loguru"
+    assert attrs["sentry.environment"] == "production"
+    assert attrs["sentry.origin"] == "auto.log.loguru"
+    assert logs[0]["severity_number"] == 13
+    assert logs[0]["severity_text"] == "warn"
+
+
+def test_sentry_logs_debug(
+    sentry_init, capture_envelopes, uninstall_integration, request
+):
+    uninstall_integration("loguru")
+    request.addfinalizer(logger.remove)
+
+    sentry_init(enable_logs=True)
+    envelopes = capture_envelopes()
+
+    logger.debug("this is %s a template %s", "1", "2")
+    sentry_sdk.get_client().flush()
+
+    assert len(envelopes) == 0
+
+
+def test_sentry_log_levels(
+    sentry_init, capture_envelopes, uninstall_integration, request
+):
+    uninstall_integration("loguru")
+    request.addfinalizer(logger.remove)
+
+    sentry_init(
+        integrations=[LoguruIntegration(sentry_logs_level=LoggingLevels.SUCCESS)],
+        enable_logs=True,
+    )
+    envelopes = capture_envelopes()
+
+    logger.trace("this is a log")
+    logger.debug("this is a log")
+    logger.info("this is a log")
+    logger.success("this is a log")
+    logger.warning("this is a log")
+    logger.error("this is a log")
+    logger.critical("this is a log")
+
+    sentry_sdk.get_client().flush()
+    logs = envelopes_to_logs(envelopes)
+    assert len(logs) == 4
+
+    assert logs[0]["severity_number"] == 11
+    assert logs[0]["severity_text"] == "info"
+    assert logs[1]["severity_number"] == 13
+    assert logs[1]["severity_text"] == "warn"
+    assert logs[2]["severity_number"] == 17
+    assert logs[2]["severity_text"] == "error"
+    assert logs[3]["severity_number"] == 21
+    assert logs[3]["severity_text"] == "fatal"
+
+
+def test_disable_loguru_logs(
+    sentry_init, capture_envelopes, uninstall_integration, request
+):
+    uninstall_integration("loguru")
+    request.addfinalizer(logger.remove)
+
+    sentry_init(
+        integrations=[LoguruIntegration(sentry_logs_level=None)],
+        enable_logs=True,
+    )
+    envelopes = capture_envelopes()
+
+    logger.trace("this is a log")
+    logger.debug("this is a log")
+    logger.info("this is a log")
+    logger.success("this is a log")
+    logger.warning("this is a log")
+    logger.error("this is a log")
+    logger.critical("this is a log")
+
+    sentry_sdk.get_client().flush()
+    logs = envelopes_to_logs(envelopes)
+    assert len(logs) == 0
+
+
+def test_disable_sentry_logs(
+    sentry_init, capture_envelopes, uninstall_integration, request
+):
+    uninstall_integration("loguru")
+    request.addfinalizer(logger.remove)
+
+    sentry_init(
+        _experiments={"enable_logs": False},
+    )
+    envelopes = capture_envelopes()
+
+    logger.trace("this is a log")
+    logger.debug("this is a log")
+    logger.info("this is a log")
+    logger.success("this is a log")
+    logger.warning("this is a log")
+    logger.error("this is a log")
+    logger.critical("this is a log")
+
+    sentry_sdk.get_client().flush()
+    logs = envelopes_to_logs(envelopes)
+    assert len(logs) == 0
+
+
+def test_no_log_infinite_loop(
+    sentry_init, capture_envelopes, uninstall_integration, request
+):
+    """
+    In debug mode, there should be no infinite loops even when a low log level is set.
+    """
+    uninstall_integration("loguru")
+    request.addfinalizer(logger.remove)
+
+    sentry_init(
+        enable_logs=True,
+        integrations=[LoguruIntegration(sentry_logs_level=LoggingLevels.DEBUG)],
+        debug=True,
+    )
+    envelopes = capture_envelopes()
+
+    logger.debug("this is %s a template %s", "1", "2")
+    sentry_sdk.get_client().flush()
+
+    assert len(envelopes) == 1
+
+
+def test_logging_errors(sentry_init, capture_envelopes, uninstall_integration, request):
+    """We're able to log errors without erroring."""
+    uninstall_integration("loguru")
+    request.addfinalizer(logger.remove)
+
+    sentry_init(enable_logs=True)
+    envelopes = capture_envelopes()
+
+    logger.error(Exception("test exc 1"))
+    logger.error("error is %s", Exception("test exc 2"))
+    sentry_sdk.get_client().flush()
+
+    error_event_1 = envelopes[0].items[0].payload.json
+    assert error_event_1["level"] == "error"
+    error_event_2 = envelopes[1].items[0].payload.json
+    assert error_event_2["level"] == "error"
+
+    logs = envelopes_to_logs(envelopes)
+    assert logs[0]["severity_text"] == "error"
+    assert "code.line.number" in logs[0]["attributes"]
+
+    assert logs[1]["severity_text"] == "error"
+    assert "code.line.number" in logs[1]["attributes"]
+
+    assert len(logs) == 2
+
+
+def test_log_strips_project_root(
+    sentry_init, capture_envelopes, uninstall_integration, request
+):
+    uninstall_integration("loguru")
+    request.addfinalizer(logger.remove)
+
+    sentry_init(
+        enable_logs=True,
+        project_root="/custom/test",
+    )
+    envelopes = capture_envelopes()
+
+    class FakeMessage:
+        def __init__(self, *args, **kwargs):
+            pass
+
+        @property
+        def record(self):
+            return {
+                "elapsed": MagicMock(),
+                "exception": None,
+                "file": RecordFile(name="app.py", path="/custom/test/blah/path.py"),
+                "function": "",
+                "level": RecordLevel(name="ERROR", no=20, icon=""),
+                "line": 35,
+                "message": "some message",
+                "module": "app",
+                "name": "__main__",
+                "process": MagicMock(),
+                "thread": MagicMock(),
+                "time": MagicMock(),
+                "extra": MagicMock(),
+            }
+
+        @record.setter
+        def record(self, val):
+            pass
+
+    with patch("loguru._handler.Message", FakeMessage):
+        logger.error("some message")
+
+    sentry_sdk.get_client().flush()
+
+    logs = envelopes_to_logs(envelopes)
+    assert len(logs) == 1
+    attrs = logs[0]["attributes"]
+    assert attrs["code.file.path"] == "blah/path.py"
+
+
+def test_log_keeps_full_path_if_not_in_project_root(
+    sentry_init, capture_envelopes, uninstall_integration, request
+):
+    uninstall_integration("loguru")
+    request.addfinalizer(logger.remove)
+
+    sentry_init(
+        enable_logs=True,
+        project_root="/custom/test",
+    )
+    envelopes = capture_envelopes()
+
+    class FakeMessage:
+        def __init__(self, *args, **kwargs):
+            pass
+
+        @property
+        def record(self):
+            return {
+                "elapsed": MagicMock(),
+                "exception": None,
+                "file": RecordFile(name="app.py", path="/blah/path.py"),
+                "function": "",
+                "level": RecordLevel(name="ERROR", no=20, icon=""),
+                "line": 35,
+                "message": "some message",
+                "module": "app",
+                "name": "__main__",
+                "process": MagicMock(),
+                "thread": MagicMock(),
+                "time": MagicMock(),
+                "extra": MagicMock(),
+            }
+
+        @record.setter
+        def record(self, val):
+            pass
+
+    with patch("loguru._handler.Message", FakeMessage):
+        logger.error("some message")
+
+    sentry_sdk.get_client().flush()
+
+    logs = envelopes_to_logs(envelopes)
+    assert len(logs) == 1
+    attrs = logs[0]["attributes"]
+    assert attrs["code.file.path"] == "/blah/path.py"
+
+
+def test_logger_with_all_attributes(
+    sentry_init, capture_envelopes, uninstall_integration, request
+):
+    uninstall_integration("loguru")
+    request.addfinalizer(logger.remove)
+
+    sentry_init(enable_logs=True)
+    envelopes = capture_envelopes()
+
+    logger.warning("log #{}", 1)
+    sentry_sdk.get_client().flush()
+
+    logs = envelopes_to_logs(envelopes)
+
+    assert "span_id" in logs[0]
+    assert isinstance(logs[0]["span_id"], str)
+
+    attributes = logs[0]["attributes"]
+
+    assert "process.pid" in attributes
+    assert isinstance(attributes["process.pid"], int)
+    del attributes["process.pid"]
+
+    assert "sentry.release" in attributes
+    assert isinstance(attributes["sentry.release"], str)
+    del attributes["sentry.release"]
+
+    assert "server.address" in attributes
+    assert isinstance(attributes["server.address"], str)
+    del attributes["server.address"]
+
+    assert "thread.id" in attributes
+    assert isinstance(attributes["thread.id"], int)
+    del attributes["thread.id"]
+
+    assert "code.file.path" in attributes
+    assert isinstance(attributes["code.file.path"], str)
+    del attributes["code.file.path"]
+
+    assert "code.function.name" in attributes
+    assert isinstance(attributes["code.function.name"], str)
+    del attributes["code.function.name"]
+
+    assert "code.line.number" in attributes
+    assert isinstance(attributes["code.line.number"], int)
+    del attributes["code.line.number"]
+
+    assert "process.executable.name" in attributes
+    assert isinstance(attributes["process.executable.name"], str)
+    del attributes["process.executable.name"]
+
+    assert "thread.name" in attributes
+    assert isinstance(attributes["thread.name"], str)
+    del attributes["thread.name"]
+
+    assert attributes.pop("sentry.sdk.name").startswith("sentry.python")
+
+    # Assert on the remaining non-dynamic attributes.
+    assert attributes == {
+        "logger.name": "tests.integrations.loguru.test_loguru",
+        "sentry.origin": "auto.log.loguru",
+        "sentry.environment": "production",
+        "sentry.sdk.version": VERSION,
+        "sentry.severity_number": 13,
+        "sentry.severity_text": "warn",
+    }
+
+
+def test_logger_capture_parameters_from_args(
+    sentry_init, capture_envelopes, uninstall_integration, request
+):
+    # This is currently not supported as regular args don't get added to extra
+    # (which we use for populating parameters). Adding this test to make that
+    # explicit and so that it's easy to change later.
+    uninstall_integration("loguru")
+    request.addfinalizer(logger.remove)
+
+    sentry_init(enable_logs=True)
+    envelopes = capture_envelopes()
+
+    logger.warning("Task ID: {}", 123)
+
+    sentry_sdk.get_client().flush()
+
+    logs = envelopes_to_logs(envelopes)
+
+    attributes = logs[0]["attributes"]
+    assert "sentry.message.parameter.0" not in attributes
+
+
+def test_logger_capture_parameters_from_kwargs(
+    sentry_init, capture_envelopes, uninstall_integration, request
+):
+    uninstall_integration("loguru")
+    request.addfinalizer(logger.remove)
+
+    sentry_init(enable_logs=True)
+    envelopes = capture_envelopes()
+
+    logger.warning("Task ID: {task_id}", task_id=123)
+
+    sentry_sdk.get_client().flush()
+
+    logs = envelopes_to_logs(envelopes)
+
+    attributes = logs[0]["attributes"]
+    assert attributes["sentry.message.parameter.task_id"] == 123
+
+
+def test_logger_capture_parameters_from_contextualize(
+    sentry_init, capture_envelopes, uninstall_integration, request
+):
+    uninstall_integration("loguru")
+    request.addfinalizer(logger.remove)
+
+    sentry_init(enable_logs=True)
+    envelopes = capture_envelopes()
+
+    with logger.contextualize(task_id=123):
+        logger.warning("Log")
+
+    sentry_sdk.get_client().flush()
+
+    logs = envelopes_to_logs(envelopes)
+
+    attributes = logs[0]["attributes"]
+    assert attributes["sentry.message.parameter.task_id"] == 123
+
+
+def test_logger_capture_parameters_from_bind(
+    sentry_init, capture_envelopes, uninstall_integration, request
+):
+    uninstall_integration("loguru")
+    request.addfinalizer(logger.remove)
+
+    sentry_init(enable_logs=True)
+    envelopes = capture_envelopes()
+
+    logger.bind(task_id=123).warning("Log")
+    sentry_sdk.get_client().flush()
+
+    logs = envelopes_to_logs(envelopes)
+
+    attributes = logs[0]["attributes"]
+    assert attributes["sentry.message.parameter.task_id"] == 123
+
+
+def test_logger_capture_parameters_from_patch(
+    sentry_init, capture_envelopes, uninstall_integration, request
+):
+    uninstall_integration("loguru")
+    request.addfinalizer(logger.remove)
+
+    sentry_init(enable_logs=True)
+    envelopes = capture_envelopes()
+
+    logger.patch(lambda record: record["extra"].update(task_id=123)).warning("Log")
+    sentry_sdk.get_client().flush()
+
+    logs = envelopes_to_logs(envelopes)
+
+    attributes = logs[0]["attributes"]
+    assert attributes["sentry.message.parameter.task_id"] == 123
+
+
+def test_no_parameters_no_template(
+    sentry_init, capture_envelopes, uninstall_integration, request
+):
+    uninstall_integration("loguru")
+    request.addfinalizer(logger.remove)
+
+    sentry_init(enable_logs=True)
+    envelopes = capture_envelopes()
+
+    logger.warning("Logging a hardcoded warning")
+    sentry_sdk.get_client().flush()
+
+    logs = envelopes_to_logs(envelopes)
+
+    attributes = logs[0]["attributes"]
+    assert "sentry.message.template" not in attributes
diff --git a/tests/integrations/mcp/__init__.py b/tests/integrations/mcp/__init__.py
new file mode 100644
index 0000000000..01ef442500
--- /dev/null
+++ b/tests/integrations/mcp/__init__.py
@@ -0,0 +1,3 @@
+import pytest
+
+pytest.importorskip("mcp")
diff --git a/tests/integrations/mcp/test_mcp.py b/tests/integrations/mcp/test_mcp.py
new file mode 100644
index 0000000000..4415467cd7
--- /dev/null
+++ b/tests/integrations/mcp/test_mcp.py
@@ -0,0 +1,1021 @@
+"""
+Unit tests for the MCP (Model Context Protocol) integration.
+
+This test suite covers:
+- Tool handlers (sync and async)
+- Prompt handlers (sync and async)
+- Resource handlers (sync and async)
+- Error handling for each handler type
+- Request context data extraction (request_id, session_id, transport)
+- Tool result content extraction (various formats)
+- Span data validation
+- Origin tracking
+
+The tests mock the MCP server components and request context to verify
+that the integration properly instruments MCP handlers with Sentry spans.
+"""
+
+import pytest
+import json
+from unittest import mock
+
+try:
+    from unittest.mock import AsyncMock
+except ImportError:
+
+    class AsyncMock(mock.MagicMock):
+        async def __call__(self, *args, **kwargs):
+            return super(AsyncMock, self).__call__(*args, **kwargs)
+
+
+from mcp.server.lowlevel import Server
+from mcp.server.lowlevel.server import request_ctx
+
+try:
+    from mcp.server.lowlevel.server import request_ctx
+except ImportError:
+    request_ctx = None
+
+from sentry_sdk import start_transaction
+from sentry_sdk.consts import SPANDATA, OP
+from sentry_sdk.integrations.mcp import MCPIntegration
+
+
+@pytest.fixture(autouse=True)
+def reset_request_ctx():
+    """Reset request context before and after each test"""
+    if request_ctx is not None:
+        try:
+            if request_ctx.get() is not None:
+                request_ctx.set(None)
+        except LookupError:
+            pass
+
+    yield
+
+    if request_ctx is not None:
+        try:
+            request_ctx.set(None)
+        except LookupError:
+            pass
+
+
+# Mock MCP types and structures
+class MockURI:
+    """Mock URI object for resource testing"""
+
+    def __init__(self, uri_string):
+        self.scheme = uri_string.split("://")[0] if "://" in uri_string else ""
+        self.path = uri_string.split("://")[1] if "://" in uri_string else uri_string
+        self._uri_string = uri_string
+
+    def __str__(self):
+        return self._uri_string
+
+
+class MockRequestContext:
+    """Mock MCP request context"""
+
+    def __init__(self, request_id=None, session_id=None, transport="stdio"):
+        self.request_id = request_id
+        if transport in ("http", "sse"):
+            self.request = MockHTTPRequest(session_id, transport)
+        else:
+            self.request = None
+
+
+class MockHTTPRequest:
+    """Mock HTTP request for SSE/StreamableHTTP transport"""
+
+    def __init__(self, session_id=None, transport="http"):
+        self.headers = {}
+        self.query_params = {}
+
+        if transport == "sse":
+            # SSE transport uses query parameter
+            if session_id:
+                self.query_params["session_id"] = session_id
+        else:
+            # StreamableHTTP transport uses header
+            if session_id:
+                self.headers["mcp-session-id"] = session_id
+
+
+class MockTextContent:
+    """Mock TextContent object"""
+
+    def __init__(self, text):
+        self.text = text
+
+
+class MockPromptMessage:
+    """Mock PromptMessage object"""
+
+    def __init__(self, role, content_text):
+        self.role = role
+        self.content = MockTextContent(content_text)
+
+
+class MockGetPromptResult:
+    """Mock GetPromptResult object"""
+
+    def __init__(self, messages):
+        self.messages = messages
+
+
+def test_integration_patches_server(sentry_init):
+    """Test that MCPIntegration patches the Server class"""
+    # Get original methods before integration
+    original_call_tool = Server.call_tool
+    original_get_prompt = Server.get_prompt
+    original_read_resource = Server.read_resource
+
+    sentry_init(
+        integrations=[MCPIntegration()],
+        traces_sample_rate=1.0,
+    )
+
+    # After initialization, the methods should be patched
+    assert Server.call_tool is not original_call_tool
+    assert Server.get_prompt is not original_get_prompt
+    assert Server.read_resource is not original_read_resource
+
+
+@pytest.mark.parametrize(
+    "send_default_pii, include_prompts",
+    [(True, True), (True, False), (False, True), (False, False)],
+)
+def test_tool_handler_sync(
+    sentry_init, capture_events, send_default_pii, include_prompts
+):
+    """Test that synchronous tool handlers create proper spans"""
+    sentry_init(
+        integrations=[MCPIntegration(include_prompts=include_prompts)],
+        traces_sample_rate=1.0,
+        send_default_pii=send_default_pii,
+    )
+    events = capture_events()
+
+    server = Server("test-server")
+
+    # Set up mock request context
+    mock_ctx = MockRequestContext(request_id="req-123", transport="stdio")
+    request_ctx.set(mock_ctx)
+
+    @server.call_tool()
+    def test_tool(tool_name, arguments):
+        return {"result": "success", "value": 42}
+
+    with start_transaction(name="mcp tx"):
+        # Call the tool handler
+        result = test_tool("calculate", {"x": 10, "y": 5})
+
+    assert result == {"result": "success", "value": 42}
+
+    (tx,) = events
+    assert tx["type"] == "transaction"
+    assert len(tx["spans"]) == 1
+
+    span = tx["spans"][0]
+    assert span["op"] == OP.MCP_SERVER
+    assert span["description"] == "tools/call calculate"
+    assert span["origin"] == "auto.ai.mcp"
+
+    # Check span data
+    assert span["data"][SPANDATA.MCP_TOOL_NAME] == "calculate"
+    assert span["data"][SPANDATA.MCP_METHOD_NAME] == "tools/call"
+    assert span["data"][SPANDATA.MCP_TRANSPORT] == "stdio"
+    assert span["data"][SPANDATA.MCP_REQUEST_ID] == "req-123"
+    assert span["data"]["mcp.request.argument.x"] == "10"
+    assert span["data"]["mcp.request.argument.y"] == "5"
+
+    # Check PII-sensitive data is only present when both flags are True
+    if send_default_pii and include_prompts:
+        assert span["data"][SPANDATA.MCP_TOOL_RESULT_CONTENT] == json.dumps(
+            {
+                "result": "success",
+                "value": 42,
+            }
+        )
+        assert span["data"][SPANDATA.MCP_TOOL_RESULT_CONTENT_COUNT] == 2
+    else:
+        assert SPANDATA.MCP_TOOL_RESULT_CONTENT not in span["data"]
+        assert SPANDATA.MCP_TOOL_RESULT_CONTENT_COUNT not in span["data"]
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    "send_default_pii, include_prompts",
+    [(True, True), (True, False), (False, True), (False, False)],
+)
+async def test_tool_handler_async(
+    sentry_init, capture_events, send_default_pii, include_prompts
+):
+    """Test that async tool handlers create proper spans"""
+    sentry_init(
+        integrations=[MCPIntegration(include_prompts=include_prompts)],
+        traces_sample_rate=1.0,
+        send_default_pii=send_default_pii,
+    )
+    events = capture_events()
+
+    server = Server("test-server")
+
+    # Set up mock request context
+    mock_ctx = MockRequestContext(
+        request_id="req-456", session_id="session-789", transport="http"
+    )
+    request_ctx.set(mock_ctx)
+
+    @server.call_tool()
+    async def test_tool_async(tool_name, arguments):
+        return {"status": "completed"}
+
+    with start_transaction(name="mcp tx"):
+        result = await test_tool_async("process", {"data": "test"})
+
+    assert result == {"status": "completed"}
+
+    (tx,) = events
+    assert tx["type"] == "transaction"
+    assert len(tx["spans"]) == 1
+
+    span = tx["spans"][0]
+    assert span["op"] == OP.MCP_SERVER
+    assert span["description"] == "tools/call process"
+    assert span["origin"] == "auto.ai.mcp"
+
+    # Check span data
+    assert span["data"][SPANDATA.MCP_TOOL_NAME] == "process"
+    assert span["data"][SPANDATA.MCP_METHOD_NAME] == "tools/call"
+    assert span["data"][SPANDATA.MCP_TRANSPORT] == "http"
+    assert span["data"][SPANDATA.MCP_REQUEST_ID] == "req-456"
+    assert span["data"][SPANDATA.MCP_SESSION_ID] == "session-789"
+    assert span["data"]["mcp.request.argument.data"] == '"test"'
+
+    # Check PII-sensitive data
+    if send_default_pii and include_prompts:
+        assert span["data"][SPANDATA.MCP_TOOL_RESULT_CONTENT] == json.dumps(
+            {"status": "completed"}
+        )
+    else:
+        assert SPANDATA.MCP_TOOL_RESULT_CONTENT not in span["data"]
+
+
+def test_tool_handler_with_error(sentry_init, capture_events):
+    """Test that tool handler errors are captured properly"""
+    sentry_init(
+        integrations=[MCPIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    server = Server("test-server")
+
+    # Set up mock request context
+    mock_ctx = MockRequestContext(request_id="req-error", transport="stdio")
+    request_ctx.set(mock_ctx)
+
+    @server.call_tool()
+    def failing_tool(tool_name, arguments):
+        raise ValueError("Tool execution failed")
+
+    with start_transaction(name="mcp tx"):
+        with pytest.raises(ValueError):
+            failing_tool("bad_tool", {})
+
+    # Should have error event and transaction
+    assert len(events) == 2
+    error_event, tx = events
+
+    # Check error event
+    assert error_event["level"] == "error"
+    assert error_event["exception"]["values"][0]["type"] == "ValueError"
+    assert error_event["exception"]["values"][0]["value"] == "Tool execution failed"
+
+    # Check transaction and span
+    assert tx["type"] == "transaction"
+    assert len(tx["spans"]) == 1
+    span = tx["spans"][0]
+
+    # Error flag should be set for tools
+    assert span["data"][SPANDATA.MCP_TOOL_RESULT_IS_ERROR] is True
+    assert span["status"] == "internal_error"
+    assert span["tags"]["status"] == "internal_error"
+
+
+@pytest.mark.parametrize(
+    "send_default_pii, include_prompts",
+    [(True, True), (True, False), (False, True), (False, False)],
+)
+def test_prompt_handler_sync(
+    sentry_init, capture_events, send_default_pii, include_prompts
+):
+    """Test that synchronous prompt handlers create proper spans"""
+    sentry_init(
+        integrations=[MCPIntegration(include_prompts=include_prompts)],
+        traces_sample_rate=1.0,
+        send_default_pii=send_default_pii,
+    )
+    events = capture_events()
+
+    server = Server("test-server")
+
+    # Set up mock request context
+    mock_ctx = MockRequestContext(request_id="req-prompt", transport="stdio")
+    request_ctx.set(mock_ctx)
+
+    @server.get_prompt()
+    def test_prompt(name, arguments):
+        return MockGetPromptResult([MockPromptMessage("user", "Tell me about Python")])
+
+    with start_transaction(name="mcp tx"):
+        result = test_prompt("code_help", {"language": "python"})
+
+    assert result.messages[0].role == "user"
+    assert result.messages[0].content.text == "Tell me about Python"
+
+    (tx,) = events
+    assert tx["type"] == "transaction"
+    assert len(tx["spans"]) == 1
+
+    span = tx["spans"][0]
+    assert span["op"] == OP.MCP_SERVER
+    assert span["description"] == "prompts/get code_help"
+    assert span["origin"] == "auto.ai.mcp"
+
+    # Check span data
+    assert span["data"][SPANDATA.MCP_PROMPT_NAME] == "code_help"
+    assert span["data"][SPANDATA.MCP_METHOD_NAME] == "prompts/get"
+    assert span["data"][SPANDATA.MCP_TRANSPORT] == "stdio"
+    assert span["data"][SPANDATA.MCP_REQUEST_ID] == "req-prompt"
+    assert span["data"]["mcp.request.argument.name"] == '"code_help"'
+    assert span["data"]["mcp.request.argument.language"] == '"python"'
+
+    # Message count is always captured
+    assert span["data"][SPANDATA.MCP_PROMPT_RESULT_MESSAGE_COUNT] == 1
+
+    # For single message prompts, role and content should be captured only with PII
+    if send_default_pii and include_prompts:
+        assert span["data"][SPANDATA.MCP_PROMPT_RESULT_MESSAGE_ROLE] == "user"
+        assert (
+            span["data"][SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT]
+            == "Tell me about Python"
+        )
+    else:
+        assert SPANDATA.MCP_PROMPT_RESULT_MESSAGE_ROLE not in span["data"]
+        assert SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT not in span["data"]
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    "send_default_pii, include_prompts",
+    [(True, True), (True, False), (False, True), (False, False)],
+)
+async def test_prompt_handler_async(
+    sentry_init, capture_events, send_default_pii, include_prompts
+):
+    """Test that async prompt handlers create proper spans"""
+    sentry_init(
+        integrations=[MCPIntegration(include_prompts=include_prompts)],
+        traces_sample_rate=1.0,
+        send_default_pii=send_default_pii,
+    )
+    events = capture_events()
+
+    server = Server("test-server")
+
+    # Set up mock request context
+    mock_ctx = MockRequestContext(
+        request_id="req-async-prompt", session_id="session-abc", transport="http"
+    )
+    request_ctx.set(mock_ctx)
+
+    @server.get_prompt()
+    async def test_prompt_async(name, arguments):
+        return MockGetPromptResult(
+            [
+                MockPromptMessage("system", "You are a helpful assistant"),
+                MockPromptMessage("user", "What is MCP?"),
+            ]
+        )
+
+    with start_transaction(name="mcp tx"):
+        result = await test_prompt_async("mcp_info", {})
+
+    assert len(result.messages) == 2
+
+    (tx,) = events
+    assert tx["type"] == "transaction"
+    assert len(tx["spans"]) == 1
+
+    span = tx["spans"][0]
+    assert span["op"] == OP.MCP_SERVER
+    assert span["description"] == "prompts/get mcp_info"
+
+    # For multi-message prompts, count is always captured
+    assert span["data"][SPANDATA.MCP_PROMPT_RESULT_MESSAGE_COUNT] == 2
+    # Role/content are never captured for multi-message prompts (even with PII)
+    assert SPANDATA.MCP_PROMPT_RESULT_MESSAGE_ROLE not in span["data"]
+    assert SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT not in span["data"]
+
+
+def test_prompt_handler_with_error(sentry_init, capture_events):
+    """Test that prompt handler errors are captured"""
+    sentry_init(
+        integrations=[MCPIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    server = Server("test-server")
+
+    # Set up mock request context
+    mock_ctx = MockRequestContext(request_id="req-error-prompt", transport="stdio")
+    request_ctx.set(mock_ctx)
+
+    @server.get_prompt()
+    def failing_prompt(name, arguments):
+        raise RuntimeError("Prompt not found")
+
+    with start_transaction(name="mcp tx"):
+        with pytest.raises(RuntimeError):
+            failing_prompt("missing_prompt", {})
+
+    # Should have error event and transaction
+    assert len(events) == 2
+    error_event, tx = events
+
+    assert error_event["level"] == "error"
+    assert error_event["exception"]["values"][0]["type"] == "RuntimeError"
+
+
+def test_resource_handler_sync(sentry_init, capture_events):
+    """Test that synchronous resource handlers create proper spans"""
+    sentry_init(
+        integrations=[MCPIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    server = Server("test-server")
+
+    # Set up mock request context
+    mock_ctx = MockRequestContext(request_id="req-resource", transport="stdio")
+    request_ctx.set(mock_ctx)
+
+    @server.read_resource()
+    def test_resource(uri):
+        return {"content": "file contents", "mime_type": "text/plain"}
+
+    with start_transaction(name="mcp tx"):
+        uri = MockURI("file:///path/to/file.txt")
+        result = test_resource(uri)
+
+    assert result["content"] == "file contents"
+
+    (tx,) = events
+    assert tx["type"] == "transaction"
+    assert len(tx["spans"]) == 1
+
+    span = tx["spans"][0]
+    assert span["op"] == OP.MCP_SERVER
+    assert span["description"] == "resources/read file:///path/to/file.txt"
+    assert span["origin"] == "auto.ai.mcp"
+
+    # Check span data
+    assert span["data"][SPANDATA.MCP_RESOURCE_URI] == "file:///path/to/file.txt"
+    assert span["data"][SPANDATA.MCP_METHOD_NAME] == "resources/read"
+    assert span["data"][SPANDATA.MCP_TRANSPORT] == "stdio"
+    assert span["data"][SPANDATA.MCP_REQUEST_ID] == "req-resource"
+    assert span["data"][SPANDATA.MCP_RESOURCE_PROTOCOL] == "file"
+    # Resources don't capture result content
+    assert SPANDATA.MCP_TOOL_RESULT_CONTENT not in span["data"]
+
+
+@pytest.mark.asyncio
+async def test_resource_handler_async(sentry_init, capture_events):
+    """Test that async resource handlers create proper spans"""
+    sentry_init(
+        integrations=[MCPIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    server = Server("test-server")
+
+    # Set up mock request context
+    mock_ctx = MockRequestContext(
+        request_id="req-async-resource", session_id="session-res", transport="http"
+    )
+    request_ctx.set(mock_ctx)
+
+    @server.read_resource()
+    async def test_resource_async(uri):
+        return {"data": "resource data"}
+
+    with start_transaction(name="mcp tx"):
+        uri = MockURI("https://example.com/resource")
+        result = await test_resource_async(uri)
+
+    assert result["data"] == "resource data"
+
+    (tx,) = events
+    assert tx["type"] == "transaction"
+    assert len(tx["spans"]) == 1
+
+    span = tx["spans"][0]
+    assert span["op"] == OP.MCP_SERVER
+    assert span["description"] == "resources/read https://example.com/resource"
+
+    assert span["data"][SPANDATA.MCP_RESOURCE_URI] == "https://example.com/resource"
+    assert span["data"][SPANDATA.MCP_RESOURCE_PROTOCOL] == "https"
+    assert span["data"][SPANDATA.MCP_SESSION_ID] == "session-res"
+
+
+def test_resource_handler_with_error(sentry_init, capture_events):
+    """Test that resource handler errors are captured"""
+    sentry_init(
+        integrations=[MCPIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    server = Server("test-server")
+
+    # Set up mock request context
+    mock_ctx = MockRequestContext(request_id="req-error-resource", transport="stdio")
+    request_ctx.set(mock_ctx)
+
+    @server.read_resource()
+    def failing_resource(uri):
+        raise FileNotFoundError("Resource not found")
+
+    with start_transaction(name="mcp tx"):
+        with pytest.raises(FileNotFoundError):
+            uri = MockURI("file:///missing.txt")
+            failing_resource(uri)
+
+    # Should have error event and transaction
+    assert len(events) == 2
+    error_event, tx = events
+
+    assert error_event["level"] == "error"
+    assert error_event["exception"]["values"][0]["type"] == "FileNotFoundError"
+
+
+@pytest.mark.parametrize(
+    "send_default_pii, include_prompts",
+    [(True, True), (False, False)],
+)
+def test_tool_result_extraction_tuple(
+    sentry_init, capture_events, send_default_pii, include_prompts
+):
+    """Test extraction of tool results from tuple format (UnstructuredContent, StructuredContent)"""
+    sentry_init(
+        integrations=[MCPIntegration(include_prompts=include_prompts)],
+        traces_sample_rate=1.0,
+        send_default_pii=send_default_pii,
+    )
+    events = capture_events()
+
+    server = Server("test-server")
+
+    # Set up mock request context
+    mock_ctx = MockRequestContext(request_id="req-tuple", transport="stdio")
+    request_ctx.set(mock_ctx)
+
+    @server.call_tool()
+    def test_tool_tuple(tool_name, arguments):
+        # Return CombinationContent: (UnstructuredContent, StructuredContent)
+        unstructured = [MockTextContent("Result text")]
+        structured = {"key": "value", "count": 5}
+        return (unstructured, structured)
+
+    with start_transaction(name="mcp tx"):
+        test_tool_tuple("combo_tool", {})
+
+    (tx,) = events
+    span = tx["spans"][0]
+
+    # Should extract the structured content (second element of tuple) only with PII
+    if send_default_pii and include_prompts:
+        assert span["data"][SPANDATA.MCP_TOOL_RESULT_CONTENT] == json.dumps(
+            {
+                "key": "value",
+                "count": 5,
+            }
+        )
+        assert span["data"][SPANDATA.MCP_TOOL_RESULT_CONTENT_COUNT] == 2
+    else:
+        assert SPANDATA.MCP_TOOL_RESULT_CONTENT not in span["data"]
+        assert SPANDATA.MCP_TOOL_RESULT_CONTENT_COUNT not in span["data"]
+
+
+@pytest.mark.parametrize(
+    "send_default_pii, include_prompts",
+    [(True, True), (False, False)],
+)
+def test_tool_result_extraction_unstructured(
+    sentry_init, capture_events, send_default_pii, include_prompts
+):
+    """Test extraction of tool results from UnstructuredContent (list of content blocks)"""
+    sentry_init(
+        integrations=[MCPIntegration(include_prompts=include_prompts)],
+        traces_sample_rate=1.0,
+        send_default_pii=send_default_pii,
+    )
+    events = capture_events()
+
+    server = Server("test-server")
+
+    # Set up mock request context
+    mock_ctx = MockRequestContext(request_id="req-unstructured", transport="stdio")
+    request_ctx.set(mock_ctx)
+
+    @server.call_tool()
+    def test_tool_unstructured(tool_name, arguments):
+        # Return UnstructuredContent as list of content blocks
+        return [
+            MockTextContent("First part"),
+            MockTextContent("Second part"),
+        ]
+
+    with start_transaction(name="mcp tx"):
+        test_tool_unstructured("text_tool", {})
+
+    (tx,) = events
+    span = tx["spans"][0]
+
+    # Should extract and join text from content blocks only with PII
+    if send_default_pii and include_prompts:
+        assert (
+            span["data"][SPANDATA.MCP_TOOL_RESULT_CONTENT] == '"First part Second part"'
+        )
+    else:
+        assert SPANDATA.MCP_TOOL_RESULT_CONTENT not in span["data"]
+
+
+def test_request_context_no_context(sentry_init, capture_events):
+    """Test handling when no request context is available"""
+    sentry_init(
+        integrations=[MCPIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    server = Server("test-server")
+
+    # Clear request context (simulating no context available)
+    # This will cause a LookupError when trying to get context
+    request_ctx.set(None)
+
+    @server.call_tool()
+    def test_tool_no_ctx(tool_name, arguments):
+        return {"result": "ok"}
+
+    with start_transaction(name="mcp tx"):
+        # This should work even without request context
+        try:
+            test_tool_no_ctx("tool", {})
+        except LookupError:
+            # If it raises LookupError, that's expected when context is truly missing
+            pass
+
+    # Should still create span even if context is missing
+    (tx,) = events
+    span = tx["spans"][0]
+
+    # Transport defaults to "pipe" when no context
+    assert span["data"][SPANDATA.MCP_TRANSPORT] == "stdio"
+    # Request ID and Session ID should not be present
+    assert SPANDATA.MCP_REQUEST_ID not in span["data"]
+    assert SPANDATA.MCP_SESSION_ID not in span["data"]
+
+
+def test_span_origin(sentry_init, capture_events):
+    """Test that span origin is set correctly"""
+    sentry_init(
+        integrations=[MCPIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    server = Server("test-server")
+
+    # Set up mock request context
+    mock_ctx = MockRequestContext(request_id="req-origin", transport="stdio")
+    request_ctx.set(mock_ctx)
+
+    @server.call_tool()
+    def test_tool(tool_name, arguments):
+        return {"result": "test"}
+
+    with start_transaction(name="mcp tx"):
+        test_tool("origin_test", {})
+
+    (tx,) = events
+
+    assert tx["contexts"]["trace"]["origin"] == "manual"
+    assert tx["spans"][0]["origin"] == "auto.ai.mcp"
+
+
+def test_multiple_handlers(sentry_init, capture_events):
+    """Test that multiple handler calls create multiple spans"""
+    sentry_init(
+        integrations=[MCPIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    server = Server("test-server")
+
+    # Set up mock request context
+    mock_ctx = MockRequestContext(request_id="req-multi", transport="stdio")
+    request_ctx.set(mock_ctx)
+
+    @server.call_tool()
+    def tool1(tool_name, arguments):
+        return {"result": "tool1"}
+
+    @server.call_tool()
+    def tool2(tool_name, arguments):
+        return {"result": "tool2"}
+
+    @server.get_prompt()
+    def prompt1(name, arguments):
+        return MockGetPromptResult([MockPromptMessage("user", "Test prompt")])
+
+    with start_transaction(name="mcp tx"):
+        tool1("tool_a", {})
+        tool2("tool_b", {})
+        prompt1("prompt_a", {})
+
+    (tx,) = events
+    assert tx["type"] == "transaction"
+    assert len(tx["spans"]) == 3
+
+    # Check that we have different span types
+    span_ops = [span["op"] for span in tx["spans"]]
+    assert all(op == OP.MCP_SERVER for op in span_ops)
+
+    span_descriptions = [span["description"] for span in tx["spans"]]
+    assert "tools/call tool_a" in span_descriptions
+    assert "tools/call tool_b" in span_descriptions
+    assert "prompts/get prompt_a" in span_descriptions
+
+
+@pytest.mark.parametrize(
+    "send_default_pii, include_prompts",
+    [(True, True), (False, False)],
+)
+def test_prompt_with_dict_result(
+    sentry_init, capture_events, send_default_pii, include_prompts
+):
+    """Test prompt handler with dict result instead of GetPromptResult object"""
+    sentry_init(
+        integrations=[MCPIntegration(include_prompts=include_prompts)],
+        traces_sample_rate=1.0,
+        send_default_pii=send_default_pii,
+    )
+    events = capture_events()
+
+    server = Server("test-server")
+
+    # Set up mock request context
+    mock_ctx = MockRequestContext(request_id="req-dict-prompt", transport="stdio")
+    request_ctx.set(mock_ctx)
+
+    @server.get_prompt()
+    def test_prompt_dict(name, arguments):
+        # Return dict format instead of GetPromptResult object
+        return {
+            "messages": [
+                {"role": "user", "content": {"text": "Hello from dict"}},
+            ]
+        }
+
+    with start_transaction(name="mcp tx"):
+        test_prompt_dict("dict_prompt", {})
+
+    (tx,) = events
+    span = tx["spans"][0]
+
+    # Message count is always captured
+    assert span["data"][SPANDATA.MCP_PROMPT_RESULT_MESSAGE_COUNT] == 1
+
+    # Role and content only captured with PII
+    if send_default_pii and include_prompts:
+        assert span["data"][SPANDATA.MCP_PROMPT_RESULT_MESSAGE_ROLE] == "user"
+        assert (
+            span["data"][SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT]
+            == "Hello from dict"
+        )
+    else:
+        assert SPANDATA.MCP_PROMPT_RESULT_MESSAGE_ROLE not in span["data"]
+        assert SPANDATA.MCP_PROMPT_RESULT_MESSAGE_CONTENT not in span["data"]
+
+
+def test_resource_without_protocol(sentry_init, capture_events):
+    """Test resource handler with URI without protocol scheme"""
+    sentry_init(
+        integrations=[MCPIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    server = Server("test-server")
+
+    # Set up mock request context
+    mock_ctx = MockRequestContext(request_id="req-no-proto", transport="stdio")
+    request_ctx.set(mock_ctx)
+
+    @server.read_resource()
+    def test_resource(uri):
+        return {"data": "test"}
+
+    with start_transaction(name="mcp tx"):
+        # URI without protocol
+        test_resource("simple-path")
+
+    (tx,) = events
+    span = tx["spans"][0]
+
+    assert span["data"][SPANDATA.MCP_RESOURCE_URI] == "simple-path"
+    # No protocol should be set
+    assert SPANDATA.MCP_RESOURCE_PROTOCOL not in span["data"]
+
+
+def test_tool_with_complex_arguments(sentry_init, capture_events):
+    """Test tool handler with complex nested arguments"""
+    sentry_init(
+        integrations=[MCPIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    server = Server("test-server")
+
+    # Set up mock request context
+    mock_ctx = MockRequestContext(request_id="req-complex", transport="stdio")
+    request_ctx.set(mock_ctx)
+
+    @server.call_tool()
+    def test_tool_complex(tool_name, arguments):
+        return {"processed": True}
+
+    with start_transaction(name="mcp tx"):
+        complex_args = {
+            "nested": {"key": "value", "list": [1, 2, 3]},
+            "string": "test",
+            "number": 42,
+        }
+        test_tool_complex("complex_tool", complex_args)
+
+    (tx,) = events
+    span = tx["spans"][0]
+
+    # Complex arguments should be serialized
+    assert span["data"]["mcp.request.argument.nested"] == json.dumps(
+        {"key": "value", "list": [1, 2, 3]}
+    )
+    assert span["data"]["mcp.request.argument.string"] == '"test"'
+    assert span["data"]["mcp.request.argument.number"] == "42"
+
+
+@pytest.mark.asyncio
+async def test_async_handlers_mixed(sentry_init, capture_events):
+    """Test mixing sync and async handlers in the same transaction"""
+    sentry_init(
+        integrations=[MCPIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    server = Server("test-server")
+
+    # Set up mock request context
+    mock_ctx = MockRequestContext(request_id="req-mixed", transport="stdio")
+    request_ctx.set(mock_ctx)
+
+    @server.call_tool()
+    def sync_tool(tool_name, arguments):
+        return {"type": "sync"}
+
+    @server.call_tool()
+    async def async_tool(tool_name, arguments):
+        return {"type": "async"}
+
+    with start_transaction(name="mcp tx"):
+        sync_result = sync_tool("sync", {})
+        async_result = await async_tool("async", {})
+
+    assert sync_result["type"] == "sync"
+    assert async_result["type"] == "async"
+
+    (tx,) = events
+    assert len(tx["spans"]) == 2
+
+    # Both should be instrumented correctly
+    assert all(span["op"] == OP.MCP_SERVER for span in tx["spans"])
+
+
+def test_sse_transport_detection(sentry_init, capture_events):
+    """Test that SSE transport is correctly detected via query parameter"""
+    sentry_init(
+        integrations=[MCPIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    server = Server("test-server")
+
+    # Set up mock request context with SSE transport
+    mock_ctx = MockRequestContext(
+        request_id="req-sse", session_id="session-sse-123", transport="sse"
+    )
+    request_ctx.set(mock_ctx)
+
+    @server.call_tool()
+    def test_tool(tool_name, arguments):
+        return {"result": "success"}
+
+    with start_transaction(name="mcp tx"):
+        result = test_tool("sse_tool", {})
+
+    assert result == {"result": "success"}
+
+    (tx,) = events
+    span = tx["spans"][0]
+
+    # Check that SSE transport is detected
+    assert span["data"][SPANDATA.MCP_TRANSPORT] == "sse"
+    assert span["data"][SPANDATA.NETWORK_TRANSPORT] == "tcp"
+    assert span["data"][SPANDATA.MCP_SESSION_ID] == "session-sse-123"
+
+
+def test_streamable_http_transport_detection(sentry_init, capture_events):
+    """Test that StreamableHTTP transport is correctly detected via header"""
+    sentry_init(
+        integrations=[MCPIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    server = Server("test-server")
+
+    # Set up mock request context with StreamableHTTP transport
+    mock_ctx = MockRequestContext(
+        request_id="req-http", session_id="session-http-456", transport="http"
+    )
+    request_ctx.set(mock_ctx)
+
+    @server.call_tool()
+    def test_tool(tool_name, arguments):
+        return {"result": "success"}
+
+    with start_transaction(name="mcp tx"):
+        result = test_tool("http_tool", {})
+
+    assert result == {"result": "success"}
+
+    (tx,) = events
+    span = tx["spans"][0]
+
+    # Check that HTTP transport is detected
+    assert span["data"][SPANDATA.MCP_TRANSPORT] == "http"
+    assert span["data"][SPANDATA.NETWORK_TRANSPORT] == "tcp"
+    assert span["data"][SPANDATA.MCP_SESSION_ID] == "session-http-456"
+
+
+def test_stdio_transport_detection(sentry_init, capture_events):
+    """Test that stdio transport is correctly detected when no HTTP request"""
+    sentry_init(
+        integrations=[MCPIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    server = Server("test-server")
+
+    # Set up mock request context with stdio transport (no HTTP request)
+    mock_ctx = MockRequestContext(request_id="req-stdio", transport="stdio")
+    request_ctx.set(mock_ctx)
+
+    @server.call_tool()
+    def test_tool(tool_name, arguments):
+        return {"result": "success"}
+
+    with start_transaction(name="mcp tx"):
+        result = test_tool("stdio_tool", {})
+
+    assert result == {"result": "success"}
+
+    (tx,) = events
+    span = tx["spans"][0]
+
+    # Check that stdio transport is detected
+    assert span["data"][SPANDATA.MCP_TRANSPORT] == "stdio"
+    assert span["data"][SPANDATA.NETWORK_TRANSPORT] == "pipe"
+    # No session ID for stdio transport
+    assert SPANDATA.MCP_SESSION_ID not in span["data"]
diff --git a/tests/integrations/modules/test_modules.py b/tests/integrations/modules/test_modules.py
index c7097972b0..3f4d7bd9dc 100644
--- a/tests/integrations/modules/test_modules.py
+++ b/tests/integrations/modules/test_modules.py
@@ -1,22 +1,6 @@
-import pytest
-import re
 import sentry_sdk
 
-from sentry_sdk.integrations.modules import (
-    ModulesIntegration,
-    _get_installed_modules,
-)
-
-
-def _normalize_distribution_name(name):
-    # type: (str) -> str
-    """Normalize distribution name according to PEP-0503.
-
-    See:
-    https://peps.python.org/pep-0503/#normalized-names
-    for more details.
-    """
-    return re.sub(r"[-_.]+", "-", name).lower()
+from sentry_sdk.integrations.modules import ModulesIntegration
 
 
 def test_basic(sentry_init, capture_events):
@@ -28,44 +12,3 @@ def test_basic(sentry_init, capture_events):
     (event,) = events
     assert "sentry-sdk" in event["modules"]
     assert "pytest" in event["modules"]
-
-
-def test_installed_modules():
-    try:
-        from importlib.metadata import distributions, version
-
-        importlib_available = True
-    except ImportError:
-        importlib_available = False
-
-    try:
-        import pkg_resources
-
-        pkg_resources_available = True
-    except ImportError:
-        pkg_resources_available = False
-
-    installed_distributions = {
-        _normalize_distribution_name(dist): version
-        for dist, version in _get_installed_modules().items()
-    }
-
-    if importlib_available:
-        importlib_distributions = {
-            _normalize_distribution_name(dist.metadata["Name"]): version(
-                dist.metadata["Name"]
-            )
-            for dist in distributions()
-            if dist.metadata["Name"] is not None
-            and version(dist.metadata["Name"]) is not None
-        }
-        assert installed_distributions == importlib_distributions
-
-    elif pkg_resources_available:
-        pkg_resources_distributions = {
-            _normalize_distribution_name(dist.key): dist.version
-            for dist in pkg_resources.working_set
-        }
-        assert installed_distributions == pkg_resources_distributions
-    else:
-        pytest.fail("Neither importlib nor pkg_resources is available")
diff --git a/tests/integrations/openai/__init__.py b/tests/integrations/openai/__init__.py
new file mode 100644
index 0000000000..d6cc3d5505
--- /dev/null
+++ b/tests/integrations/openai/__init__.py
@@ -0,0 +1,3 @@
+import pytest
+
+pytest.importorskip("openai")
diff --git a/tests/integrations/openai/test_openai.py b/tests/integrations/openai/test_openai.py
new file mode 100644
index 0000000000..814289c887
--- /dev/null
+++ b/tests/integrations/openai/test_openai.py
@@ -0,0 +1,1561 @@
+import json
+import pytest
+
+from sentry_sdk.utils import package_version
+
+try:
+    from openai import NOT_GIVEN
+except ImportError:
+    NOT_GIVEN = None
+try:
+    from openai import omit
+except ImportError:
+    omit = None
+
+from openai import AsyncOpenAI, OpenAI, AsyncStream, Stream, OpenAIError
+from openai.types import CompletionUsage, CreateEmbeddingResponse, Embedding
+from openai.types.chat import ChatCompletion, ChatCompletionMessage, ChatCompletionChunk
+from openai.types.chat.chat_completion import Choice
+from openai.types.chat.chat_completion_chunk import ChoiceDelta, Choice as DeltaChoice
+from openai.types.create_embedding_response import Usage as EmbeddingTokenUsage
+
+SKIP_RESPONSES_TESTS = False
+
+try:
+    from openai.types.responses.response_completed_event import ResponseCompletedEvent
+    from openai.types.responses.response_created_event import ResponseCreatedEvent
+    from openai.types.responses.response_text_delta_event import ResponseTextDeltaEvent
+    from openai.types.responses.response_usage import (
+        InputTokensDetails,
+        OutputTokensDetails,
+    )
+    from openai.types.responses import (
+        Response,
+        ResponseUsage,
+        ResponseOutputMessage,
+        ResponseOutputText,
+    )
+except ImportError:
+    SKIP_RESPONSES_TESTS = True
+
+from sentry_sdk import start_transaction
+from sentry_sdk.consts import SPANDATA
+from sentry_sdk.integrations.openai import (
+    OpenAIIntegration,
+    _calculate_token_usage,
+)
+from sentry_sdk.ai.utils import MAX_GEN_AI_MESSAGE_BYTES
+from sentry_sdk._types import AnnotatedValue
+from sentry_sdk.serializer import serialize
+
+from unittest import mock  # python 3.3 and above
+
+try:
+    from unittest.mock import AsyncMock
+except ImportError:
+
+    class AsyncMock(mock.MagicMock):
+        async def __call__(self, *args, **kwargs):
+            return super(AsyncMock, self).__call__(*args, **kwargs)
+
+
+OPENAI_VERSION = package_version("openai")
+EXAMPLE_CHAT_COMPLETION = ChatCompletion(
+    id="chat-id",
+    choices=[
+        Choice(
+            index=0,
+            finish_reason="stop",
+            message=ChatCompletionMessage(
+                role="assistant", content="the model response"
+            ),
+        )
+    ],
+    created=10000000,
+    model="response-model-id",
+    object="chat.completion",
+    usage=CompletionUsage(
+        completion_tokens=10,
+        prompt_tokens=20,
+        total_tokens=30,
+    ),
+)
+
+
+if SKIP_RESPONSES_TESTS:
+    EXAMPLE_RESPONSE = None
+else:
+    EXAMPLE_RESPONSE = Response(
+        id="chat-id",
+        output=[
+            ResponseOutputMessage(
+                id="message-id",
+                content=[
+                    ResponseOutputText(
+                        annotations=[],
+                        text="the model response",
+                        type="output_text",
+                    ),
+                ],
+                role="assistant",
+                status="completed",
+                type="message",
+            ),
+        ],
+        parallel_tool_calls=False,
+        tool_choice="none",
+        tools=[],
+        created_at=10000000,
+        model="response-model-id",
+        object="response",
+        usage=ResponseUsage(
+            input_tokens=20,
+            input_tokens_details=InputTokensDetails(
+                cached_tokens=5,
+            ),
+            output_tokens=10,
+            output_tokens_details=OutputTokensDetails(
+                reasoning_tokens=8,
+            ),
+            total_tokens=30,
+        ),
+    )
+
+
+async def async_iterator(values):
+    for value in values:
+        yield value
+
+
+@pytest.mark.parametrize(
+    "send_default_pii, include_prompts",
+    [(True, True), (True, False), (False, True), (False, False)],
+)
+def test_nonstreaming_chat_completion(
+    sentry_init, capture_events, send_default_pii, include_prompts
+):
+    sentry_init(
+        integrations=[OpenAIIntegration(include_prompts=include_prompts)],
+        traces_sample_rate=1.0,
+        send_default_pii=send_default_pii,
+    )
+    events = capture_events()
+
+    client = OpenAI(api_key="z")
+    client.chat.completions._post = mock.Mock(return_value=EXAMPLE_CHAT_COMPLETION)
+
+    with start_transaction(name="openai tx"):
+        response = (
+            client.chat.completions.create(
+                model="some-model", messages=[{"role": "system", "content": "hello"}]
+            )
+            .choices[0]
+            .message.content
+        )
+
+    assert response == "the model response"
+    tx = events[0]
+    assert tx["type"] == "transaction"
+    span = tx["spans"][0]
+    assert span["op"] == "gen_ai.chat"
+
+    if send_default_pii and include_prompts:
+        assert "hello" in span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES]
+        assert "the model response" in span["data"][SPANDATA.GEN_AI_RESPONSE_TEXT]
+    else:
+        assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["data"]
+        assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["data"]
+
+    assert span["data"]["gen_ai.usage.output_tokens"] == 10
+    assert span["data"]["gen_ai.usage.input_tokens"] == 20
+    assert span["data"]["gen_ai.usage.total_tokens"] == 30
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    "send_default_pii, include_prompts",
+    [(True, True), (True, False), (False, True), (False, False)],
+)
+async def test_nonstreaming_chat_completion_async(
+    sentry_init, capture_events, send_default_pii, include_prompts
+):
+    sentry_init(
+        integrations=[OpenAIIntegration(include_prompts=include_prompts)],
+        traces_sample_rate=1.0,
+        send_default_pii=send_default_pii,
+    )
+    events = capture_events()
+
+    client = AsyncOpenAI(api_key="z")
+    client.chat.completions._post = AsyncMock(return_value=EXAMPLE_CHAT_COMPLETION)
+
+    with start_transaction(name="openai tx"):
+        response = await client.chat.completions.create(
+            model="some-model", messages=[{"role": "system", "content": "hello"}]
+        )
+        response = response.choices[0].message.content
+
+    assert response == "the model response"
+    tx = events[0]
+    assert tx["type"] == "transaction"
+    span = tx["spans"][0]
+    assert span["op"] == "gen_ai.chat"
+
+    if send_default_pii and include_prompts:
+        assert "hello" in span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES]
+        assert "the model response" in span["data"][SPANDATA.GEN_AI_RESPONSE_TEXT]
+    else:
+        assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["data"]
+        assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["data"]
+
+    assert span["data"]["gen_ai.usage.output_tokens"] == 10
+    assert span["data"]["gen_ai.usage.input_tokens"] == 20
+    assert span["data"]["gen_ai.usage.total_tokens"] == 30
+
+
+def tiktoken_encoding_if_installed():
+    try:
+        import tiktoken  # type: ignore # noqa # pylint: disable=unused-import
+
+        return "cl100k_base"
+    except ImportError:
+        return None
+
+
+# noinspection PyTypeChecker
+@pytest.mark.parametrize(
+    "send_default_pii, include_prompts",
+    [(True, True), (True, False), (False, True), (False, False)],
+)
+def test_streaming_chat_completion(
+    sentry_init, capture_events, send_default_pii, include_prompts
+):
+    sentry_init(
+        integrations=[
+            OpenAIIntegration(
+                include_prompts=include_prompts,
+                tiktoken_encoding_name=tiktoken_encoding_if_installed(),
+            )
+        ],
+        traces_sample_rate=1.0,
+        send_default_pii=send_default_pii,
+    )
+    events = capture_events()
+
+    client = OpenAI(api_key="z")
+    returned_stream = Stream(cast_to=None, response=None, client=client)
+    returned_stream._iterator = [
+        ChatCompletionChunk(
+            id="1",
+            choices=[
+                DeltaChoice(
+                    index=0, delta=ChoiceDelta(content="hel"), finish_reason=None
+                )
+            ],
+            created=100000,
+            model="model-id",
+            object="chat.completion.chunk",
+        ),
+        ChatCompletionChunk(
+            id="1",
+            choices=[
+                DeltaChoice(
+                    index=1, delta=ChoiceDelta(content="lo "), finish_reason=None
+                )
+            ],
+            created=100000,
+            model="model-id",
+            object="chat.completion.chunk",
+        ),
+        ChatCompletionChunk(
+            id="1",
+            choices=[
+                DeltaChoice(
+                    index=2, delta=ChoiceDelta(content="world"), finish_reason="stop"
+                )
+            ],
+            created=100000,
+            model="model-id",
+            object="chat.completion.chunk",
+        ),
+    ]
+
+    client.chat.completions._post = mock.Mock(return_value=returned_stream)
+    with start_transaction(name="openai tx"):
+        response_stream = client.chat.completions.create(
+            model="some-model", messages=[{"role": "system", "content": "hello"}]
+        )
+        response_string = "".join(
+            map(lambda x: x.choices[0].delta.content, response_stream)
+        )
+    assert response_string == "hello world"
+    tx = events[0]
+    assert tx["type"] == "transaction"
+    span = tx["spans"][0]
+    assert span["op"] == "gen_ai.chat"
+
+    if send_default_pii and include_prompts:
+        assert "hello" in span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES]
+        assert "hello world" in span["data"][SPANDATA.GEN_AI_RESPONSE_TEXT]
+    else:
+        assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["data"]
+        assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["data"]
+
+    try:
+        import tiktoken  # type: ignore # noqa # pylint: disable=unused-import
+
+        assert span["data"]["gen_ai.usage.output_tokens"] == 2
+        assert span["data"]["gen_ai.usage.input_tokens"] == 1
+        assert span["data"]["gen_ai.usage.total_tokens"] == 3
+    except ImportError:
+        pass  # if tiktoken is not installed, we can't guarantee token usage will be calculated properly
+
+
+# noinspection PyTypeChecker
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    "send_default_pii, include_prompts",
+    [(True, True), (True, False), (False, True), (False, False)],
+)
+async def test_streaming_chat_completion_async(
+    sentry_init, capture_events, send_default_pii, include_prompts
+):
+    sentry_init(
+        integrations=[
+            OpenAIIntegration(
+                include_prompts=include_prompts,
+                tiktoken_encoding_name=tiktoken_encoding_if_installed(),
+            )
+        ],
+        traces_sample_rate=1.0,
+        send_default_pii=send_default_pii,
+    )
+    events = capture_events()
+
+    client = AsyncOpenAI(api_key="z")
+    returned_stream = AsyncStream(cast_to=None, response=None, client=client)
+    returned_stream._iterator = async_iterator(
+        [
+            ChatCompletionChunk(
+                id="1",
+                choices=[
+                    DeltaChoice(
+                        index=0, delta=ChoiceDelta(content="hel"), finish_reason=None
+                    )
+                ],
+                created=100000,
+                model="model-id",
+                object="chat.completion.chunk",
+            ),
+            ChatCompletionChunk(
+                id="1",
+                choices=[
+                    DeltaChoice(
+                        index=1, delta=ChoiceDelta(content="lo "), finish_reason=None
+                    )
+                ],
+                created=100000,
+                model="model-id",
+                object="chat.completion.chunk",
+            ),
+            ChatCompletionChunk(
+                id="1",
+                choices=[
+                    DeltaChoice(
+                        index=2,
+                        delta=ChoiceDelta(content="world"),
+                        finish_reason="stop",
+                    )
+                ],
+                created=100000,
+                model="model-id",
+                object="chat.completion.chunk",
+            ),
+        ]
+    )
+
+    client.chat.completions._post = AsyncMock(return_value=returned_stream)
+    with start_transaction(name="openai tx"):
+        response_stream = await client.chat.completions.create(
+            model="some-model", messages=[{"role": "system", "content": "hello"}]
+        )
+
+        response_string = ""
+        async for x in response_stream:
+            response_string += x.choices[0].delta.content
+
+    assert response_string == "hello world"
+    tx = events[0]
+    assert tx["type"] == "transaction"
+    span = tx["spans"][0]
+    assert span["op"] == "gen_ai.chat"
+
+    if send_default_pii and include_prompts:
+        assert "hello" in span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES]
+        assert "hello world" in span["data"][SPANDATA.GEN_AI_RESPONSE_TEXT]
+    else:
+        assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["data"]
+        assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["data"]
+
+    try:
+        import tiktoken  # type: ignore # noqa # pylint: disable=unused-import
+
+        assert span["data"]["gen_ai.usage.output_tokens"] == 2
+        assert span["data"]["gen_ai.usage.input_tokens"] == 1
+        assert span["data"]["gen_ai.usage.total_tokens"] == 3
+    except ImportError:
+        pass  # if tiktoken is not installed, we can't guarantee token usage will be calculated properly
+
+
+def test_bad_chat_completion(sentry_init, capture_events):
+    sentry_init(integrations=[OpenAIIntegration()], traces_sample_rate=1.0)
+    events = capture_events()
+
+    client = OpenAI(api_key="z")
+    client.chat.completions._post = mock.Mock(
+        side_effect=OpenAIError("API rate limit reached")
+    )
+    with pytest.raises(OpenAIError):
+        client.chat.completions.create(
+            model="some-model", messages=[{"role": "system", "content": "hello"}]
+        )
+
+    (event,) = events
+    assert event["level"] == "error"
+
+
+def test_span_status_error(sentry_init, capture_events):
+    sentry_init(integrations=[OpenAIIntegration()], traces_sample_rate=1.0)
+    events = capture_events()
+
+    with start_transaction(name="test"):
+        client = OpenAI(api_key="z")
+        client.chat.completions._post = mock.Mock(
+            side_effect=OpenAIError("API rate limit reached")
+        )
+        with pytest.raises(OpenAIError):
+            client.chat.completions.create(
+                model="some-model", messages=[{"role": "system", "content": "hello"}]
+            )
+
+    (error, transaction) = events
+    assert error["level"] == "error"
+    assert transaction["spans"][0]["status"] == "internal_error"
+    assert transaction["spans"][0]["tags"]["status"] == "internal_error"
+    assert transaction["contexts"]["trace"]["status"] == "internal_error"
+
+
+@pytest.mark.asyncio
+async def test_bad_chat_completion_async(sentry_init, capture_events):
+    sentry_init(integrations=[OpenAIIntegration()], traces_sample_rate=1.0)
+    events = capture_events()
+
+    client = AsyncOpenAI(api_key="z")
+    client.chat.completions._post = AsyncMock(
+        side_effect=OpenAIError("API rate limit reached")
+    )
+    with pytest.raises(OpenAIError):
+        await client.chat.completions.create(
+            model="some-model", messages=[{"role": "system", "content": "hello"}]
+        )
+
+    (event,) = events
+    assert event["level"] == "error"
+
+
+@pytest.mark.parametrize(
+    "send_default_pii, include_prompts",
+    [(True, True), (True, False), (False, True), (False, False)],
+)
+def test_embeddings_create(
+    sentry_init, capture_events, send_default_pii, include_prompts
+):
+    sentry_init(
+        integrations=[OpenAIIntegration(include_prompts=include_prompts)],
+        traces_sample_rate=1.0,
+        send_default_pii=send_default_pii,
+    )
+    events = capture_events()
+
+    client = OpenAI(api_key="z")
+
+    returned_embedding = CreateEmbeddingResponse(
+        data=[Embedding(object="embedding", index=0, embedding=[1.0, 2.0, 3.0])],
+        model="some-model",
+        object="list",
+        usage=EmbeddingTokenUsage(
+            prompt_tokens=20,
+            total_tokens=30,
+        ),
+    )
+
+    client.embeddings._post = mock.Mock(return_value=returned_embedding)
+    with start_transaction(name="openai tx"):
+        response = client.embeddings.create(
+            input="hello", model="text-embedding-3-large"
+        )
+
+    assert len(response.data[0].embedding) == 3
+
+    tx = events[0]
+    assert tx["type"] == "transaction"
+    span = tx["spans"][0]
+    assert span["op"] == "gen_ai.embeddings"
+    if send_default_pii and include_prompts:
+        assert "hello" in span["data"][SPANDATA.GEN_AI_EMBEDDINGS_INPUT]
+    else:
+        assert SPANDATA.GEN_AI_EMBEDDINGS_INPUT not in span["data"]
+
+    assert span["data"]["gen_ai.usage.input_tokens"] == 20
+    assert span["data"]["gen_ai.usage.total_tokens"] == 30
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    "send_default_pii, include_prompts",
+    [(True, True), (True, False), (False, True), (False, False)],
+)
+async def test_embeddings_create_async(
+    sentry_init, capture_events, send_default_pii, include_prompts
+):
+    sentry_init(
+        integrations=[OpenAIIntegration(include_prompts=include_prompts)],
+        traces_sample_rate=1.0,
+        send_default_pii=send_default_pii,
+    )
+    events = capture_events()
+
+    client = AsyncOpenAI(api_key="z")
+
+    returned_embedding = CreateEmbeddingResponse(
+        data=[Embedding(object="embedding", index=0, embedding=[1.0, 2.0, 3.0])],
+        model="some-model",
+        object="list",
+        usage=EmbeddingTokenUsage(
+            prompt_tokens=20,
+            total_tokens=30,
+        ),
+    )
+
+    client.embeddings._post = AsyncMock(return_value=returned_embedding)
+    with start_transaction(name="openai tx"):
+        response = await client.embeddings.create(
+            input="hello", model="text-embedding-3-large"
+        )
+
+    assert len(response.data[0].embedding) == 3
+
+    tx = events[0]
+    assert tx["type"] == "transaction"
+    span = tx["spans"][0]
+    assert span["op"] == "gen_ai.embeddings"
+    if send_default_pii and include_prompts:
+        assert "hello" in span["data"][SPANDATA.GEN_AI_EMBEDDINGS_INPUT]
+    else:
+        assert SPANDATA.GEN_AI_EMBEDDINGS_INPUT not in span["data"]
+
+    assert span["data"]["gen_ai.usage.input_tokens"] == 20
+    assert span["data"]["gen_ai.usage.total_tokens"] == 30
+
+
+@pytest.mark.parametrize(
+    "send_default_pii, include_prompts",
+    [(True, True), (True, False), (False, True), (False, False)],
+)
+def test_embeddings_create_raises_error(
+    sentry_init, capture_events, send_default_pii, include_prompts
+):
+    sentry_init(
+        integrations=[OpenAIIntegration(include_prompts=include_prompts)],
+        traces_sample_rate=1.0,
+        send_default_pii=send_default_pii,
+    )
+    events = capture_events()
+
+    client = OpenAI(api_key="z")
+
+    client.embeddings._post = mock.Mock(
+        side_effect=OpenAIError("API rate limit reached")
+    )
+
+    with pytest.raises(OpenAIError):
+        client.embeddings.create(input="hello", model="text-embedding-3-large")
+
+    (event,) = events
+    assert event["level"] == "error"
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    "send_default_pii, include_prompts",
+    [(True, True), (True, False), (False, True), (False, False)],
+)
+async def test_embeddings_create_raises_error_async(
+    sentry_init, capture_events, send_default_pii, include_prompts
+):
+    sentry_init(
+        integrations=[OpenAIIntegration(include_prompts=include_prompts)],
+        traces_sample_rate=1.0,
+        send_default_pii=send_default_pii,
+    )
+    events = capture_events()
+
+    client = AsyncOpenAI(api_key="z")
+
+    client.embeddings._post = AsyncMock(
+        side_effect=OpenAIError("API rate limit reached")
+    )
+
+    with pytest.raises(OpenAIError):
+        await client.embeddings.create(input="hello", model="text-embedding-3-large")
+
+    (event,) = events
+    assert event["level"] == "error"
+
+
+def test_span_origin_nonstreaming_chat(sentry_init, capture_events):
+    sentry_init(
+        integrations=[OpenAIIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    client = OpenAI(api_key="z")
+    client.chat.completions._post = mock.Mock(return_value=EXAMPLE_CHAT_COMPLETION)
+
+    with start_transaction(name="openai tx"):
+        client.chat.completions.create(
+            model="some-model", messages=[{"role": "system", "content": "hello"}]
+        )
+
+    (event,) = events
+
+    assert event["contexts"]["trace"]["origin"] == "manual"
+    assert event["spans"][0]["origin"] == "auto.ai.openai"
+
+
+@pytest.mark.asyncio
+async def test_span_origin_nonstreaming_chat_async(sentry_init, capture_events):
+    sentry_init(
+        integrations=[OpenAIIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    client = AsyncOpenAI(api_key="z")
+    client.chat.completions._post = AsyncMock(return_value=EXAMPLE_CHAT_COMPLETION)
+
+    with start_transaction(name="openai tx"):
+        await client.chat.completions.create(
+            model="some-model", messages=[{"role": "system", "content": "hello"}]
+        )
+
+    (event,) = events
+
+    assert event["contexts"]["trace"]["origin"] == "manual"
+    assert event["spans"][0]["origin"] == "auto.ai.openai"
+
+
+def test_span_origin_streaming_chat(sentry_init, capture_events):
+    sentry_init(
+        integrations=[OpenAIIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    client = OpenAI(api_key="z")
+    returned_stream = Stream(cast_to=None, response=None, client=client)
+    returned_stream._iterator = [
+        ChatCompletionChunk(
+            id="1",
+            choices=[
+                DeltaChoice(
+                    index=0, delta=ChoiceDelta(content="hel"), finish_reason=None
+                )
+            ],
+            created=100000,
+            model="model-id",
+            object="chat.completion.chunk",
+        ),
+        ChatCompletionChunk(
+            id="1",
+            choices=[
+                DeltaChoice(
+                    index=1, delta=ChoiceDelta(content="lo "), finish_reason=None
+                )
+            ],
+            created=100000,
+            model="model-id",
+            object="chat.completion.chunk",
+        ),
+        ChatCompletionChunk(
+            id="1",
+            choices=[
+                DeltaChoice(
+                    index=2, delta=ChoiceDelta(content="world"), finish_reason="stop"
+                )
+            ],
+            created=100000,
+            model="model-id",
+            object="chat.completion.chunk",
+        ),
+    ]
+
+    client.chat.completions._post = mock.Mock(return_value=returned_stream)
+    with start_transaction(name="openai tx"):
+        response_stream = client.chat.completions.create(
+            model="some-model", messages=[{"role": "system", "content": "hello"}]
+        )
+
+        "".join(map(lambda x: x.choices[0].delta.content, response_stream))
+
+    (event,) = events
+
+    assert event["contexts"]["trace"]["origin"] == "manual"
+    assert event["spans"][0]["origin"] == "auto.ai.openai"
+
+
+@pytest.mark.asyncio
+async def test_span_origin_streaming_chat_async(sentry_init, capture_events):
+    sentry_init(
+        integrations=[OpenAIIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    client = AsyncOpenAI(api_key="z")
+    returned_stream = AsyncStream(cast_to=None, response=None, client=client)
+    returned_stream._iterator = async_iterator(
+        [
+            ChatCompletionChunk(
+                id="1",
+                choices=[
+                    DeltaChoice(
+                        index=0, delta=ChoiceDelta(content="hel"), finish_reason=None
+                    )
+                ],
+                created=100000,
+                model="model-id",
+                object="chat.completion.chunk",
+            ),
+            ChatCompletionChunk(
+                id="1",
+                choices=[
+                    DeltaChoice(
+                        index=1, delta=ChoiceDelta(content="lo "), finish_reason=None
+                    )
+                ],
+                created=100000,
+                model="model-id",
+                object="chat.completion.chunk",
+            ),
+            ChatCompletionChunk(
+                id="1",
+                choices=[
+                    DeltaChoice(
+                        index=2,
+                        delta=ChoiceDelta(content="world"),
+                        finish_reason="stop",
+                    )
+                ],
+                created=100000,
+                model="model-id",
+                object="chat.completion.chunk",
+            ),
+        ]
+    )
+
+    client.chat.completions._post = AsyncMock(return_value=returned_stream)
+    with start_transaction(name="openai tx"):
+        response_stream = await client.chat.completions.create(
+            model="some-model", messages=[{"role": "system", "content": "hello"}]
+        )
+        async for _ in response_stream:
+            pass
+
+        # "".join(map(lambda x: x.choices[0].delta.content, response_stream))
+
+    (event,) = events
+
+    assert event["contexts"]["trace"]["origin"] == "manual"
+    assert event["spans"][0]["origin"] == "auto.ai.openai"
+
+
+def test_span_origin_embeddings(sentry_init, capture_events):
+    sentry_init(
+        integrations=[OpenAIIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    client = OpenAI(api_key="z")
+
+    returned_embedding = CreateEmbeddingResponse(
+        data=[Embedding(object="embedding", index=0, embedding=[1.0, 2.0, 3.0])],
+        model="some-model",
+        object="list",
+        usage=EmbeddingTokenUsage(
+            prompt_tokens=20,
+            total_tokens=30,
+        ),
+    )
+
+    client.embeddings._post = mock.Mock(return_value=returned_embedding)
+    with start_transaction(name="openai tx"):
+        client.embeddings.create(input="hello", model="text-embedding-3-large")
+
+    (event,) = events
+
+    assert event["contexts"]["trace"]["origin"] == "manual"
+    assert event["spans"][0]["origin"] == "auto.ai.openai"
+
+
+@pytest.mark.asyncio
+async def test_span_origin_embeddings_async(sentry_init, capture_events):
+    sentry_init(
+        integrations=[OpenAIIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    client = AsyncOpenAI(api_key="z")
+
+    returned_embedding = CreateEmbeddingResponse(
+        data=[Embedding(object="embedding", index=0, embedding=[1.0, 2.0, 3.0])],
+        model="some-model",
+        object="list",
+        usage=EmbeddingTokenUsage(
+            prompt_tokens=20,
+            total_tokens=30,
+        ),
+    )
+
+    client.embeddings._post = AsyncMock(return_value=returned_embedding)
+    with start_transaction(name="openai tx"):
+        await client.embeddings.create(input="hello", model="text-embedding-3-large")
+
+    (event,) = events
+
+    assert event["contexts"]["trace"]["origin"] == "manual"
+    assert event["spans"][0]["origin"] == "auto.ai.openai"
+
+
+def test_calculate_token_usage_a():
+    span = mock.MagicMock()
+
+    def count_tokens(msg):
+        return len(str(msg))
+
+    response = mock.MagicMock()
+    response.usage = mock.MagicMock()
+    response.usage.completion_tokens = 10
+    response.usage.prompt_tokens = 20
+    response.usage.total_tokens = 30
+    messages = []
+    streaming_message_responses = []
+
+    with mock.patch(
+        "sentry_sdk.integrations.openai.record_token_usage"
+    ) as mock_record_token_usage:
+        _calculate_token_usage(
+            messages, response, span, streaming_message_responses, count_tokens
+        )
+        mock_record_token_usage.assert_called_once_with(
+            span,
+            input_tokens=20,
+            input_tokens_cached=None,
+            output_tokens=10,
+            output_tokens_reasoning=None,
+            total_tokens=30,
+        )
+
+
+def test_calculate_token_usage_b():
+    span = mock.MagicMock()
+
+    def count_tokens(msg):
+        return len(str(msg))
+
+    response = mock.MagicMock()
+    response.usage = mock.MagicMock()
+    response.usage.completion_tokens = 10
+    response.usage.total_tokens = 10
+    messages = [
+        {"content": "one"},
+        {"content": "two"},
+        {"content": "three"},
+    ]
+    streaming_message_responses = []
+
+    with mock.patch(
+        "sentry_sdk.integrations.openai.record_token_usage"
+    ) as mock_record_token_usage:
+        _calculate_token_usage(
+            messages, response, span, streaming_message_responses, count_tokens
+        )
+        mock_record_token_usage.assert_called_once_with(
+            span,
+            input_tokens=11,
+            input_tokens_cached=None,
+            output_tokens=10,
+            output_tokens_reasoning=None,
+            total_tokens=10,
+        )
+
+
+def test_calculate_token_usage_c():
+    span = mock.MagicMock()
+
+    def count_tokens(msg):
+        return len(str(msg))
+
+    response = mock.MagicMock()
+    response.usage = mock.MagicMock()
+    response.usage.prompt_tokens = 20
+    response.usage.total_tokens = 20
+    messages = []
+    streaming_message_responses = [
+        "one",
+        "two",
+        "three",
+    ]
+
+    with mock.patch(
+        "sentry_sdk.integrations.openai.record_token_usage"
+    ) as mock_record_token_usage:
+        _calculate_token_usage(
+            messages, response, span, streaming_message_responses, count_tokens
+        )
+        mock_record_token_usage.assert_called_once_with(
+            span,
+            input_tokens=20,
+            input_tokens_cached=None,
+            output_tokens=11,
+            output_tokens_reasoning=None,
+            total_tokens=20,
+        )
+
+
+def test_calculate_token_usage_d():
+    span = mock.MagicMock()
+
+    def count_tokens(msg):
+        return len(str(msg))
+
+    response = mock.MagicMock()
+    response.usage = mock.MagicMock()
+    response.usage.prompt_tokens = 20
+    response.usage.total_tokens = 20
+    response.choices = [
+        mock.MagicMock(message="one"),
+        mock.MagicMock(message="two"),
+        mock.MagicMock(message="three"),
+    ]
+    messages = []
+    streaming_message_responses = []
+
+    with mock.patch(
+        "sentry_sdk.integrations.openai.record_token_usage"
+    ) as mock_record_token_usage:
+        _calculate_token_usage(
+            messages, response, span, streaming_message_responses, count_tokens
+        )
+        mock_record_token_usage.assert_called_once_with(
+            span,
+            input_tokens=20,
+            input_tokens_cached=None,
+            output_tokens=None,
+            output_tokens_reasoning=None,
+            total_tokens=20,
+        )
+
+
+def test_calculate_token_usage_e():
+    span = mock.MagicMock()
+
+    def count_tokens(msg):
+        return len(str(msg))
+
+    response = mock.MagicMock()
+    messages = []
+    streaming_message_responses = None
+
+    with mock.patch(
+        "sentry_sdk.integrations.openai.record_token_usage"
+    ) as mock_record_token_usage:
+        _calculate_token_usage(
+            messages, response, span, streaming_message_responses, count_tokens
+        )
+        mock_record_token_usage.assert_called_once_with(
+            span,
+            input_tokens=None,
+            input_tokens_cached=None,
+            output_tokens=None,
+            output_tokens_reasoning=None,
+            total_tokens=None,
+        )
+
+
+@pytest.mark.skipif(SKIP_RESPONSES_TESTS, reason="Responses API not available")
+def test_ai_client_span_responses_api_no_pii(sentry_init, capture_events):
+    sentry_init(
+        integrations=[OpenAIIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    client = OpenAI(api_key="z")
+    client.responses._post = mock.Mock(return_value=EXAMPLE_RESPONSE)
+
+    with start_transaction(name="openai tx"):
+        client.responses.create(
+            model="gpt-4o",
+            instructions="You are a coding assistant that talks like a pirate.",
+            input="How do I check if a Python object is an instance of a class?",
+        )
+
+    (transaction,) = events
+    spans = transaction["spans"]
+
+    assert len(spans) == 1
+    assert spans[0]["op"] == "gen_ai.responses"
+    assert spans[0]["origin"] == "auto.ai.openai"
+    assert spans[0]["data"] == {
+        "gen_ai.operation.name": "responses",
+        "gen_ai.request.model": "gpt-4o",
+        "gen_ai.response.model": "response-model-id",
+        "gen_ai.system": "openai",
+        "gen_ai.usage.input_tokens": 20,
+        "gen_ai.usage.input_tokens.cached": 5,
+        "gen_ai.usage.output_tokens": 10,
+        "gen_ai.usage.output_tokens.reasoning": 8,
+        "gen_ai.usage.total_tokens": 30,
+        "thread.id": mock.ANY,
+        "thread.name": mock.ANY,
+    }
+
+    assert "gen_ai.request.messages" not in spans[0]["data"]
+    assert "gen_ai.response.text" not in spans[0]["data"]
+
+
+@pytest.mark.skipif(SKIP_RESPONSES_TESTS, reason="Responses API not available")
+def test_ai_client_span_responses_api(sentry_init, capture_events):
+    sentry_init(
+        integrations=[OpenAIIntegration(include_prompts=True)],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+    events = capture_events()
+
+    client = OpenAI(api_key="z")
+    client.responses._post = mock.Mock(return_value=EXAMPLE_RESPONSE)
+
+    with start_transaction(name="openai tx"):
+        client.responses.create(
+            model="gpt-4o",
+            instructions="You are a coding assistant that talks like a pirate.",
+            input="How do I check if a Python object is an instance of a class?",
+        )
+
+    (transaction,) = events
+    spans = transaction["spans"]
+
+    assert len(spans) == 1
+    assert spans[0]["op"] == "gen_ai.responses"
+    assert spans[0]["origin"] == "auto.ai.openai"
+    assert spans[0]["data"] == {
+        "gen_ai.operation.name": "responses",
+        "gen_ai.request.messages": '["How do I check if a Python object is an instance of a class?"]',
+        "gen_ai.request.model": "gpt-4o",
+        "gen_ai.system": "openai",
+        "gen_ai.response.model": "response-model-id",
+        "gen_ai.usage.input_tokens": 20,
+        "gen_ai.usage.input_tokens.cached": 5,
+        "gen_ai.usage.output_tokens": 10,
+        "gen_ai.usage.output_tokens.reasoning": 8,
+        "gen_ai.usage.total_tokens": 30,
+        "gen_ai.response.text": "the model response",
+        "thread.id": mock.ANY,
+        "thread.name": mock.ANY,
+    }
+
+
+@pytest.mark.skipif(SKIP_RESPONSES_TESTS, reason="Responses API not available")
+def test_error_in_responses_api(sentry_init, capture_events):
+    sentry_init(
+        integrations=[OpenAIIntegration(include_prompts=True)],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+    events = capture_events()
+
+    client = OpenAI(api_key="z")
+    client.responses._post = mock.Mock(
+        side_effect=OpenAIError("API rate limit reached")
+    )
+
+    with start_transaction(name="openai tx"):
+        with pytest.raises(OpenAIError):
+            client.responses.create(
+                model="gpt-4o",
+                instructions="You are a coding assistant that talks like a pirate.",
+                input="How do I check if a Python object is an instance of a class?",
+            )
+
+    (error_event, transaction_event) = events
+
+    assert transaction_event["type"] == "transaction"
+    # make sure the span where the error occurred is captured
+    assert transaction_event["spans"][0]["op"] == "gen_ai.responses"
+
+    assert error_event["level"] == "error"
+    assert error_event["exception"]["values"][0]["type"] == "OpenAIError"
+
+    assert (
+        error_event["contexts"]["trace"]["trace_id"]
+        == transaction_event["contexts"]["trace"]["trace_id"]
+    )
+
+
+@pytest.mark.asyncio
+@pytest.mark.skipif(SKIP_RESPONSES_TESTS, reason="Responses API not available")
+async def test_ai_client_span_responses_async_api(sentry_init, capture_events):
+    sentry_init(
+        integrations=[OpenAIIntegration(include_prompts=True)],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+    events = capture_events()
+
+    client = AsyncOpenAI(api_key="z")
+    client.responses._post = AsyncMock(return_value=EXAMPLE_RESPONSE)
+
+    with start_transaction(name="openai tx"):
+        await client.responses.create(
+            model="gpt-4o",
+            instructions="You are a coding assistant that talks like a pirate.",
+            input="How do I check if a Python object is an instance of a class?",
+        )
+
+    (transaction,) = events
+    spans = transaction["spans"]
+
+    assert len(spans) == 1
+    assert spans[0]["op"] == "gen_ai.responses"
+    assert spans[0]["origin"] == "auto.ai.openai"
+    assert spans[0]["data"] == {
+        "gen_ai.operation.name": "responses",
+        "gen_ai.request.messages": '["How do I check if a Python object is an instance of a class?"]',
+        "gen_ai.request.model": "gpt-4o",
+        "gen_ai.response.model": "response-model-id",
+        "gen_ai.system": "openai",
+        "gen_ai.usage.input_tokens": 20,
+        "gen_ai.usage.input_tokens.cached": 5,
+        "gen_ai.usage.output_tokens": 10,
+        "gen_ai.usage.output_tokens.reasoning": 8,
+        "gen_ai.usage.total_tokens": 30,
+        "gen_ai.response.text": "the model response",
+        "thread.id": mock.ANY,
+        "thread.name": mock.ANY,
+    }
+
+
+@pytest.mark.asyncio
+@pytest.mark.skipif(SKIP_RESPONSES_TESTS, reason="Responses API not available")
+async def test_ai_client_span_streaming_responses_async_api(
+    sentry_init, capture_events
+):
+    sentry_init(
+        integrations=[OpenAIIntegration(include_prompts=True)],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+    events = capture_events()
+
+    client = AsyncOpenAI(api_key="z")
+    client.responses._post = AsyncMock(return_value=EXAMPLE_RESPONSE)
+
+    with start_transaction(name="openai tx"):
+        await client.responses.create(
+            model="gpt-4o",
+            instructions="You are a coding assistant that talks like a pirate.",
+            input="How do I check if a Python object is an instance of a class?",
+            stream=True,
+        )
+
+    (transaction,) = events
+    spans = transaction["spans"]
+
+    assert len(spans) == 1
+    assert spans[0]["op"] == "gen_ai.responses"
+    assert spans[0]["origin"] == "auto.ai.openai"
+    assert spans[0]["data"] == {
+        "gen_ai.operation.name": "responses",
+        "gen_ai.request.messages": '["How do I check if a Python object is an instance of a class?"]',
+        "gen_ai.request.model": "gpt-4o",
+        "gen_ai.response.model": "response-model-id",
+        "gen_ai.response.streaming": True,
+        "gen_ai.system": "openai",
+        "gen_ai.usage.input_tokens": 20,
+        "gen_ai.usage.input_tokens.cached": 5,
+        "gen_ai.usage.output_tokens": 10,
+        "gen_ai.usage.output_tokens.reasoning": 8,
+        "gen_ai.usage.total_tokens": 30,
+        "gen_ai.response.text": "the model response",
+        "thread.id": mock.ANY,
+        "thread.name": mock.ANY,
+    }
+
+
+@pytest.mark.asyncio
+@pytest.mark.skipif(SKIP_RESPONSES_TESTS, reason="Responses API not available")
+async def test_error_in_responses_async_api(sentry_init, capture_events):
+    sentry_init(
+        integrations=[OpenAIIntegration(include_prompts=True)],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+    events = capture_events()
+
+    client = AsyncOpenAI(api_key="z")
+    client.responses._post = AsyncMock(
+        side_effect=OpenAIError("API rate limit reached")
+    )
+
+    with start_transaction(name="openai tx"):
+        with pytest.raises(OpenAIError):
+            await client.responses.create(
+                model="gpt-4o",
+                instructions="You are a coding assistant that talks like a pirate.",
+                input="How do I check if a Python object is an instance of a class?",
+            )
+
+    (error_event, transaction_event) = events
+
+    assert transaction_event["type"] == "transaction"
+    # make sure the span where the error occurred is captured
+    assert transaction_event["spans"][0]["op"] == "gen_ai.responses"
+
+    assert error_event["level"] == "error"
+    assert error_event["exception"]["values"][0]["type"] == "OpenAIError"
+
+    assert (
+        error_event["contexts"]["trace"]["trace_id"]
+        == transaction_event["contexts"]["trace"]["trace_id"]
+    )
+
+
+if SKIP_RESPONSES_TESTS:
+    EXAMPLE_RESPONSES_STREAM = []
+else:
+    EXAMPLE_RESPONSES_STREAM = [
+        ResponseCreatedEvent(
+            sequence_number=1,
+            type="response.created",
+            response=Response(
+                id="chat-id",
+                created_at=10000000,
+                model="response-model-id",
+                object="response",
+                output=[],
+                parallel_tool_calls=False,
+                tool_choice="none",
+                tools=[],
+            ),
+        ),
+        ResponseTextDeltaEvent(
+            item_id="msg_1",
+            sequence_number=2,
+            type="response.output_text.delta",
+            logprobs=[],
+            content_index=0,
+            output_index=0,
+            delta="hel",
+        ),
+        ResponseTextDeltaEvent(
+            item_id="msg_1",
+            sequence_number=3,
+            type="response.output_text.delta",
+            logprobs=[],
+            content_index=0,
+            output_index=0,
+            delta="lo ",
+        ),
+        ResponseTextDeltaEvent(
+            item_id="msg_1",
+            sequence_number=4,
+            type="response.output_text.delta",
+            logprobs=[],
+            content_index=0,
+            output_index=0,
+            delta="world",
+        ),
+        ResponseCompletedEvent(
+            sequence_number=5,
+            type="response.completed",
+            response=Response(
+                id="chat-id",
+                created_at=10000000,
+                model="response-model-id",
+                object="response",
+                output=[],
+                parallel_tool_calls=False,
+                tool_choice="none",
+                tools=[],
+                usage=ResponseUsage(
+                    input_tokens=20,
+                    input_tokens_details=InputTokensDetails(
+                        cached_tokens=5,
+                    ),
+                    output_tokens=10,
+                    output_tokens_details=OutputTokensDetails(
+                        reasoning_tokens=8,
+                    ),
+                    total_tokens=30,
+                ),
+            ),
+        ),
+    ]
+
+
+@pytest.mark.parametrize(
+    "send_default_pii, include_prompts",
+    [(True, True), (True, False), (False, True), (False, False)],
+)
+@pytest.mark.skipif(SKIP_RESPONSES_TESTS, reason="Responses API not available")
+def test_streaming_responses_api(
+    sentry_init, capture_events, send_default_pii, include_prompts
+):
+    sentry_init(
+        integrations=[
+            OpenAIIntegration(
+                include_prompts=include_prompts,
+            )
+        ],
+        traces_sample_rate=1.0,
+        send_default_pii=send_default_pii,
+    )
+    events = capture_events()
+
+    client = OpenAI(api_key="z")
+    returned_stream = Stream(cast_to=None, response=None, client=client)
+    returned_stream._iterator = EXAMPLE_RESPONSES_STREAM
+    client.responses._post = mock.Mock(return_value=returned_stream)
+
+    with start_transaction(name="openai tx"):
+        response_stream = client.responses.create(
+            model="some-model",
+            input="hello",
+            stream=True,
+        )
+
+        response_string = ""
+        for item in response_stream:
+            if hasattr(item, "delta"):
+                response_string += item.delta
+
+    assert response_string == "hello world"
+
+    (transaction,) = events
+    (span,) = transaction["spans"]
+    assert span["op"] == "gen_ai.responses"
+
+    if send_default_pii and include_prompts:
+        assert span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES] == '["hello"]'
+        assert span["data"][SPANDATA.GEN_AI_RESPONSE_TEXT] == "hello world"
+    else:
+        assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["data"]
+        assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["data"]
+
+    assert span["data"]["gen_ai.usage.input_tokens"] == 20
+    assert span["data"]["gen_ai.usage.output_tokens"] == 10
+    assert span["data"]["gen_ai.usage.total_tokens"] == 30
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    "send_default_pii, include_prompts",
+    [(True, True), (True, False), (False, True), (False, False)],
+)
+@pytest.mark.skipif(SKIP_RESPONSES_TESTS, reason="Responses API not available")
+async def test_streaming_responses_api_async(
+    sentry_init, capture_events, send_default_pii, include_prompts
+):
+    sentry_init(
+        integrations=[
+            OpenAIIntegration(
+                include_prompts=include_prompts,
+            )
+        ],
+        traces_sample_rate=1.0,
+        send_default_pii=send_default_pii,
+    )
+    events = capture_events()
+
+    client = AsyncOpenAI(api_key="z")
+    returned_stream = AsyncStream(cast_to=None, response=None, client=client)
+    returned_stream._iterator = async_iterator(EXAMPLE_RESPONSES_STREAM)
+    client.responses._post = AsyncMock(return_value=returned_stream)
+
+    with start_transaction(name="openai tx"):
+        response_stream = await client.responses.create(
+            model="some-model",
+            input="hello",
+            stream=True,
+        )
+
+        response_string = ""
+        async for item in response_stream:
+            if hasattr(item, "delta"):
+                response_string += item.delta
+
+    assert response_string == "hello world"
+
+    (transaction,) = events
+    (span,) = transaction["spans"]
+    assert span["op"] == "gen_ai.responses"
+
+    if send_default_pii and include_prompts:
+        assert span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES] == '["hello"]'
+        assert span["data"][SPANDATA.GEN_AI_RESPONSE_TEXT] == "hello world"
+    else:
+        assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["data"]
+        assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["data"]
+
+    assert span["data"]["gen_ai.usage.input_tokens"] == 20
+    assert span["data"]["gen_ai.usage.output_tokens"] == 10
+    assert span["data"]["gen_ai.usage.total_tokens"] == 30
+
+
+@pytest.mark.skipif(
+    OPENAI_VERSION <= (1, 1, 0),
+    reason="OpenAI versions <=1.1.0 do not support the tools parameter.",
+)
+@pytest.mark.parametrize(
+    "tools",
+    [[], None, NOT_GIVEN, omit],
+)
+def test_empty_tools_in_chat_completion(sentry_init, capture_events, tools):
+    sentry_init(
+        integrations=[OpenAIIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    client = OpenAI(api_key="z")
+    client.chat.completions._post = mock.Mock(return_value=EXAMPLE_CHAT_COMPLETION)
+
+    with start_transaction(name="openai tx"):
+        client.chat.completions.create(
+            model="some-model",
+            messages=[{"role": "system", "content": "hello"}],
+            tools=tools,
+        )
+
+    (event,) = events
+    span = event["spans"][0]
+
+    assert "gen_ai.request.available_tools" not in span["data"]
+
+
+def test_openai_message_role_mapping(sentry_init, capture_events):
+    """Test that OpenAI integration properly maps message roles like 'ai' to 'assistant'"""
+
+    sentry_init(
+        integrations=[OpenAIIntegration(include_prompts=True)],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+    events = capture_events()
+
+    client = OpenAI(api_key="z")
+    client.chat.completions._post = mock.Mock(return_value=EXAMPLE_CHAT_COMPLETION)
+    # Test messages with mixed roles including "ai" that should be mapped to "assistant"
+    test_messages = [
+        {"role": "system", "content": "You are helpful."},
+        {"role": "user", "content": "Hello"},
+        {"role": "ai", "content": "Hi there!"},  # Should be mapped to "assistant"
+        {"role": "assistant", "content": "How can I help?"},  # Should stay "assistant"
+    ]
+
+    with start_transaction(name="openai tx"):
+        client.chat.completions.create(model="test-model", messages=test_messages)
+    # Verify that the span was created correctly
+    (event,) = events
+    span = event["spans"][0]
+    assert span["op"] == "gen_ai.chat"
+    assert SPANDATA.GEN_AI_REQUEST_MESSAGES in span["data"]
+
+    # Parse the stored messages
+    import json
+
+    stored_messages = json.loads(span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES])
+
+    # Verify that "ai" role was mapped to "assistant"
+    assert len(stored_messages) == 4
+    assert stored_messages[0]["role"] == "system"
+    assert stored_messages[1]["role"] == "user"
+    assert (
+        stored_messages[2]["role"] == "assistant"
+    )  # "ai" should be mapped to "assistant"
+    assert stored_messages[3]["role"] == "assistant"  # should stay "assistant"
+
+    # Verify content is preserved
+    assert stored_messages[2]["content"] == "Hi there!"
+    assert stored_messages[3]["content"] == "How can I help?"
+
+    # Verify no "ai" roles remain
+    roles = [msg["role"] for msg in stored_messages]
+    assert "ai" not in roles
+
+
+def test_openai_message_truncation(sentry_init, capture_events):
+    """Test that large messages are truncated properly in OpenAI integration."""
+    sentry_init(
+        integrations=[OpenAIIntegration(include_prompts=True)],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+    events = capture_events()
+
+    client = OpenAI(api_key="z")
+    client.chat.completions._post = mock.Mock(return_value=EXAMPLE_CHAT_COMPLETION)
+
+    large_content = (
+        "This is a very long message that will exceed our size limits. " * 1000
+    )
+    large_messages = [
+        {"role": "system", "content": "You are a helpful assistant."},
+        {"role": "user", "content": large_content},
+        {"role": "assistant", "content": large_content},
+        {"role": "user", "content": large_content},
+    ]
+
+    with start_transaction(name="openai tx"):
+        client.chat.completions.create(
+            model="some-model",
+            messages=large_messages,
+        )
+
+    (event,) = events
+    span = event["spans"][0]
+    assert SPANDATA.GEN_AI_REQUEST_MESSAGES in span["data"]
+
+    messages_data = span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES]
+    assert isinstance(messages_data, str)
+
+    parsed_messages = json.loads(messages_data)
+    assert isinstance(parsed_messages, list)
+    assert len(parsed_messages) <= len(large_messages)
+
+    if "_meta" in event and len(parsed_messages) < len(large_messages):
+        meta_path = event["_meta"]
+        if (
+            "spans" in meta_path
+            and "0" in meta_path["spans"]
+            and "data" in meta_path["spans"]["0"]
+        ):
+            span_meta = meta_path["spans"]["0"]["data"]
+            if SPANDATA.GEN_AI_REQUEST_MESSAGES in span_meta:
+                messages_meta = span_meta[SPANDATA.GEN_AI_REQUEST_MESSAGES]
+                assert "len" in messages_meta.get("", {})
diff --git a/tests/integrations/openai_agents/__init__.py b/tests/integrations/openai_agents/__init__.py
new file mode 100644
index 0000000000..6940e2bbbe
--- /dev/null
+++ b/tests/integrations/openai_agents/__init__.py
@@ -0,0 +1,3 @@
+import pytest
+
+pytest.importorskip("agents")
diff --git a/tests/integrations/openai_agents/test_openai_agents.py b/tests/integrations/openai_agents/test_openai_agents.py
new file mode 100644
index 0000000000..c5cb25dfee
--- /dev/null
+++ b/tests/integrations/openai_agents/test_openai_agents.py
@@ -0,0 +1,2000 @@
+import asyncio
+import re
+import pytest
+from unittest.mock import MagicMock, patch
+import os
+import json
+
+import sentry_sdk
+from sentry_sdk import start_span
+from sentry_sdk.consts import SPANDATA
+from sentry_sdk.integrations.openai_agents import OpenAIAgentsIntegration
+from sentry_sdk.integrations.openai_agents.utils import _set_input_data, safe_serialize
+from sentry_sdk.utils import parse_version
+
+import agents
+from agents import (
+    Agent,
+    ModelResponse,
+    Usage,
+    ModelSettings,
+)
+from agents.items import (
+    McpCall,
+    ResponseOutputMessage,
+    ResponseOutputText,
+    ResponseFunctionToolCall,
+)
+from agents.exceptions import MaxTurnsExceeded, ModelBehaviorError
+from agents.version import __version__ as OPENAI_AGENTS_VERSION
+
+from openai.types.responses.response_usage import (
+    InputTokensDetails,
+    OutputTokensDetails,
+)
+
+test_run_config = agents.RunConfig(tracing_disabled=True)
+
+
+@pytest.fixture
+def mock_usage():
+    return Usage(
+        requests=1,
+        input_tokens=10,
+        output_tokens=20,
+        total_tokens=30,
+        input_tokens_details=InputTokensDetails(cached_tokens=0),
+        output_tokens_details=OutputTokensDetails(reasoning_tokens=5),
+    )
+
+
+@pytest.fixture
+def mock_model_response(mock_usage):
+    return ModelResponse(
+        output=[
+            ResponseOutputMessage(
+                id="msg_123",
+                type="message",
+                status="completed",
+                content=[
+                    ResponseOutputText(
+                        text="Hello, how can I help you?",
+                        type="output_text",
+                        annotations=[],
+                    )
+                ],
+                role="assistant",
+            )
+        ],
+        usage=mock_usage,
+        response_id="resp_123",
+    )
+
+
+@pytest.fixture
+def test_agent():
+    """Create a real Agent instance for testing."""
+    return Agent(
+        name="test_agent",
+        instructions="You are a helpful test assistant.",
+        model="gpt-4",
+        model_settings=ModelSettings(
+            max_tokens=100,
+            temperature=0.7,
+            top_p=1.0,
+            presence_penalty=0.0,
+            frequency_penalty=0.0,
+        ),
+    )
+
+
+@pytest.fixture
+def test_agent_custom_model():
+    """Create a real Agent instance for testing."""
+    return Agent(
+        name="test_agent_custom_model",
+        instructions="You are a helpful test assistant.",
+        # the model could be agents.OpenAIChatCompletionsModel()
+        model="my-custom-model",
+        model_settings=ModelSettings(
+            max_tokens=100,
+            temperature=0.7,
+            top_p=1.0,
+            presence_penalty=0.0,
+            frequency_penalty=0.0,
+        ),
+    )
+
+
+@pytest.mark.asyncio
+async def test_agent_invocation_span(
+    sentry_init, capture_events, test_agent, mock_model_response
+):
+    """
+    Test that the integration creates spans for agent invocations.
+    """
+
+    with patch.dict(os.environ, {"OPENAI_API_KEY": "test-key"}):
+        with patch(
+            "agents.models.openai_responses.OpenAIResponsesModel.get_response"
+        ) as mock_get_response:
+            mock_get_response.return_value = mock_model_response
+
+            sentry_init(
+                integrations=[OpenAIAgentsIntegration()],
+                traces_sample_rate=1.0,
+                send_default_pii=True,
+            )
+
+            events = capture_events()
+
+            result = await agents.Runner.run(
+                test_agent, "Test input", run_config=test_run_config
+            )
+
+            assert result is not None
+            assert result.final_output == "Hello, how can I help you?"
+
+    (transaction,) = events
+    spans = transaction["spans"]
+    invoke_agent_span, ai_client_span = spans
+
+    assert transaction["transaction"] == "test_agent workflow"
+    assert transaction["contexts"]["trace"]["origin"] == "auto.ai.openai_agents"
+
+    assert invoke_agent_span["description"] == "invoke_agent test_agent"
+    assert invoke_agent_span["data"]["gen_ai.request.messages"] == safe_serialize(
+        [
+            {
+                "content": [
+                    {"text": "You are a helpful test assistant.", "type": "text"}
+                ],
+                "role": "system",
+            },
+            {"content": [{"text": "Test input", "type": "text"}], "role": "user"},
+        ]
+    )
+    assert (
+        invoke_agent_span["data"]["gen_ai.response.text"]
+        == "Hello, how can I help you?"
+    )
+    assert invoke_agent_span["data"]["gen_ai.operation.name"] == "invoke_agent"
+    assert invoke_agent_span["data"]["gen_ai.system"] == "openai"
+    assert invoke_agent_span["data"]["gen_ai.agent.name"] == "test_agent"
+    assert invoke_agent_span["data"]["gen_ai.request.max_tokens"] == 100
+    assert invoke_agent_span["data"]["gen_ai.request.model"] == "gpt-4"
+    assert invoke_agent_span["data"]["gen_ai.request.temperature"] == 0.7
+    assert invoke_agent_span["data"]["gen_ai.request.top_p"] == 1.0
+
+    assert ai_client_span["description"] == "chat gpt-4"
+    assert ai_client_span["data"]["gen_ai.operation.name"] == "chat"
+    assert ai_client_span["data"]["gen_ai.system"] == "openai"
+    assert ai_client_span["data"]["gen_ai.agent.name"] == "test_agent"
+    assert ai_client_span["data"]["gen_ai.request.max_tokens"] == 100
+    assert ai_client_span["data"]["gen_ai.request.model"] == "gpt-4"
+    assert ai_client_span["data"]["gen_ai.request.temperature"] == 0.7
+    assert ai_client_span["data"]["gen_ai.request.top_p"] == 1.0
+
+
+@pytest.mark.asyncio
+async def test_client_span_custom_model(
+    sentry_init, capture_events, test_agent_custom_model, mock_model_response
+):
+    """
+    Test that the integration uses the correct model name if a custom model is used.
+    """
+
+    with patch.dict(os.environ, {"OPENAI_API_KEY": "test-key"}):
+        with patch(
+            "agents.models.openai_responses.OpenAIResponsesModel.get_response"
+        ) as mock_get_response:
+            mock_get_response.return_value = mock_model_response
+
+            sentry_init(
+                integrations=[OpenAIAgentsIntegration()],
+                traces_sample_rate=1.0,
+            )
+
+            events = capture_events()
+
+            result = await agents.Runner.run(
+                test_agent_custom_model, "Test input", run_config=test_run_config
+            )
+
+            assert result is not None
+            assert result.final_output == "Hello, how can I help you?"
+
+    (transaction,) = events
+    spans = transaction["spans"]
+    _, ai_client_span = spans
+
+    assert ai_client_span["description"] == "chat my-custom-model"
+    assert ai_client_span["data"]["gen_ai.request.model"] == "my-custom-model"
+
+
+def test_agent_invocation_span_sync(
+    sentry_init, capture_events, test_agent, mock_model_response
+):
+    """
+    Test that the integration creates spans for agent invocations.
+    """
+
+    with patch.dict(os.environ, {"OPENAI_API_KEY": "test-key"}):
+        with patch(
+            "agents.models.openai_responses.OpenAIResponsesModel.get_response"
+        ) as mock_get_response:
+            mock_get_response.return_value = mock_model_response
+
+            sentry_init(
+                integrations=[OpenAIAgentsIntegration()],
+                traces_sample_rate=1.0,
+            )
+
+            events = capture_events()
+
+            result = agents.Runner.run_sync(
+                test_agent, "Test input", run_config=test_run_config
+            )
+
+            assert result is not None
+            assert result.final_output == "Hello, how can I help you?"
+
+    (transaction,) = events
+    spans = transaction["spans"]
+    invoke_agent_span, ai_client_span = spans
+
+    assert transaction["transaction"] == "test_agent workflow"
+    assert transaction["contexts"]["trace"]["origin"] == "auto.ai.openai_agents"
+
+    assert invoke_agent_span["description"] == "invoke_agent test_agent"
+    assert invoke_agent_span["data"]["gen_ai.operation.name"] == "invoke_agent"
+    assert invoke_agent_span["data"]["gen_ai.system"] == "openai"
+    assert invoke_agent_span["data"]["gen_ai.agent.name"] == "test_agent"
+    assert invoke_agent_span["data"]["gen_ai.request.max_tokens"] == 100
+    assert invoke_agent_span["data"]["gen_ai.request.model"] == "gpt-4"
+    assert invoke_agent_span["data"]["gen_ai.request.temperature"] == 0.7
+    assert invoke_agent_span["data"]["gen_ai.request.top_p"] == 1.0
+
+    assert ai_client_span["description"] == "chat gpt-4"
+    assert ai_client_span["data"]["gen_ai.operation.name"] == "chat"
+    assert ai_client_span["data"]["gen_ai.system"] == "openai"
+    assert ai_client_span["data"]["gen_ai.agent.name"] == "test_agent"
+    assert ai_client_span["data"]["gen_ai.request.max_tokens"] == 100
+    assert ai_client_span["data"]["gen_ai.request.model"] == "gpt-4"
+    assert ai_client_span["data"]["gen_ai.request.temperature"] == 0.7
+    assert ai_client_span["data"]["gen_ai.request.top_p"] == 1.0
+
+
+@pytest.mark.asyncio
+async def test_handoff_span(sentry_init, capture_events, mock_usage):
+    """
+    Test that handoff spans are created when agents hand off to other agents.
+    """
+    # Create two simple agents with a handoff relationship
+    secondary_agent = agents.Agent(
+        name="secondary_agent",
+        instructions="You are a secondary agent.",
+        model="gpt-4o-mini",
+    )
+
+    primary_agent = agents.Agent(
+        name="primary_agent",
+        instructions="You are a primary agent that hands off to secondary agent.",
+        model="gpt-4o-mini",
+        handoffs=[secondary_agent],
+    )
+
+    with patch.dict(os.environ, {"OPENAI_API_KEY": "test-key"}):
+        with patch(
+            "agents.models.openai_responses.OpenAIResponsesModel.get_response"
+        ) as mock_get_response:
+            # Mock two responses:
+            # 1. Primary agent calls handoff tool
+            # 2. Secondary agent provides final response
+            handoff_response = ModelResponse(
+                output=[
+                    ResponseFunctionToolCall(
+                        id="call_handoff_123",
+                        call_id="call_handoff_123",
+                        name="transfer_to_secondary_agent",
+                        type="function_call",
+                        arguments="{}",
+                    )
+                ],
+                usage=mock_usage,
+                response_id="resp_handoff_123",
+            )
+
+            final_response = ModelResponse(
+                output=[
+                    ResponseOutputMessage(
+                        id="msg_final",
+                        type="message",
+                        status="completed",
+                        content=[
+                            ResponseOutputText(
+                                text="I'm the specialist and I can help with that!",
+                                type="output_text",
+                                annotations=[],
+                            )
+                        ],
+                        role="assistant",
+                    )
+                ],
+                usage=mock_usage,
+                response_id="resp_final_123",
+            )
+
+            mock_get_response.side_effect = [handoff_response, final_response]
+
+            sentry_init(
+                integrations=[OpenAIAgentsIntegration()],
+                traces_sample_rate=1.0,
+            )
+
+            events = capture_events()
+
+            result = await agents.Runner.run(
+                primary_agent,
+                "Please hand off to secondary agent",
+                run_config=test_run_config,
+            )
+
+            assert result is not None
+
+    (transaction,) = events
+    spans = transaction["spans"]
+    handoff_span = spans[2]
+
+    # Verify handoff span was created
+    assert handoff_span is not None
+    assert (
+        handoff_span["description"] == "handoff from primary_agent to secondary_agent"
+    )
+    assert handoff_span["data"]["gen_ai.operation.name"] == "handoff"
+
+
+@pytest.mark.asyncio
+async def test_max_turns_before_handoff_span(sentry_init, capture_events, mock_usage):
+    """
+    Example raising agents.exceptions.AgentsException after the agent invocation span is complete.
+    """
+    # Create two simple agents with a handoff relationship
+    secondary_agent = agents.Agent(
+        name="secondary_agent",
+        instructions="You are a secondary agent.",
+        model="gpt-4o-mini",
+    )
+
+    primary_agent = agents.Agent(
+        name="primary_agent",
+        instructions="You are a primary agent that hands off to secondary agent.",
+        model="gpt-4o-mini",
+        handoffs=[secondary_agent],
+    )
+
+    with patch.dict(os.environ, {"OPENAI_API_KEY": "test-key"}):
+        with patch(
+            "agents.models.openai_responses.OpenAIResponsesModel.get_response"
+        ) as mock_get_response:
+            # Mock two responses:
+            # 1. Primary agent calls handoff tool
+            # 2. Secondary agent provides final response
+            handoff_response = ModelResponse(
+                output=[
+                    ResponseFunctionToolCall(
+                        id="call_handoff_123",
+                        call_id="call_handoff_123",
+                        name="transfer_to_secondary_agent",
+                        type="function_call",
+                        arguments="{}",
+                    )
+                ],
+                usage=mock_usage,
+                response_id="resp_handoff_123",
+            )
+
+            final_response = ModelResponse(
+                output=[
+                    ResponseOutputMessage(
+                        id="msg_final",
+                        type="message",
+                        status="completed",
+                        content=[
+                            ResponseOutputText(
+                                text="I'm the specialist and I can help with that!",
+                                type="output_text",
+                                annotations=[],
+                            )
+                        ],
+                        role="assistant",
+                    )
+                ],
+                usage=mock_usage,
+                response_id="resp_final_123",
+            )
+
+            mock_get_response.side_effect = [handoff_response, final_response]
+
+            sentry_init(
+                integrations=[OpenAIAgentsIntegration()],
+                traces_sample_rate=1.0,
+            )
+
+            events = capture_events()
+
+            with pytest.raises(MaxTurnsExceeded):
+                await agents.Runner.run(
+                    primary_agent,
+                    "Please hand off to secondary agent",
+                    run_config=test_run_config,
+                    max_turns=1,
+                )
+
+    (error, transaction) = events
+    spans = transaction["spans"]
+    handoff_span = spans[2]
+
+    # Verify handoff span was created
+    assert handoff_span is not None
+    assert (
+        handoff_span["description"] == "handoff from primary_agent to secondary_agent"
+    )
+    assert handoff_span["data"]["gen_ai.operation.name"] == "handoff"
+
+
+@pytest.mark.asyncio
+async def test_tool_execution_span(sentry_init, capture_events, test_agent):
+    """
+    Test tool execution span creation.
+    """
+
+    @agents.function_tool
+    def simple_test_tool(message: str) -> str:
+        """A simple tool"""
+        return f"Tool executed with: {message}"
+
+    # Create agent with the tool
+    agent_with_tool = test_agent.clone(tools=[simple_test_tool])
+
+    with patch.dict(os.environ, {"OPENAI_API_KEY": "test-key"}):
+        with patch(
+            "agents.models.openai_responses.OpenAIResponsesModel.get_response"
+        ) as mock_get_response:
+            # Create a mock response that includes tool calls
+            tool_call = ResponseFunctionToolCall(
+                id="call_123",
+                call_id="call_123",
+                name="simple_test_tool",
+                type="function_call",
+                arguments='{"message": "hello"}',
+            )
+
+            # First response with tool call
+            tool_response = ModelResponse(
+                output=[tool_call],
+                usage=Usage(
+                    requests=1, input_tokens=10, output_tokens=5, total_tokens=15
+                ),
+                response_id="resp_tool_123",
+            )
+
+            # Second response with final answer
+            final_response = ModelResponse(
+                output=[
+                    ResponseOutputMessage(
+                        id="msg_final",
+                        type="message",
+                        status="completed",
+                        content=[
+                            ResponseOutputText(
+                                text="Task completed using the tool",
+                                type="output_text",
+                                annotations=[],
+                            )
+                        ],
+                        role="assistant",
+                    )
+                ],
+                usage=Usage(
+                    requests=1, input_tokens=15, output_tokens=10, total_tokens=25
+                ),
+                response_id="resp_final_123",
+            )
+
+            # Return different responses on successive calls
+            mock_get_response.side_effect = [tool_response, final_response]
+
+            sentry_init(
+                integrations=[OpenAIAgentsIntegration()],
+                traces_sample_rate=1.0,
+                send_default_pii=True,
+            )
+
+            events = capture_events()
+
+            await agents.Runner.run(
+                agent_with_tool,
+                "Please use the simple test tool",
+                run_config=test_run_config,
+            )
+
+    (transaction,) = events
+    spans = transaction["spans"]
+    (
+        agent_span,
+        ai_client_span1,
+        tool_span,
+        ai_client_span2,
+    ) = spans
+
+    available_tools = [
+        {
+            "name": "simple_test_tool",
+            "description": "A simple tool",
+            "params_json_schema": {
+                "properties": {"message": {"title": "Message", "type": "string"}},
+                "required": ["message"],
+                "title": "simple_test_tool_args",
+                "type": "object",
+                "additionalProperties": False,
+            },
+            "on_invoke_tool": "._create_function_tool.._on_invoke_tool>",
+            "strict_json_schema": True,
+            "is_enabled": True,
+        }
+    ]
+    if parse_version(OPENAI_AGENTS_VERSION) >= (0, 3, 3):
+        available_tools[0].update(
+            {"tool_input_guardrails": None, "tool_output_guardrails": None}
+        )
+
+    available_tools = safe_serialize(available_tools)
+
+    assert transaction["transaction"] == "test_agent workflow"
+    assert transaction["contexts"]["trace"]["origin"] == "auto.ai.openai_agents"
+
+    assert agent_span["description"] == "invoke_agent test_agent"
+    assert agent_span["origin"] == "auto.ai.openai_agents"
+    assert agent_span["data"]["gen_ai.agent.name"] == "test_agent"
+    assert agent_span["data"]["gen_ai.operation.name"] == "invoke_agent"
+    assert agent_span["data"]["gen_ai.request.available_tools"] == available_tools
+    assert agent_span["data"]["gen_ai.request.max_tokens"] == 100
+    assert agent_span["data"]["gen_ai.request.model"] == "gpt-4"
+    assert agent_span["data"]["gen_ai.request.temperature"] == 0.7
+    assert agent_span["data"]["gen_ai.request.top_p"] == 1.0
+    assert agent_span["data"]["gen_ai.system"] == "openai"
+
+    assert ai_client_span1["description"] == "chat gpt-4"
+    assert ai_client_span1["data"]["gen_ai.operation.name"] == "chat"
+    assert ai_client_span1["data"]["gen_ai.system"] == "openai"
+    assert ai_client_span1["data"]["gen_ai.agent.name"] == "test_agent"
+    assert ai_client_span1["data"]["gen_ai.request.available_tools"] == available_tools
+    assert ai_client_span1["data"]["gen_ai.request.max_tokens"] == 100
+    assert ai_client_span1["data"]["gen_ai.request.messages"] == safe_serialize(
+        [
+            {
+                "role": "system",
+                "content": [
+                    {"type": "text", "text": "You are a helpful test assistant."}
+                ],
+            },
+            {
+                "role": "user",
+                "content": [
+                    {"type": "text", "text": "Please use the simple test tool"}
+                ],
+            },
+        ]
+    )
+    assert ai_client_span1["data"]["gen_ai.request.model"] == "gpt-4"
+    assert ai_client_span1["data"]["gen_ai.request.temperature"] == 0.7
+    assert ai_client_span1["data"]["gen_ai.request.top_p"] == 1.0
+    assert ai_client_span1["data"]["gen_ai.usage.input_tokens"] == 10
+    assert ai_client_span1["data"]["gen_ai.usage.input_tokens.cached"] == 0
+    assert ai_client_span1["data"]["gen_ai.usage.output_tokens"] == 5
+    assert ai_client_span1["data"]["gen_ai.usage.output_tokens.reasoning"] == 0
+    assert ai_client_span1["data"]["gen_ai.usage.total_tokens"] == 15
+    assert ai_client_span1["data"]["gen_ai.response.tool_calls"] == safe_serialize(
+        [
+            {
+                "arguments": '{"message": "hello"}',
+                "call_id": "call_123",
+                "name": "simple_test_tool",
+                "type": "function_call",
+                "id": "call_123",
+                "status": None,
+            }
+        ]
+    )
+
+    assert tool_span["description"] == "execute_tool simple_test_tool"
+    assert tool_span["data"]["gen_ai.agent.name"] == "test_agent"
+    assert tool_span["data"]["gen_ai.operation.name"] == "execute_tool"
+    assert (
+        re.sub(
+            "<.*>(,)",
+            r"'NOT_CHECKED'\1",
+            agent_span["data"]["gen_ai.request.available_tools"],
+        )
+        == available_tools
+    )
+    assert tool_span["data"]["gen_ai.request.max_tokens"] == 100
+    assert tool_span["data"]["gen_ai.request.model"] == "gpt-4"
+    assert tool_span["data"]["gen_ai.request.temperature"] == 0.7
+    assert tool_span["data"]["gen_ai.request.top_p"] == 1.0
+    assert tool_span["data"]["gen_ai.system"] == "openai"
+    assert tool_span["data"]["gen_ai.tool.description"] == "A simple tool"
+    assert tool_span["data"]["gen_ai.tool.input"] == '{"message": "hello"}'
+    assert tool_span["data"]["gen_ai.tool.name"] == "simple_test_tool"
+    assert tool_span["data"]["gen_ai.tool.output"] == "Tool executed with: hello"
+    assert tool_span["data"]["gen_ai.tool.type"] == "function"
+
+    assert ai_client_span2["description"] == "chat gpt-4"
+    assert ai_client_span2["data"]["gen_ai.agent.name"] == "test_agent"
+    assert ai_client_span2["data"]["gen_ai.operation.name"] == "chat"
+    assert (
+        re.sub(
+            "<.*>(,)",
+            r"'NOT_CHECKED'\1",
+            agent_span["data"]["gen_ai.request.available_tools"],
+        )
+        == available_tools
+    )
+    assert ai_client_span2["data"]["gen_ai.request.max_tokens"] == 100
+    assert ai_client_span2["data"]["gen_ai.request.messages"] == safe_serialize(
+        [
+            {
+                "role": "system",
+                "content": [
+                    {"type": "text", "text": "You are a helpful test assistant."}
+                ],
+            },
+            {
+                "role": "user",
+                "content": [
+                    {"type": "text", "text": "Please use the simple test tool"}
+                ],
+            },
+            {
+                "role": "assistant",
+                "content": [
+                    {
+                        "arguments": '{"message": "hello"}',
+                        "call_id": "call_123",
+                        "name": "simple_test_tool",
+                        "type": "function_call",
+                        "id": "call_123",
+                    }
+                ],
+            },
+            {
+                "role": "tool",
+                "content": [
+                    {
+                        "call_id": "call_123",
+                        "output": "Tool executed with: hello",
+                        "type": "function_call_output",
+                    }
+                ],
+            },
+        ]
+    )
+    assert ai_client_span2["data"]["gen_ai.request.model"] == "gpt-4"
+    assert ai_client_span2["data"]["gen_ai.request.temperature"] == 0.7
+    assert ai_client_span2["data"]["gen_ai.request.top_p"] == 1.0
+    assert (
+        ai_client_span2["data"]["gen_ai.response.text"]
+        == "Task completed using the tool"
+    )
+    assert ai_client_span2["data"]["gen_ai.system"] == "openai"
+    assert ai_client_span2["data"]["gen_ai.usage.input_tokens.cached"] == 0
+    assert ai_client_span2["data"]["gen_ai.usage.input_tokens"] == 15
+    assert ai_client_span2["data"]["gen_ai.usage.output_tokens.reasoning"] == 0
+    assert ai_client_span2["data"]["gen_ai.usage.output_tokens"] == 10
+    assert ai_client_span2["data"]["gen_ai.usage.total_tokens"] == 25
+
+
+@pytest.mark.asyncio
+async def test_model_behavior_error(sentry_init, capture_events, test_agent):
+    """
+    Example raising agents.exceptions.AgentsException before the agent invocation span is complete.
+    The mocked API response indicates that "wrong_tool" was called.
+    """
+
+    @agents.function_tool
+    def simple_test_tool(message: str) -> str:
+        """A simple tool"""
+        return f"Tool executed with: {message}"
+
+    # Create agent with the tool
+    agent_with_tool = test_agent.clone(tools=[simple_test_tool])
+
+    with patch.dict(os.environ, {"OPENAI_API_KEY": "test-key"}):
+        with patch(
+            "agents.models.openai_responses.OpenAIResponsesModel.get_response"
+        ) as mock_get_response:
+            # Create a mock response that includes tool calls
+            tool_call = ResponseFunctionToolCall(
+                id="call_123",
+                call_id="call_123",
+                name="wrong_tool",
+                type="function_call",
+                arguments='{"message": "hello"}',
+            )
+
+            tool_response = ModelResponse(
+                output=[tool_call],
+                usage=Usage(
+                    requests=1, input_tokens=10, output_tokens=5, total_tokens=15
+                ),
+                response_id="resp_tool_123",
+            )
+
+            mock_get_response.side_effect = [tool_response]
+
+            sentry_init(
+                integrations=[OpenAIAgentsIntegration()],
+                traces_sample_rate=1.0,
+                send_default_pii=True,
+            )
+
+            events = capture_events()
+
+            with pytest.raises(ModelBehaviorError):
+                await agents.Runner.run(
+                    agent_with_tool,
+                    "Please use the simple test tool",
+                    run_config=test_run_config,
+                )
+
+    (error, transaction) = events
+    spans = transaction["spans"]
+    (
+        agent_span,
+        ai_client_span1,
+    ) = spans
+
+    assert transaction["transaction"] == "test_agent workflow"
+    assert transaction["contexts"]["trace"]["origin"] == "auto.ai.openai_agents"
+
+    assert agent_span["description"] == "invoke_agent test_agent"
+    assert agent_span["origin"] == "auto.ai.openai_agents"
+
+    # Error due to unrecognized tool in model response.
+    assert agent_span["status"] == "internal_error"
+    assert agent_span["tags"]["status"] == "internal_error"
+
+
+@pytest.mark.asyncio
+async def test_error_handling(sentry_init, capture_events, test_agent):
+    """
+    Test error handling in agent execution.
+    """
+
+    with patch.dict(os.environ, {"OPENAI_API_KEY": "test-key"}):
+        with patch(
+            "agents.models.openai_responses.OpenAIResponsesModel.get_response"
+        ) as mock_get_response:
+            mock_get_response.side_effect = Exception("Model Error")
+
+            sentry_init(
+                integrations=[OpenAIAgentsIntegration()],
+                traces_sample_rate=1.0,
+            )
+
+            events = capture_events()
+
+            with pytest.raises(Exception, match="Model Error"):
+                await agents.Runner.run(
+                    test_agent, "Test input", run_config=test_run_config
+                )
+
+    (
+        error_event,
+        transaction,
+    ) = events
+
+    assert error_event["exception"]["values"][0]["type"] == "Exception"
+    assert error_event["exception"]["values"][0]["value"] == "Model Error"
+    assert error_event["exception"]["values"][0]["mechanism"]["type"] == "openai_agents"
+
+    spans = transaction["spans"]
+    (invoke_agent_span, ai_client_span) = spans
+
+    assert transaction["transaction"] == "test_agent workflow"
+    assert transaction["contexts"]["trace"]["origin"] == "auto.ai.openai_agents"
+
+    assert invoke_agent_span["description"] == "invoke_agent test_agent"
+    assert invoke_agent_span["origin"] == "auto.ai.openai_agents"
+
+    assert ai_client_span["description"] == "chat gpt-4"
+    assert ai_client_span["origin"] == "auto.ai.openai_agents"
+    assert ai_client_span["status"] == "internal_error"
+    assert ai_client_span["tags"]["status"] == "internal_error"
+
+
+@pytest.mark.asyncio
+async def test_error_captures_input_data(sentry_init, capture_events, test_agent):
+    """
+    Test that input data is captured even when the API call raises an exception.
+    This verifies that _set_input_data is called before the API call.
+    """
+    with patch.dict(os.environ, {"OPENAI_API_KEY": "test-key"}):
+        with patch(
+            "agents.models.openai_responses.OpenAIResponsesModel.get_response"
+        ) as mock_get_response:
+            mock_get_response.side_effect = Exception("API Error")
+
+            sentry_init(
+                integrations=[OpenAIAgentsIntegration()],
+                traces_sample_rate=1.0,
+                send_default_pii=True,
+            )
+
+            events = capture_events()
+
+            with pytest.raises(Exception, match="API Error"):
+                await agents.Runner.run(
+                    test_agent, "Test input", run_config=test_run_config
+                )
+
+    (
+        error_event,
+        transaction,
+    ) = events
+
+    assert error_event["exception"]["values"][0]["type"] == "Exception"
+    assert error_event["exception"]["values"][0]["value"] == "API Error"
+
+    spans = transaction["spans"]
+    ai_client_span = [s for s in spans if s["op"] == "gen_ai.chat"][0]
+
+    assert ai_client_span["description"] == "chat gpt-4"
+    assert ai_client_span["status"] == "internal_error"
+    assert ai_client_span["tags"]["status"] == "internal_error"
+
+    assert "gen_ai.request.messages" in ai_client_span["data"]
+    request_messages = safe_serialize(
+        [
+            {
+                "role": "system",
+                "content": [
+                    {"type": "text", "text": "You are a helpful test assistant."}
+                ],
+            },
+            {"role": "user", "content": [{"type": "text", "text": "Test input"}]},
+        ]
+    )
+    assert ai_client_span["data"]["gen_ai.request.messages"] == request_messages
+
+
+@pytest.mark.asyncio
+async def test_span_status_error(sentry_init, capture_events, test_agent):
+    with patch.dict(os.environ, {"OPENAI_API_KEY": "test-key"}):
+        with patch(
+            "agents.models.openai_responses.OpenAIResponsesModel.get_response"
+        ) as mock_get_response:
+            mock_get_response.side_effect = ValueError("Model Error")
+
+            sentry_init(
+                integrations=[OpenAIAgentsIntegration()],
+                traces_sample_rate=1.0,
+            )
+
+            events = capture_events()
+
+            with pytest.raises(ValueError, match="Model Error"):
+                await agents.Runner.run(
+                    test_agent, "Test input", run_config=test_run_config
+                )
+
+    (error, transaction) = events
+    assert error["level"] == "error"
+    assert transaction["spans"][0]["status"] == "internal_error"
+    assert transaction["spans"][0]["tags"]["status"] == "internal_error"
+    assert transaction["contexts"]["trace"]["status"] == "internal_error"
+
+
+@pytest.mark.asyncio
+async def test_mcp_tool_execution_spans(sentry_init, capture_events, test_agent):
+    """
+    Test that MCP (Model Context Protocol) tool calls create execute_tool spans.
+    """
+
+    with patch.dict(os.environ, {"OPENAI_API_KEY": "test-key"}):
+        with patch(
+            "agents.models.openai_responses.OpenAIResponsesModel.get_response"
+        ) as mock_get_response:
+            # Create a McpCall object
+            mcp_call = McpCall(
+                id="mcp_call_123",
+                name="test_mcp_tool",
+                arguments='{"query": "search term"}',
+                output="MCP tool executed successfully",
+                error=None,
+                type="mcp_call",
+                server_label="test_server",
+            )
+
+            # Create a ModelResponse with an McpCall in the output
+            mcp_response = ModelResponse(
+                output=[mcp_call],
+                usage=Usage(
+                    requests=1,
+                    input_tokens=10,
+                    output_tokens=5,
+                    total_tokens=15,
+                ),
+                response_id="resp_mcp_123",
+            )
+
+            # Final response after MCP tool execution
+            final_response = ModelResponse(
+                output=[
+                    ResponseOutputMessage(
+                        id="msg_final",
+                        type="message",
+                        status="completed",
+                        content=[
+                            ResponseOutputText(
+                                text="Task completed using MCP tool",
+                                type="output_text",
+                                annotations=[],
+                            )
+                        ],
+                        role="assistant",
+                    )
+                ],
+                usage=Usage(
+                    requests=1,
+                    input_tokens=15,
+                    output_tokens=10,
+                    total_tokens=25,
+                ),
+                response_id="resp_final_123",
+            )
+
+            mock_get_response.side_effect = [mcp_response, final_response]
+
+            sentry_init(
+                integrations=[OpenAIAgentsIntegration()],
+                traces_sample_rate=1.0,
+                send_default_pii=True,
+            )
+
+            events = capture_events()
+
+            await agents.Runner.run(
+                test_agent,
+                "Please use MCP tool",
+                run_config=test_run_config,
+            )
+
+    (transaction,) = events
+    spans = transaction["spans"]
+
+    # Find the MCP execute_tool span
+    mcp_tool_span = None
+    for span in spans:
+        if (
+            span.get("description") == "execute_tool test_mcp_tool"
+            and span.get("data", {}).get("gen_ai.tool.type") == "mcp"
+        ):
+            mcp_tool_span = span
+            break
+
+    # Verify the MCP tool span was created
+    assert mcp_tool_span is not None, "MCP execute_tool span was not created"
+    assert mcp_tool_span["description"] == "execute_tool test_mcp_tool"
+    assert mcp_tool_span["data"]["gen_ai.tool.type"] == "mcp"
+    assert mcp_tool_span["data"]["gen_ai.tool.name"] == "test_mcp_tool"
+    assert mcp_tool_span["data"]["gen_ai.tool.input"] == '{"query": "search term"}'
+    assert (
+        mcp_tool_span["data"]["gen_ai.tool.output"] == "MCP tool executed successfully"
+    )
+
+    # Verify no error status since error was None
+    assert mcp_tool_span.get("status") != "internal_error"
+    assert mcp_tool_span.get("tags", {}).get("status") != "internal_error"
+
+
+@pytest.mark.asyncio
+async def test_mcp_tool_execution_with_error(sentry_init, capture_events, test_agent):
+    """
+    Test that MCP tool calls with errors are tracked with error status.
+    """
+
+    with patch.dict(os.environ, {"OPENAI_API_KEY": "test-key"}):
+        with patch(
+            "agents.models.openai_responses.OpenAIResponsesModel.get_response"
+        ) as mock_get_response:
+            # Create a McpCall object with an error
+            mcp_call_with_error = McpCall(
+                id="mcp_call_error_123",
+                name="failing_mcp_tool",
+                arguments='{"query": "test"}',
+                output=None,
+                error="MCP tool execution failed",
+                type="mcp_call",
+                server_label="test_server",
+            )
+
+            # Create a ModelResponse with a failing McpCall
+            mcp_response = ModelResponse(
+                output=[mcp_call_with_error],
+                usage=Usage(
+                    requests=1,
+                    input_tokens=10,
+                    output_tokens=5,
+                    total_tokens=15,
+                ),
+                response_id="resp_mcp_error_123",
+            )
+
+            # Final response after error
+            final_response = ModelResponse(
+                output=[
+                    ResponseOutputMessage(
+                        id="msg_final",
+                        type="message",
+                        status="completed",
+                        content=[
+                            ResponseOutputText(
+                                text="The MCP tool encountered an error",
+                                type="output_text",
+                                annotations=[],
+                            )
+                        ],
+                        role="assistant",
+                    )
+                ],
+                usage=Usage(
+                    requests=1,
+                    input_tokens=15,
+                    output_tokens=10,
+                    total_tokens=25,
+                ),
+                response_id="resp_final_error_123",
+            )
+
+            mock_get_response.side_effect = [mcp_response, final_response]
+
+            sentry_init(
+                integrations=[OpenAIAgentsIntegration()],
+                traces_sample_rate=1.0,
+                send_default_pii=True,
+            )
+
+            events = capture_events()
+
+            await agents.Runner.run(
+                test_agent,
+                "Please use failing MCP tool",
+                run_config=test_run_config,
+            )
+
+    (transaction,) = events
+    spans = transaction["spans"]
+
+    # Find the MCP execute_tool span with error
+    mcp_tool_span = None
+    for span in spans:
+        if (
+            span.get("description") == "execute_tool failing_mcp_tool"
+            and span.get("data", {}).get("gen_ai.tool.type") == "mcp"
+        ):
+            mcp_tool_span = span
+            break
+
+    # Verify the MCP tool span was created with error status
+    assert mcp_tool_span is not None, "MCP execute_tool span was not created"
+    assert mcp_tool_span["description"] == "execute_tool failing_mcp_tool"
+    assert mcp_tool_span["data"]["gen_ai.tool.type"] == "mcp"
+    assert mcp_tool_span["data"]["gen_ai.tool.name"] == "failing_mcp_tool"
+    assert mcp_tool_span["data"]["gen_ai.tool.input"] == '{"query": "test"}'
+    assert mcp_tool_span["data"]["gen_ai.tool.output"] is None
+
+    # Verify error status was set
+    assert mcp_tool_span["status"] == "internal_error"
+    assert mcp_tool_span["tags"]["status"] == "internal_error"
+
+
+@pytest.mark.asyncio
+async def test_mcp_tool_execution_without_pii(sentry_init, capture_events, test_agent):
+    """
+    Test that MCP tool input/output are not included when send_default_pii is False.
+    """
+
+    with patch.dict(os.environ, {"OPENAI_API_KEY": "test-key"}):
+        with patch(
+            "agents.models.openai_responses.OpenAIResponsesModel.get_response"
+        ) as mock_get_response:
+            # Create a McpCall object
+            mcp_call = McpCall(
+                id="mcp_call_pii_123",
+                name="test_mcp_tool",
+                arguments='{"query": "sensitive data"}',
+                output="Result with sensitive info",
+                error=None,
+                type="mcp_call",
+                server_label="test_server",
+            )
+
+            # Create a ModelResponse with an McpCall
+            mcp_response = ModelResponse(
+                output=[mcp_call],
+                usage=Usage(
+                    requests=1,
+                    input_tokens=10,
+                    output_tokens=5,
+                    total_tokens=15,
+                ),
+                response_id="resp_mcp_123",
+            )
+
+            # Final response
+            final_response = ModelResponse(
+                output=[
+                    ResponseOutputMessage(
+                        id="msg_final",
+                        type="message",
+                        status="completed",
+                        content=[
+                            ResponseOutputText(
+                                text="Task completed",
+                                type="output_text",
+                                annotations=[],
+                            )
+                        ],
+                        role="assistant",
+                    )
+                ],
+                usage=Usage(
+                    requests=1,
+                    input_tokens=15,
+                    output_tokens=10,
+                    total_tokens=25,
+                ),
+                response_id="resp_final_123",
+            )
+
+            mock_get_response.side_effect = [mcp_response, final_response]
+
+            sentry_init(
+                integrations=[OpenAIAgentsIntegration()],
+                traces_sample_rate=1.0,
+                send_default_pii=False,  # PII disabled
+            )
+
+            events = capture_events()
+
+            await agents.Runner.run(
+                test_agent,
+                "Please use MCP tool",
+                run_config=test_run_config,
+            )
+
+    (transaction,) = events
+    spans = transaction["spans"]
+
+    # Find the MCP execute_tool span
+    mcp_tool_span = None
+    for span in spans:
+        if (
+            span.get("description") == "execute_tool test_mcp_tool"
+            and span.get("data", {}).get("gen_ai.tool.type") == "mcp"
+        ):
+            mcp_tool_span = span
+            break
+
+    # Verify the MCP tool span was created but without input/output
+    assert mcp_tool_span is not None, "MCP execute_tool span was not created"
+    assert mcp_tool_span["description"] == "execute_tool test_mcp_tool"
+    assert mcp_tool_span["data"]["gen_ai.tool.type"] == "mcp"
+    assert mcp_tool_span["data"]["gen_ai.tool.name"] == "test_mcp_tool"
+
+    # Verify input and output are not included when send_default_pii is False
+    assert "gen_ai.tool.input" not in mcp_tool_span["data"]
+    assert "gen_ai.tool.output" not in mcp_tool_span["data"]
+
+
+@pytest.mark.asyncio
+async def test_multiple_agents_asyncio(
+    sentry_init, capture_events, test_agent, mock_model_response
+):
+    """
+    Test that multiple agents can be run at the same time in asyncio tasks
+    without interfering with each other.
+    """
+
+    with patch.dict(os.environ, {"OPENAI_API_KEY": "test-key"}):
+        with patch(
+            "agents.models.openai_responses.OpenAIResponsesModel.get_response"
+        ) as mock_get_response:
+            mock_get_response.return_value = mock_model_response
+
+            sentry_init(
+                integrations=[OpenAIAgentsIntegration()],
+                traces_sample_rate=1.0,
+            )
+
+            events = capture_events()
+
+            async def run():
+                await agents.Runner.run(
+                    starting_agent=test_agent,
+                    input="Test input",
+                    run_config=test_run_config,
+                )
+
+            await asyncio.gather(*[run() for _ in range(3)])
+
+    assert len(events) == 3
+    txn1, txn2, txn3 = events
+
+    assert txn1["type"] == "transaction"
+    assert txn1["transaction"] == "test_agent workflow"
+    assert txn2["type"] == "transaction"
+    assert txn2["transaction"] == "test_agent workflow"
+    assert txn3["type"] == "transaction"
+    assert txn3["transaction"] == "test_agent workflow"
+
+
+def test_openai_agents_message_role_mapping(sentry_init, capture_events):
+    """Test that OpenAI Agents integration properly maps message roles like 'ai' to 'assistant'"""
+    sentry_init(
+        integrations=[OpenAIAgentsIntegration()],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+
+    # Test input messages with mixed roles including "ai"
+    test_input = [
+        {"role": "system", "content": "You are helpful."},
+        {"role": "user", "content": "Hello"},
+        {"role": "ai", "content": "Hi there!"},  # Should be mapped to "assistant"
+        {"role": "assistant", "content": "How can I help?"},  # Should stay "assistant"
+    ]
+
+    get_response_kwargs = {"input": test_input}
+
+    from sentry_sdk.integrations.openai_agents.utils import _set_input_data
+    from sentry_sdk import start_span
+
+    with start_span(op="test") as span:
+        _set_input_data(span, get_response_kwargs)
+
+    # Verify that messages were processed and roles were mapped
+    from sentry_sdk.consts import SPANDATA
+
+    if SPANDATA.GEN_AI_REQUEST_MESSAGES in span._data:
+        import json
+
+        stored_messages = json.loads(span._data[SPANDATA.GEN_AI_REQUEST_MESSAGES])
+
+        # Verify roles were properly mapped
+        found_assistant_roles = 0
+        for message in stored_messages:
+            if message["role"] == "assistant":
+                found_assistant_roles += 1
+
+        # Should have 2 assistant roles (1 from original "assistant", 1 from mapped "ai")
+        assert found_assistant_roles == 2
+
+        # Verify no "ai" roles remain in any message
+        for message in stored_messages:
+            assert message["role"] != "ai"
+
+
+@pytest.mark.asyncio
+async def test_tool_execution_error_tracing(sentry_init, capture_events, test_agent):
+    """
+    Test that tool execution errors are properly tracked via error tracing patch.
+
+    This tests the patch of agents error tracing function to ensure execute_tool
+    spans are set to error status when tool execution fails.
+
+    The function location varies by version:
+    - Newer versions: agents.util._error_tracing.attach_error_to_current_span
+    - Older versions: agents._utils.attach_error_to_current_span
+    """
+
+    @agents.function_tool
+    def failing_tool(message: str) -> str:
+        """A tool that fails"""
+        raise ValueError("Tool execution failed")
+
+    # Create agent with the failing tool
+    agent_with_tool = test_agent.clone(tools=[failing_tool])
+
+    with patch.dict(os.environ, {"OPENAI_API_KEY": "test-key"}):
+        with patch(
+            "agents.models.openai_responses.OpenAIResponsesModel.get_response"
+        ) as mock_get_response:
+            # Create a mock response that includes tool call
+            tool_call = ResponseFunctionToolCall(
+                id="call_123",
+                call_id="call_123",
+                name="failing_tool",
+                type="function_call",
+                arguments='{"message": "test"}',
+            )
+
+            # First response with tool call
+            tool_response = ModelResponse(
+                output=[tool_call],
+                usage=Usage(
+                    requests=1, input_tokens=10, output_tokens=5, total_tokens=15
+                ),
+                response_id="resp_tool_123",
+            )
+
+            # Second response after tool error (agents library handles the error and continues)
+            final_response = ModelResponse(
+                output=[
+                    ResponseOutputMessage(
+                        id="msg_final",
+                        type="message",
+                        status="completed",
+                        content=[
+                            ResponseOutputText(
+                                text="An error occurred while running the tool",
+                                type="output_text",
+                                annotations=[],
+                            )
+                        ],
+                        role="assistant",
+                    )
+                ],
+                usage=Usage(
+                    requests=1, input_tokens=15, output_tokens=10, total_tokens=25
+                ),
+                response_id="resp_final_123",
+            )
+
+            mock_get_response.side_effect = [tool_response, final_response]
+
+            sentry_init(
+                integrations=[OpenAIAgentsIntegration()],
+                traces_sample_rate=1.0,
+                send_default_pii=True,
+            )
+
+            events = capture_events()
+
+            # Note: The agents library catches tool exceptions internally,
+            # so we don't expect this to raise
+            await agents.Runner.run(
+                agent_with_tool,
+                "Please use the failing tool",
+                run_config=test_run_config,
+            )
+
+    (transaction,) = events
+    spans = transaction["spans"]
+
+    # Find the execute_tool span
+    execute_tool_span = None
+    for span in spans:
+        if span.get("description", "").startswith("execute_tool failing_tool"):
+            execute_tool_span = span
+            break
+
+    # Verify the execute_tool span was created
+    assert execute_tool_span is not None, "execute_tool span was not created"
+    assert execute_tool_span["description"] == "execute_tool failing_tool"
+    assert execute_tool_span["data"]["gen_ai.tool.name"] == "failing_tool"
+
+    # Verify error status was set (this is the key test for our patch)
+    # The span should be marked as error because the tool execution failed
+    assert execute_tool_span["status"] == "internal_error"
+    assert execute_tool_span["tags"]["status"] == "internal_error"
+
+
+@pytest.mark.asyncio
+async def test_invoke_agent_span_includes_usage_data(
+    sentry_init, capture_events, test_agent, mock_usage
+):
+    """
+    Test that invoke_agent spans include aggregated usage data from context_wrapper.
+    This verifies the new functionality added to track token usage in invoke_agent spans.
+    """
+
+    with patch.dict(os.environ, {"OPENAI_API_KEY": "test-key"}):
+        with patch(
+            "agents.models.openai_responses.OpenAIResponsesModel.get_response"
+        ) as mock_get_response:
+            # Create a response with usage data
+            response = ModelResponse(
+                output=[
+                    ResponseOutputMessage(
+                        id="msg_123",
+                        type="message",
+                        status="completed",
+                        content=[
+                            ResponseOutputText(
+                                text="Response with usage",
+                                type="output_text",
+                                annotations=[],
+                            )
+                        ],
+                        role="assistant",
+                    )
+                ],
+                usage=mock_usage,
+                response_id="resp_123",
+            )
+            mock_get_response.return_value = response
+
+            sentry_init(
+                integrations=[OpenAIAgentsIntegration()],
+                traces_sample_rate=1.0,
+                send_default_pii=True,
+            )
+
+            events = capture_events()
+
+            result = await agents.Runner.run(
+                test_agent, "Test input", run_config=test_run_config
+            )
+
+            assert result is not None
+
+    (transaction,) = events
+    spans = transaction["spans"]
+    invoke_agent_span, ai_client_span = spans
+
+    # Verify invoke_agent span has usage data from context_wrapper
+    assert invoke_agent_span["description"] == "invoke_agent test_agent"
+    assert "gen_ai.usage.input_tokens" in invoke_agent_span["data"]
+    assert "gen_ai.usage.output_tokens" in invoke_agent_span["data"]
+    assert "gen_ai.usage.total_tokens" in invoke_agent_span["data"]
+
+    # The usage should match the mock_usage values (aggregated across all calls)
+    assert invoke_agent_span["data"]["gen_ai.usage.input_tokens"] == 10
+    assert invoke_agent_span["data"]["gen_ai.usage.output_tokens"] == 20
+    assert invoke_agent_span["data"]["gen_ai.usage.total_tokens"] == 30
+    assert invoke_agent_span["data"]["gen_ai.usage.input_tokens.cached"] == 0
+    assert invoke_agent_span["data"]["gen_ai.usage.output_tokens.reasoning"] == 5
+
+
+@pytest.mark.asyncio
+async def test_ai_client_span_includes_response_model(
+    sentry_init, capture_events, test_agent
+):
+    """
+    Test that ai_client spans (gen_ai.chat) include the response model from the actual API response.
+    This verifies the new functionality to capture the model used in the response.
+    """
+
+    with patch.dict(os.environ, {"OPENAI_API_KEY": "test-key"}):
+        # Mock the _fetch_response method to return a response with a model field
+        with patch(
+            "agents.models.openai_responses.OpenAIResponsesModel._fetch_response"
+        ) as mock_fetch_response:
+            # Create a mock OpenAI Response object with a model field
+            mock_response = MagicMock()
+            mock_response.model = "gpt-4.1-2025-04-14"  # The actual response model
+            mock_response.id = "resp_123"
+            mock_response.output = [
+                ResponseOutputMessage(
+                    id="msg_123",
+                    type="message",
+                    status="completed",
+                    content=[
+                        ResponseOutputText(
+                            text="Hello from GPT-4.1",
+                            type="output_text",
+                            annotations=[],
+                        )
+                    ],
+                    role="assistant",
+                )
+            ]
+            mock_response.usage = MagicMock()
+            mock_response.usage.input_tokens = 10
+            mock_response.usage.output_tokens = 20
+            mock_response.usage.total_tokens = 30
+            mock_response.usage.input_tokens_details = InputTokensDetails(
+                cached_tokens=0
+            )
+            mock_response.usage.output_tokens_details = OutputTokensDetails(
+                reasoning_tokens=5
+            )
+
+            mock_fetch_response.return_value = mock_response
+
+            sentry_init(
+                integrations=[OpenAIAgentsIntegration()],
+                traces_sample_rate=1.0,
+                send_default_pii=True,
+            )
+
+            events = capture_events()
+
+            result = await agents.Runner.run(
+                test_agent, "Test input", run_config=test_run_config
+            )
+
+            assert result is not None
+
+    (transaction,) = events
+    spans = transaction["spans"]
+    _, ai_client_span = spans
+
+    # Verify ai_client span has response model
+    assert ai_client_span["description"] == "chat gpt-4"
+    assert "gen_ai.response.model" in ai_client_span["data"]
+    assert ai_client_span["data"]["gen_ai.response.model"] == "gpt-4.1-2025-04-14"
+
+
+@pytest.mark.asyncio
+async def test_ai_client_span_response_model_with_chat_completions(
+    sentry_init, capture_events
+):
+    """
+    Test that response model is captured when using ChatCompletions API (not Responses API).
+    This ensures our implementation works with different OpenAI model types.
+    """
+    # Create agent that uses ChatCompletions model
+    agent = Agent(
+        name="chat_completions_agent",
+        instructions="Test agent using ChatCompletions",
+        model="gpt-4o-mini",
+    )
+
+    with patch.dict(os.environ, {"OPENAI_API_KEY": "test-key"}):
+        # Mock the get_response method directly since ChatCompletions may use Responses API anyway
+        with patch(
+            "agents.models.openai_responses.OpenAIResponsesModel._fetch_response"
+        ) as mock_fetch_response:
+            # Create a mock Response object with a model field
+            mock_response = MagicMock()
+            mock_response.model = "gpt-4o-mini-2024-07-18"  # Actual response model
+            mock_response.id = "resp_123"
+            mock_response.output = [
+                ResponseOutputMessage(
+                    id="msg_123",
+                    type="message",
+                    status="completed",
+                    content=[
+                        ResponseOutputText(
+                            text="Response from model",
+                            type="output_text",
+                            annotations=[],
+                        )
+                    ],
+                    role="assistant",
+                )
+            ]
+            mock_response.usage = MagicMock()
+            mock_response.usage.input_tokens = 15
+            mock_response.usage.output_tokens = 25
+            mock_response.usage.total_tokens = 40
+            mock_response.usage.input_tokens_details = InputTokensDetails(
+                cached_tokens=0
+            )
+            mock_response.usage.output_tokens_details = OutputTokensDetails(
+                reasoning_tokens=0
+            )
+
+            mock_fetch_response.return_value = mock_response
+
+            sentry_init(
+                integrations=[OpenAIAgentsIntegration()],
+                traces_sample_rate=1.0,
+            )
+
+            events = capture_events()
+
+            result = await agents.Runner.run(
+                agent, "Test input", run_config=test_run_config
+            )
+
+            assert result is not None
+
+    (transaction,) = events
+    spans = transaction["spans"]
+    _, ai_client_span = spans
+
+    # Verify response model from Response is captured
+    assert "gen_ai.response.model" in ai_client_span["data"]
+    assert ai_client_span["data"]["gen_ai.response.model"] == "gpt-4o-mini-2024-07-18"
+
+
+@pytest.mark.asyncio
+async def test_multiple_llm_calls_aggregate_usage(
+    sentry_init, capture_events, test_agent
+):
+    """
+    Test that invoke_agent spans show aggregated usage across multiple LLM calls
+    (e.g., when tools are used and multiple API calls are made).
+    """
+
+    @agents.function_tool
+    def calculator(a: int, b: int) -> int:
+        """Add two numbers"""
+        return a + b
+
+    agent_with_tool = test_agent.clone(tools=[calculator])
+
+    with patch.dict(os.environ, {"OPENAI_API_KEY": "test-key"}):
+        with patch(
+            "agents.models.openai_responses.OpenAIResponsesModel.get_response"
+        ) as mock_get_response:
+            # First call: agent decides to use tool (10 input, 5 output tokens)
+            tool_call_response = ModelResponse(
+                output=[
+                    ResponseFunctionToolCall(
+                        id="call_123",
+                        call_id="call_123",
+                        name="calculator",
+                        type="function_call",
+                        arguments='{"a": 5, "b": 3}',
+                    )
+                ],
+                usage=Usage(
+                    requests=1,
+                    input_tokens=10,
+                    output_tokens=5,
+                    total_tokens=15,
+                    input_tokens_details=InputTokensDetails(cached_tokens=0),
+                    output_tokens_details=OutputTokensDetails(reasoning_tokens=0),
+                ),
+                response_id="resp_tool_call",
+            )
+
+            # Second call: agent uses tool result to respond (20 input, 15 output tokens)
+            final_response = ModelResponse(
+                output=[
+                    ResponseOutputMessage(
+                        id="msg_final",
+                        type="message",
+                        status="completed",
+                        content=[
+                            ResponseOutputText(
+                                text="The result is 8",
+                                type="output_text",
+                                annotations=[],
+                            )
+                        ],
+                        role="assistant",
+                    )
+                ],
+                usage=Usage(
+                    requests=1,
+                    input_tokens=20,
+                    output_tokens=15,
+                    total_tokens=35,
+                    input_tokens_details=InputTokensDetails(cached_tokens=5),
+                    output_tokens_details=OutputTokensDetails(reasoning_tokens=3),
+                ),
+                response_id="resp_final",
+            )
+
+            mock_get_response.side_effect = [tool_call_response, final_response]
+
+            sentry_init(
+                integrations=[OpenAIAgentsIntegration()],
+                traces_sample_rate=1.0,
+                send_default_pii=True,
+            )
+
+            events = capture_events()
+
+            result = await agents.Runner.run(
+                agent_with_tool,
+                "What is 5 + 3?",
+                run_config=test_run_config,
+            )
+
+            assert result is not None
+
+    (transaction,) = events
+    spans = transaction["spans"]
+    invoke_agent_span = spans[0]
+
+    # Verify invoke_agent span has aggregated usage from both API calls
+    # Total: 10 + 20 = 30 input tokens, 5 + 15 = 20 output tokens, 15 + 35 = 50 total
+    assert invoke_agent_span["data"]["gen_ai.usage.input_tokens"] == 30
+    assert invoke_agent_span["data"]["gen_ai.usage.output_tokens"] == 20
+    assert invoke_agent_span["data"]["gen_ai.usage.total_tokens"] == 50
+    # Cached tokens should be aggregated: 0 + 5 = 5
+    assert invoke_agent_span["data"]["gen_ai.usage.input_tokens.cached"] == 5
+    # Reasoning tokens should be aggregated: 0 + 3 = 3
+    assert invoke_agent_span["data"]["gen_ai.usage.output_tokens.reasoning"] == 3
+
+
+@pytest.mark.asyncio
+async def test_response_model_not_set_when_unavailable(
+    sentry_init, capture_events, test_agent
+):
+    """
+    Test that response model is not set if the raw response doesn't have a model field.
+    This can happen with custom model implementations.
+    """
+
+    with patch.dict(os.environ, {"OPENAI_API_KEY": "test-key"}):
+        # Mock without _fetch_response (simulating custom model without this method)
+        with patch(
+            "agents.models.openai_responses.OpenAIResponsesModel.get_response"
+        ) as mock_get_response:
+            response = ModelResponse(
+                output=[
+                    ResponseOutputMessage(
+                        id="msg_123",
+                        type="message",
+                        status="completed",
+                        content=[
+                            ResponseOutputText(
+                                text="Response without model field",
+                                type="output_text",
+                                annotations=[],
+                            )
+                        ],
+                        role="assistant",
+                    )
+                ],
+                usage=Usage(
+                    requests=1,
+                    input_tokens=10,
+                    output_tokens=20,
+                    total_tokens=30,
+                ),
+                response_id="resp_123",
+            )
+            # Don't set _sentry_response_model attribute
+            mock_get_response.return_value = response
+
+            sentry_init(
+                integrations=[OpenAIAgentsIntegration()],
+                traces_sample_rate=1.0,
+            )
+
+            events = capture_events()
+
+            # Remove the _fetch_response method to simulate custom model
+            with patch.object(
+                agents.models.openai_responses.OpenAIResponsesModel,
+                "_fetch_response",
+                None,
+            ):
+                result = await agents.Runner.run(
+                    test_agent, "Test input", run_config=test_run_config
+                )
+
+                assert result is not None
+
+    (transaction,) = events
+    spans = transaction["spans"]
+    _, ai_client_span = spans
+
+    # When response model can't be captured, it shouldn't be in the span data
+    # (we only set it when we can accurately capture it)
+    assert "gen_ai.response.model" not in ai_client_span["data"]
+
+
+@pytest.mark.asyncio
+async def test_invoke_agent_span_includes_response_model(
+    sentry_init, capture_events, test_agent
+):
+    """
+    Test that invoke_agent spans include the response model.
+    When an agent makes multiple LLM calls, it should report the last model used.
+    """
+
+    with patch.dict(os.environ, {"OPENAI_API_KEY": "test-key"}):
+        with patch(
+            "agents.models.openai_responses.OpenAIResponsesModel._fetch_response"
+        ) as mock_fetch_response:
+            # Create a mock OpenAI Response object with a model field
+            mock_response = MagicMock()
+            mock_response.model = "gpt-4.1-2025-04-14"  # The actual response model
+            mock_response.id = "resp_123"
+            mock_response.output = [
+                ResponseOutputMessage(
+                    id="msg_123",
+                    type="message",
+                    status="completed",
+                    content=[
+                        ResponseOutputText(
+                            text="Response from model",
+                            type="output_text",
+                            annotations=[],
+                        )
+                    ],
+                    role="assistant",
+                )
+            ]
+            mock_response.usage = MagicMock()
+            mock_response.usage.input_tokens = 10
+            mock_response.usage.output_tokens = 20
+            mock_response.usage.total_tokens = 30
+            mock_response.usage.input_tokens_details = InputTokensDetails(
+                cached_tokens=0
+            )
+            mock_response.usage.output_tokens_details = OutputTokensDetails(
+                reasoning_tokens=5
+            )
+
+            mock_fetch_response.return_value = mock_response
+
+            sentry_init(
+                integrations=[OpenAIAgentsIntegration()],
+                traces_sample_rate=1.0,
+                send_default_pii=True,
+            )
+
+            events = capture_events()
+
+            result = await agents.Runner.run(
+                test_agent, "Test input", run_config=test_run_config
+            )
+
+            assert result is not None
+
+    (transaction,) = events
+    spans = transaction["spans"]
+    invoke_agent_span, ai_client_span = spans
+
+    # Verify invoke_agent span has response model
+    assert invoke_agent_span["description"] == "invoke_agent test_agent"
+    assert "gen_ai.response.model" in invoke_agent_span["data"]
+    assert invoke_agent_span["data"]["gen_ai.response.model"] == "gpt-4.1-2025-04-14"
+
+    # Also verify ai_client span has it
+    assert "gen_ai.response.model" in ai_client_span["data"]
+    assert ai_client_span["data"]["gen_ai.response.model"] == "gpt-4.1-2025-04-14"
+
+
+@pytest.mark.asyncio
+async def test_invoke_agent_span_uses_last_response_model(
+    sentry_init, capture_events, test_agent
+):
+    """
+    Test that when an agent makes multiple LLM calls (e.g., with tools),
+    the invoke_agent span reports the last response model used.
+    """
+
+    @agents.function_tool
+    def calculator(a: int, b: int) -> int:
+        """Add two numbers"""
+        return a + b
+
+    agent_with_tool = test_agent.clone(tools=[calculator])
+
+    with patch.dict(os.environ, {"OPENAI_API_KEY": "test-key"}):
+        with patch(
+            "agents.models.openai_responses.OpenAIResponsesModel._fetch_response"
+        ) as mock_fetch_response:
+            # First call: gpt-4 model
+            first_response = MagicMock()
+            first_response.model = "gpt-4-0613"
+            first_response.id = "resp_1"
+            first_response.output = [
+                ResponseFunctionToolCall(
+                    id="call_123",
+                    call_id="call_123",
+                    name="calculator",
+                    type="function_call",
+                    arguments='{"a": 5, "b": 3}',
+                )
+            ]
+            first_response.usage = MagicMock()
+            first_response.usage.input_tokens = 10
+            first_response.usage.output_tokens = 5
+            first_response.usage.total_tokens = 15
+            first_response.usage.input_tokens_details = InputTokensDetails(
+                cached_tokens=0
+            )
+            first_response.usage.output_tokens_details = OutputTokensDetails(
+                reasoning_tokens=0
+            )
+
+            # Second call: different model (e.g., after tool execution)
+            second_response = MagicMock()
+            second_response.model = "gpt-4.1-2025-04-14"  # Different model
+            second_response.id = "resp_2"
+            second_response.output = [
+                ResponseOutputMessage(
+                    id="msg_final",
+                    type="message",
+                    status="completed",
+                    content=[
+                        ResponseOutputText(
+                            text="The result is 8",
+                            type="output_text",
+                            annotations=[],
+                        )
+                    ],
+                    role="assistant",
+                )
+            ]
+            second_response.usage = MagicMock()
+            second_response.usage.input_tokens = 20
+            second_response.usage.output_tokens = 15
+            second_response.usage.total_tokens = 35
+            second_response.usage.input_tokens_details = InputTokensDetails(
+                cached_tokens=5
+            )
+            second_response.usage.output_tokens_details = OutputTokensDetails(
+                reasoning_tokens=3
+            )
+
+            mock_fetch_response.side_effect = [first_response, second_response]
+
+            sentry_init(
+                integrations=[OpenAIAgentsIntegration()],
+                traces_sample_rate=1.0,
+                send_default_pii=True,
+            )
+
+            events = capture_events()
+
+            result = await agents.Runner.run(
+                agent_with_tool,
+                "What is 5 + 3?",
+                run_config=test_run_config,
+            )
+
+            assert result is not None
+
+    (transaction,) = events
+    spans = transaction["spans"]
+    invoke_agent_span = spans[0]
+    first_ai_client_span = spans[1]
+    second_ai_client_span = spans[3]  # After tool span
+
+    # Verify invoke_agent span uses the LAST response model
+    assert "gen_ai.response.model" in invoke_agent_span["data"]
+    assert invoke_agent_span["data"]["gen_ai.response.model"] == "gpt-4.1-2025-04-14"
+
+    # Verify each ai_client span has its own response model
+    assert first_ai_client_span["data"]["gen_ai.response.model"] == "gpt-4-0613"
+    assert (
+        second_ai_client_span["data"]["gen_ai.response.model"] == "gpt-4.1-2025-04-14"
+    )
+
+
+def test_openai_agents_message_truncation(sentry_init, capture_events):
+    """Test that large messages are truncated properly in OpenAI Agents integration."""
+
+    large_content = (
+        "This is a very long message that will exceed our size limits. " * 1000
+    )
+
+    sentry_init(
+        integrations=[OpenAIAgentsIntegration()],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+
+    test_messages = [
+        {"role": "system", "content": "small message 1"},
+        {"role": "user", "content": large_content},
+        {"role": "assistant", "content": large_content},
+        {"role": "user", "content": "small message 4"},
+        {"role": "assistant", "content": "small message 5"},
+    ]
+
+    get_response_kwargs = {"input": test_messages}
+
+    with start_span(op="gen_ai.chat") as span:
+        scope = sentry_sdk.get_current_scope()
+        _set_input_data(span, get_response_kwargs)
+        if hasattr(scope, "_gen_ai_original_message_count"):
+            truncated_count = scope._gen_ai_original_message_count.get(span.span_id)
+            assert truncated_count == 5, (
+                f"Expected 5 original messages, got {truncated_count}"
+            )
+
+        assert SPANDATA.GEN_AI_REQUEST_MESSAGES in span._data
+        messages_data = span._data[SPANDATA.GEN_AI_REQUEST_MESSAGES]
+        assert isinstance(messages_data, str)
+
+        parsed_messages = json.loads(messages_data)
+        assert isinstance(parsed_messages, list)
+        assert len(parsed_messages) == 2
+        assert "small message 4" in str(parsed_messages[0])
+        assert "small message 5" in str(parsed_messages[1])
diff --git a/tests/integrations/openfeature/__init__.py b/tests/integrations/openfeature/__init__.py
new file mode 100644
index 0000000000..a17549ea79
--- /dev/null
+++ b/tests/integrations/openfeature/__init__.py
@@ -0,0 +1,3 @@
+import pytest
+
+pytest.importorskip("openfeature")
diff --git a/tests/integrations/openfeature/test_openfeature.py b/tests/integrations/openfeature/test_openfeature.py
new file mode 100644
index 0000000000..46acc61ae7
--- /dev/null
+++ b/tests/integrations/openfeature/test_openfeature.py
@@ -0,0 +1,179 @@
+import concurrent.futures as cf
+import sys
+
+import pytest
+
+from openfeature import api
+from openfeature.provider.in_memory_provider import InMemoryFlag, InMemoryProvider
+
+import sentry_sdk
+from sentry_sdk import start_span, start_transaction
+from sentry_sdk.integrations.openfeature import OpenFeatureIntegration
+from tests.conftest import ApproxDict
+
+
+def test_openfeature_integration(sentry_init, capture_events, uninstall_integration):
+    uninstall_integration(OpenFeatureIntegration.identifier)
+    sentry_init(integrations=[OpenFeatureIntegration()])
+
+    flags = {
+        "hello": InMemoryFlag("on", {"on": True, "off": False}),
+        "world": InMemoryFlag("off", {"on": True, "off": False}),
+    }
+    api.set_provider(InMemoryProvider(flags))
+
+    client = api.get_client()
+    client.get_boolean_value("hello", default_value=False)
+    client.get_boolean_value("world", default_value=False)
+    client.get_boolean_value("other", default_value=True)
+
+    events = capture_events()
+    sentry_sdk.capture_exception(Exception("something wrong!"))
+
+    assert len(events) == 1
+    assert events[0]["contexts"]["flags"] == {
+        "values": [
+            {"flag": "hello", "result": True},
+            {"flag": "world", "result": False},
+            {"flag": "other", "result": True},
+        ]
+    }
+
+
+def test_openfeature_integration_threaded(
+    sentry_init, capture_events, uninstall_integration
+):
+    uninstall_integration(OpenFeatureIntegration.identifier)
+    sentry_init(integrations=[OpenFeatureIntegration()])
+    events = capture_events()
+
+    flags = {
+        "hello": InMemoryFlag("on", {"on": True, "off": False}),
+        "world": InMemoryFlag("off", {"on": True, "off": False}),
+    }
+    api.set_provider(InMemoryProvider(flags))
+
+    # Capture an eval before we split isolation scopes.
+    client = api.get_client()
+    client.get_boolean_value("hello", default_value=False)
+
+    def task(flag):
+        # Create a new isolation scope for the thread. This means the flags
+        with sentry_sdk.isolation_scope():
+            client.get_boolean_value(flag, default_value=False)
+            # use a tag to identify to identify events later on
+            sentry_sdk.set_tag("task_id", flag)
+            sentry_sdk.capture_exception(Exception("something wrong!"))
+
+    # Run tasks in separate threads
+    with cf.ThreadPoolExecutor(max_workers=2) as pool:
+        pool.map(task, ["world", "other"])
+
+    # Capture error in original scope
+    sentry_sdk.set_tag("task_id", "0")
+    sentry_sdk.capture_exception(Exception("something wrong!"))
+
+    assert len(events) == 3
+    events.sort(key=lambda e: e["tags"]["task_id"])
+
+    assert events[0]["contexts"]["flags"] == {
+        "values": [
+            {"flag": "hello", "result": True},
+        ]
+    }
+    assert events[1]["contexts"]["flags"] == {
+        "values": [
+            {"flag": "hello", "result": True},
+            {"flag": "other", "result": False},
+        ]
+    }
+    assert events[2]["contexts"]["flags"] == {
+        "values": [
+            {"flag": "hello", "result": True},
+            {"flag": "world", "result": False},
+        ]
+    }
+
+
+@pytest.mark.skipif(sys.version_info < (3, 7), reason="requires python3.7 or higher")
+def test_openfeature_integration_asyncio(
+    sentry_init, capture_events, uninstall_integration
+):
+    """Assert concurrently evaluated flags do not pollute one another."""
+
+    asyncio = pytest.importorskip("asyncio")
+
+    uninstall_integration(OpenFeatureIntegration.identifier)
+    sentry_init(integrations=[OpenFeatureIntegration()])
+    events = capture_events()
+
+    async def task(flag):
+        with sentry_sdk.isolation_scope():
+            client.get_boolean_value(flag, default_value=False)
+            # use a tag to identify to identify events later on
+            sentry_sdk.set_tag("task_id", flag)
+            sentry_sdk.capture_exception(Exception("something wrong!"))
+
+    async def runner():
+        return asyncio.gather(task("world"), task("other"))
+
+    flags = {
+        "hello": InMemoryFlag("on", {"on": True, "off": False}),
+        "world": InMemoryFlag("off", {"on": True, "off": False}),
+    }
+    api.set_provider(InMemoryProvider(flags))
+
+    # Capture an eval before we split isolation scopes.
+    client = api.get_client()
+    client.get_boolean_value("hello", default_value=False)
+
+    asyncio.run(runner())
+
+    # Capture error in original scope
+    sentry_sdk.set_tag("task_id", "0")
+    sentry_sdk.capture_exception(Exception("something wrong!"))
+
+    assert len(events) == 3
+    events.sort(key=lambda e: e["tags"]["task_id"])
+
+    assert events[0]["contexts"]["flags"] == {
+        "values": [
+            {"flag": "hello", "result": True},
+        ]
+    }
+    assert events[1]["contexts"]["flags"] == {
+        "values": [
+            {"flag": "hello", "result": True},
+            {"flag": "other", "result": False},
+        ]
+    }
+    assert events[2]["contexts"]["flags"] == {
+        "values": [
+            {"flag": "hello", "result": True},
+            {"flag": "world", "result": False},
+        ]
+    }
+
+
+def test_openfeature_span_integration(
+    sentry_init, capture_events, uninstall_integration
+):
+    uninstall_integration(OpenFeatureIntegration.identifier)
+    sentry_init(traces_sample_rate=1.0, integrations=[OpenFeatureIntegration()])
+
+    api.set_provider(
+        InMemoryProvider({"hello": InMemoryFlag("on", {"on": True, "off": False})})
+    )
+    client = api.get_client()
+
+    events = capture_events()
+
+    with start_transaction(name="hi"):
+        with start_span(op="foo", name="bar"):
+            client.get_boolean_value("hello", default_value=False)
+            client.get_boolean_value("world", default_value=False)
+
+    (event,) = events
+    assert event["spans"][0]["data"] == ApproxDict(
+        {"flag.evaluation.hello": True, "flag.evaluation.world": False}
+    )
diff --git a/tests/integrations/opentelemetry/test_entry_points.py b/tests/integrations/opentelemetry/test_entry_points.py
new file mode 100644
index 0000000000..cd78209432
--- /dev/null
+++ b/tests/integrations/opentelemetry/test_entry_points.py
@@ -0,0 +1,17 @@
+import importlib
+import os
+from unittest.mock import patch
+
+from opentelemetry import propagate
+from sentry_sdk.integrations.opentelemetry import SentryPropagator
+
+
+def test_propagator_loaded_if_mentioned_in_environment_variable():
+    try:
+        with patch.dict(os.environ, {"OTEL_PROPAGATORS": "sentry"}):
+            importlib.reload(propagate)
+
+            assert len(propagate.propagators) == 1
+            assert isinstance(propagate.propagators[0], SentryPropagator)
+    finally:
+        importlib.reload(propagate)
diff --git a/tests/integrations/opentelemetry/test_experimental.py b/tests/integrations/opentelemetry/test_experimental.py
index 77286330a5..8e4b703361 100644
--- a/tests/integrations/opentelemetry/test_experimental.py
+++ b/tests/integrations/opentelemetry/test_experimental.py
@@ -1,34 +1,47 @@
-try:
-    # python 3.3 and above
-    from unittest.mock import MagicMock
-except ImportError:
-    # python < 3.3
-    from mock import MagicMock
-
-from sentry_sdk.integrations.opentelemetry.integration import OpenTelemetryIntegration
-
-
-def test_integration_enabled_if_option_is_on(sentry_init):
-    OpenTelemetryIntegration.setup_once = MagicMock()
-    sentry_init(
-        _experiments={
-            "otel_powered_performance": True,
-        }
-    )
-    OpenTelemetryIntegration.setup_once.assert_called_once()
-
-
-def test_integration_not_enabled_if_option_is_off(sentry_init):
-    OpenTelemetryIntegration.setup_once = MagicMock()
-    sentry_init(
-        _experiments={
-            "otel_powered_performance": False,
-        }
-    )
-    OpenTelemetryIntegration.setup_once.assert_not_called()
-
-
-def test_integration_not_enabled_if_option_is_missing(sentry_init):
-    OpenTelemetryIntegration.setup_once = MagicMock()
-    sentry_init()
-    OpenTelemetryIntegration.setup_once.assert_not_called()
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+
+@pytest.mark.forked
+def test_integration_enabled_if_option_is_on(sentry_init, reset_integrations):
+    mocked_setup_once = MagicMock()
+
+    with patch(
+        "sentry_sdk.integrations.opentelemetry.integration.OpenTelemetryIntegration.setup_once",
+        mocked_setup_once,
+    ):
+        sentry_init(
+            _experiments={
+                "otel_powered_performance": True,
+            },
+        )
+        mocked_setup_once.assert_called_once()
+
+
+@pytest.mark.forked
+def test_integration_not_enabled_if_option_is_off(sentry_init, reset_integrations):
+    mocked_setup_once = MagicMock()
+
+    with patch(
+        "sentry_sdk.integrations.opentelemetry.integration.OpenTelemetryIntegration.setup_once",
+        mocked_setup_once,
+    ):
+        sentry_init(
+            _experiments={
+                "otel_powered_performance": False,
+            },
+        )
+        mocked_setup_once.assert_not_called()
+
+
+@pytest.mark.forked
+def test_integration_not_enabled_if_option_is_missing(sentry_init, reset_integrations):
+    mocked_setup_once = MagicMock()
+
+    with patch(
+        "sentry_sdk.integrations.opentelemetry.integration.OpenTelemetryIntegration.setup_once",
+        mocked_setup_once,
+    ):
+        sentry_init()
+        mocked_setup_once.assert_not_called()
diff --git a/tests/integrations/opentelemetry/test_propagator.py b/tests/integrations/opentelemetry/test_propagator.py
index 510118f67f..d999b0bb2b 100644
--- a/tests/integrations/opentelemetry/test_propagator.py
+++ b/tests/integrations/opentelemetry/test_propagator.py
@@ -1,27 +1,26 @@
-try:
-    from unittest import mock  # python 3.3 and above
-    from unittest.mock import MagicMock
-except ImportError:
-    import mock  # python < 3.3
-    from mock import MagicMock
+import pytest
+
+from unittest import mock
+from unittest.mock import MagicMock
 
 from opentelemetry.context import get_current
-from opentelemetry.trace.propagation import get_current_span
 from opentelemetry.trace import (
-    set_span_in_context,
-    TraceFlags,
     SpanContext,
+    TraceFlags,
+    set_span_in_context,
 )
+from opentelemetry.trace.propagation import get_current_span
+
 from sentry_sdk.integrations.opentelemetry.consts import (
     SENTRY_BAGGAGE_KEY,
     SENTRY_TRACE_KEY,
 )
-
 from sentry_sdk.integrations.opentelemetry.propagator import SentryPropagator
 from sentry_sdk.integrations.opentelemetry.span_processor import SentrySpanProcessor
 from sentry_sdk.tracing_utils import Baggage
 
 
+@pytest.mark.forked
 def test_extract_no_context_no_sentry_trace_header():
     """
     No context and NO Sentry trace data in getter.
@@ -37,6 +36,7 @@ def test_extract_no_context_no_sentry_trace_header():
     assert modified_context == {}
 
 
+@pytest.mark.forked
 def test_extract_context_no_sentry_trace_header():
     """
     Context but NO Sentry trace data in getter.
@@ -52,6 +52,7 @@ def test_extract_context_no_sentry_trace_header():
     assert modified_context == context
 
 
+@pytest.mark.forked
 def test_extract_empty_context_sentry_trace_header_no_baggage():
     """
     Empty context but Sentry trace data but NO Baggage in getter.
@@ -81,6 +82,7 @@ def test_extract_empty_context_sentry_trace_header_no_baggage():
     assert span_context.trace_id == int("1234567890abcdef1234567890abcdef", 16)
 
 
+@pytest.mark.forked
 def test_extract_context_sentry_trace_header_baggage():
     """
     Empty context but Sentry trace data and Baggage in getter.
@@ -121,6 +123,7 @@ def test_extract_context_sentry_trace_header_baggage():
     assert span_context.trace_id == int("1234567890abcdef1234567890abcdef", 16)
 
 
+@pytest.mark.forked
 def test_inject_empty_otel_span_map():
     """
     Empty otel_span_map.
@@ -151,6 +154,7 @@ def test_inject_empty_otel_span_map():
         setter.set.assert_not_called()
 
 
+@pytest.mark.forked
 def test_inject_sentry_span_no_baggage():
     """
     Inject a sentry span with no baggage.
@@ -195,6 +199,50 @@ def test_inject_sentry_span_no_baggage():
         )
 
 
+def test_inject_sentry_span_empty_baggage():
+    """
+    Inject a sentry span with no baggage.
+    """
+    carrier = None
+    context = get_current()
+    setter = MagicMock()
+    setter.set = MagicMock()
+
+    trace_id = "1234567890abcdef1234567890abcdef"
+    span_id = "1234567890abcdef"
+
+    span_context = SpanContext(
+        trace_id=int(trace_id, 16),
+        span_id=int(span_id, 16),
+        trace_flags=TraceFlags(TraceFlags.SAMPLED),
+        is_remote=True,
+    )
+    span = MagicMock()
+    span.get_span_context.return_value = span_context
+
+    sentry_span = MagicMock()
+    sentry_span.to_traceparent = mock.Mock(
+        return_value="1234567890abcdef1234567890abcdef-1234567890abcdef-1"
+    )
+    sentry_span.containing_transaction.get_baggage = mock.Mock(return_value=Baggage({}))
+
+    span_processor = SentrySpanProcessor()
+    span_processor.otel_span_map[span_id] = sentry_span
+
+    with mock.patch(
+        "sentry_sdk.integrations.opentelemetry.propagator.trace.get_current_span",
+        return_value=span,
+    ):
+        full_context = set_span_in_context(span, context)
+        SentryPropagator().inject(carrier, full_context, setter)
+
+        setter.set.assert_called_once_with(
+            carrier,
+            "sentry-trace",
+            "1234567890abcdef1234567890abcdef-1234567890abcdef-1",
+        )
+
+
 def test_inject_sentry_span_baggage():
     """
     Inject a sentry span with baggage.
diff --git a/tests/integrations/opentelemetry/test_span_processor.py b/tests/integrations/opentelemetry/test_span_processor.py
index 679e51e808..af5cbdd3fb 100644
--- a/tests/integrations/opentelemetry/test_span_processor.py
+++ b/tests/integrations/opentelemetry/test_span_processor.py
@@ -1,49 +1,43 @@
-from datetime import datetime
 import time
-import pytest
+from datetime import datetime, timezone
+from unittest import mock
+from unittest.mock import MagicMock
 
-try:
-    from unittest import mock  # python 3.3 and above
-    from unittest.mock import MagicMock
-except ImportError:
-    import mock
-    from mock import MagicMock  # python < 3.3
+import pytest
+from opentelemetry.trace import SpanKind, SpanContext, Status, StatusCode
 
+import sentry_sdk
 from sentry_sdk.integrations.opentelemetry.span_processor import (
     SentrySpanProcessor,
     link_trace_context_to_error_event,
 )
+from sentry_sdk.utils import Dsn
 from sentry_sdk.tracing import Span, Transaction
-
-from opentelemetry.trace import SpanKind, SpanContext, Status, StatusCode
 from sentry_sdk.tracing_utils import extract_sentrytrace_data
 
 
 def test_is_sentry_span():
     otel_span = MagicMock()
 
-    hub = MagicMock()
-    hub.client = None
-
     span_processor = SentrySpanProcessor()
-    assert not span_processor._is_sentry_span(hub, otel_span)
+    assert not span_processor._is_sentry_span(otel_span)
 
     client = MagicMock()
     client.options = {"instrumenter": "otel"}
-    client.dsn = "https://1234567890abcdef@o123456.ingest.sentry.io/123456"
+    client.parsed_dsn = Dsn("https://1234567890abcdef@o123456.ingest.sentry.io/123456")
+    sentry_sdk.get_global_scope().set_client(client)
 
-    hub.client = client
-    assert not span_processor._is_sentry_span(hub, otel_span)
+    assert not span_processor._is_sentry_span(otel_span)
 
     otel_span.attributes = {
         "http.url": "https://example.com",
     }
-    assert not span_processor._is_sentry_span(hub, otel_span)
+    assert not span_processor._is_sentry_span(otel_span)
 
     otel_span.attributes = {
         "http.url": "https://o123456.ingest.sentry.io/api/123/envelope",
     }
-    assert span_processor._is_sentry_span(hub, otel_span)
+    assert span_processor._is_sentry_span(otel_span)
 
 
 def test_get_otel_context():
@@ -309,30 +303,31 @@ def test_on_start_transaction():
 
     parent_context = {}
 
+    fake_start_transaction = MagicMock()
+
     fake_client = MagicMock()
     fake_client.options = {"instrumenter": "otel"}
     fake_client.dsn = "https://1234567890abcdef@o123456.ingest.sentry.io/123456"
-
-    current_hub = MagicMock()
-    current_hub.client = fake_client
-
-    fake_hub = MagicMock()
-    fake_hub.current = current_hub
+    sentry_sdk.get_global_scope().set_client(fake_client)
 
     with mock.patch(
-        "sentry_sdk.integrations.opentelemetry.span_processor.Hub", fake_hub
+        "sentry_sdk.integrations.opentelemetry.span_processor.start_transaction",
+        fake_start_transaction,
     ):
         span_processor = SentrySpanProcessor()
         span_processor.on_start(otel_span, parent_context)
 
-        fake_hub.current.start_transaction.assert_called_once_with(
+        fake_start_transaction.assert_called_once_with(
             name="Sample OTel Span",
             span_id="1234567890abcdef",
             parent_span_id="abcdef1234567890",
             trace_id="1234567890abcdef1234567890abcdef",
             baggage=None,
-            start_timestamp=datetime.fromtimestamp(otel_span.start_time / 1e9),
+            start_timestamp=datetime.fromtimestamp(
+                otel_span.start_time / 1e9, timezone.utc
+            ),
             instrumenter="otel",
+            origin="auto.otel",
         )
 
         assert len(span_processor.otel_span_map.keys()) == 1
@@ -357,32 +352,27 @@ def test_on_start_child():
     fake_client = MagicMock()
     fake_client.options = {"instrumenter": "otel"}
     fake_client.dsn = "https://1234567890abcdef@o123456.ingest.sentry.io/123456"
+    sentry_sdk.get_global_scope().set_client(fake_client)
 
-    current_hub = MagicMock()
-    current_hub.client = fake_client
-
-    fake_hub = MagicMock()
-    fake_hub.current = current_hub
+    fake_span = MagicMock()
 
-    with mock.patch(
-        "sentry_sdk.integrations.opentelemetry.span_processor.Hub", fake_hub
-    ):
-        fake_span = MagicMock()
-
-        span_processor = SentrySpanProcessor()
-        span_processor.otel_span_map["abcdef1234567890"] = fake_span
-        span_processor.on_start(otel_span, parent_context)
-
-        fake_span.start_child.assert_called_once_with(
-            span_id="1234567890abcdef",
-            description="Sample OTel Span",
-            start_timestamp=datetime.fromtimestamp(otel_span.start_time / 1e9),
-            instrumenter="otel",
-        )
+    span_processor = SentrySpanProcessor()
+    span_processor.otel_span_map["abcdef1234567890"] = fake_span
+    span_processor.on_start(otel_span, parent_context)
+
+    fake_span.start_child.assert_called_once_with(
+        span_id="1234567890abcdef",
+        name="Sample OTel Span",
+        start_timestamp=datetime.fromtimestamp(
+            otel_span.start_time / 1e9, timezone.utc
+        ),
+        instrumenter="otel",
+        origin="auto.otel",
+    )
 
-        assert len(span_processor.otel_span_map.keys()) == 2
-        assert "abcdef1234567890" in span_processor.otel_span_map.keys()
-        assert "1234567890abcdef" in span_processor.otel_span_map.keys()
+    assert len(span_processor.otel_span_map.keys()) == 2
+    assert "abcdef1234567890" in span_processor.otel_span_map.keys()
+    assert "1234567890abcdef" in span_processor.otel_span_map.keys()
 
 
 def test_on_end_no_sentry_span():
@@ -425,6 +415,10 @@ def test_on_end_sentry_transaction():
     )
     otel_span.get_span_context.return_value = span_context
 
+    fake_client = MagicMock()
+    fake_client.options = {"instrumenter": "otel"}
+    sentry_sdk.get_global_scope().set_client(fake_client)
+
     fake_sentry_span = MagicMock(spec=Transaction)
     fake_sentry_span.set_context = MagicMock()
     fake_sentry_span.finish = MagicMock()
@@ -457,6 +451,10 @@ def test_on_end_sentry_span():
     )
     otel_span.get_span_context.return_value = span_context
 
+    fake_client = MagicMock()
+    fake_client.options = {"instrumenter": "otel"}
+    sentry_sdk.get_global_scope().set_client(fake_client)
+
     fake_sentry_span = MagicMock(spec=Span)
     fake_sentry_span.set_context = MagicMock()
     fake_sentry_span.finish = MagicMock()
@@ -482,12 +480,7 @@ def test_link_trace_context_to_error_event():
     """
     fake_client = MagicMock()
     fake_client.options = {"instrumenter": "otel"}
-
-    current_hub = MagicMock()
-    current_hub.client = fake_client
-
-    fake_hub = MagicMock()
-    fake_hub.current = current_hub
+    sentry_sdk.get_global_scope().set_client(fake_client)
 
     span_id = "1234567890abcdef"
     trace_id = "1234567890abcdef1234567890abcdef"
@@ -526,3 +519,95 @@ def test_link_trace_context_to_error_event():
         assert "contexts" in event
         assert "trace" in event["contexts"]
         assert event["contexts"]["trace"] == fake_trace_context
+
+
+def test_pruning_old_spans_on_start():
+    otel_span = MagicMock()
+    otel_span.name = "Sample OTel Span"
+    otel_span.start_time = time.time_ns()
+    span_context = SpanContext(
+        trace_id=int("1234567890abcdef1234567890abcdef", 16),
+        span_id=int("1234567890abcdef", 16),
+        is_remote=True,
+    )
+    otel_span.get_span_context.return_value = span_context
+    otel_span.parent = MagicMock()
+    otel_span.parent.span_id = int("abcdef1234567890", 16)
+
+    parent_context = {}
+    fake_client = MagicMock()
+    fake_client.options = {"instrumenter": "otel", "debug": False}
+    fake_client.dsn = "https://1234567890abcdef@o123456.ingest.sentry.io/123456"
+    sentry_sdk.get_global_scope().set_client(fake_client)
+
+    span_processor = SentrySpanProcessor()
+
+    span_processor.otel_span_map = {
+        "111111111abcdef": MagicMock(),  # should stay
+        "2222222222abcdef": MagicMock(),  # should go
+        "3333333333abcdef": MagicMock(),  # should go
+    }
+    current_time_minutes = int(time.time() / 60)
+    span_processor.open_spans = {
+        current_time_minutes - 3: {"111111111abcdef"},  # should stay
+        current_time_minutes - 11: {
+            "2222222222abcdef",
+            "3333333333abcdef",
+        },  # should go
+    }
+
+    span_processor.on_start(otel_span, parent_context)
+    assert sorted(list(span_processor.otel_span_map.keys())) == [
+        "111111111abcdef",
+        "1234567890abcdef",
+    ]
+    assert sorted(list(span_processor.open_spans.values())) == [
+        {"111111111abcdef"},
+        {"1234567890abcdef"},
+    ]
+
+
+def test_pruning_old_spans_on_end():
+    otel_span = MagicMock()
+    otel_span.name = "Sample OTel Span"
+    otel_span.start_time = time.time_ns()
+    span_context = SpanContext(
+        trace_id=int("1234567890abcdef1234567890abcdef", 16),
+        span_id=int("1234567890abcdef", 16),
+        is_remote=True,
+    )
+    otel_span.get_span_context.return_value = span_context
+    otel_span.parent = MagicMock()
+    otel_span.parent.span_id = int("abcdef1234567890", 16)
+
+    fake_client = MagicMock()
+    fake_client.options = {"instrumenter": "otel"}
+    sentry_sdk.get_global_scope().set_client(fake_client)
+
+    fake_sentry_span = MagicMock(spec=Span)
+    fake_sentry_span.set_context = MagicMock()
+    fake_sentry_span.finish = MagicMock()
+
+    span_processor = SentrySpanProcessor()
+    span_processor._get_otel_context = MagicMock()
+    span_processor._update_span_with_otel_data = MagicMock()
+
+    span_processor.otel_span_map = {
+        "111111111abcdef": MagicMock(),  # should stay
+        "2222222222abcdef": MagicMock(),  # should go
+        "3333333333abcdef": MagicMock(),  # should go
+        "1234567890abcdef": fake_sentry_span,  # should go (because it is closed)
+    }
+    current_time_minutes = int(time.time() / 60)
+    span_processor.open_spans = {
+        current_time_minutes: {"1234567890abcdef"},  # should go (because it is closed)
+        current_time_minutes - 3: {"111111111abcdef"},  # should stay
+        current_time_minutes - 11: {
+            "2222222222abcdef",
+            "3333333333abcdef",
+        },  # should go
+    }
+
+    span_processor.on_end(otel_span)
+    assert sorted(list(span_processor.otel_span_map.keys())) == ["111111111abcdef"]
+    assert sorted(list(span_processor.open_spans.values())) == [{"111111111abcdef"}]
diff --git a/tests/integrations/otlp/__init__.py b/tests/integrations/otlp/__init__.py
new file mode 100644
index 0000000000..75763c2fee
--- /dev/null
+++ b/tests/integrations/otlp/__init__.py
@@ -0,0 +1,3 @@
+import pytest
+
+pytest.importorskip("opentelemetry")
diff --git a/tests/integrations/otlp/test_otlp.py b/tests/integrations/otlp/test_otlp.py
new file mode 100644
index 0000000000..191bf5b7f4
--- /dev/null
+++ b/tests/integrations/otlp/test_otlp.py
@@ -0,0 +1,304 @@
+import pytest
+import responses
+
+from opentelemetry import trace
+from opentelemetry.trace import (
+    get_tracer_provider,
+    set_tracer_provider,
+    ProxyTracerProvider,
+    format_span_id,
+    format_trace_id,
+    get_current_span,
+)
+from opentelemetry.context import attach, detach
+from opentelemetry.propagate import get_global_textmap, set_global_textmap
+from opentelemetry.util._once import Once
+from opentelemetry.sdk.trace import TracerProvider
+from opentelemetry.sdk.trace.export import BatchSpanProcessor
+from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
+
+from sentry_sdk.integrations.otlp import OTLPIntegration, SentryOTLPPropagator
+from sentry_sdk.scope import get_external_propagation_context
+
+
+original_propagator = get_global_textmap()
+
+
+@pytest.fixture(autouse=True)
+def mock_otlp_ingest():
+    responses.start()
+    responses.add(
+        responses.POST,
+        url="https://bla.ingest.sentry.io/api/12312012/integration/otlp/v1/traces/",
+        status=200,
+    )
+
+    yield
+
+    tracer_provider = get_tracer_provider()
+    if isinstance(tracer_provider, TracerProvider):
+        tracer_provider.force_flush()
+
+    responses.stop()
+    responses.reset()
+
+
+@pytest.fixture(autouse=True)
+def reset_otlp(uninstall_integration):
+    trace._TRACER_PROVIDER_SET_ONCE = Once()
+    trace._TRACER_PROVIDER = None
+
+    set_global_textmap(original_propagator)
+
+    uninstall_integration("otlp")
+
+
+def test_sets_new_tracer_provider_with_otlp_exporter(sentry_init):
+    existing_tracer_provider = get_tracer_provider()
+    assert isinstance(existing_tracer_provider, ProxyTracerProvider)
+
+    sentry_init(
+        dsn="https://mysecret@bla.ingest.sentry.io/12312012",
+        integrations=[OTLPIntegration()],
+    )
+
+    tracer_provider = get_tracer_provider()
+    assert tracer_provider is not existing_tracer_provider
+    assert isinstance(tracer_provider, TracerProvider)
+
+    (span_processor,) = tracer_provider._active_span_processor._span_processors
+    assert isinstance(span_processor, BatchSpanProcessor)
+
+    exporter = span_processor.span_exporter
+    assert isinstance(exporter, OTLPSpanExporter)
+    assert (
+        exporter._endpoint
+        == "https://bla.ingest.sentry.io/api/12312012/integration/otlp/v1/traces/"
+    )
+    assert "X-Sentry-Auth" in exporter._headers
+    assert (
+        "Sentry sentry_key=mysecret, sentry_version=7, sentry_client=sentry.python/"
+        in exporter._headers["X-Sentry-Auth"]
+    )
+
+
+def test_uses_existing_tracer_provider_with_otlp_exporter(sentry_init):
+    existing_tracer_provider = TracerProvider()
+    set_tracer_provider(existing_tracer_provider)
+
+    sentry_init(
+        dsn="https://mysecret@bla.ingest.sentry.io/12312012",
+        integrations=[OTLPIntegration()],
+    )
+
+    tracer_provider = get_tracer_provider()
+    assert tracer_provider == existing_tracer_provider
+    assert isinstance(tracer_provider, TracerProvider)
+
+    (span_processor,) = tracer_provider._active_span_processor._span_processors
+    assert isinstance(span_processor, BatchSpanProcessor)
+
+    exporter = span_processor.span_exporter
+    assert isinstance(exporter, OTLPSpanExporter)
+    assert (
+        exporter._endpoint
+        == "https://bla.ingest.sentry.io/api/12312012/integration/otlp/v1/traces/"
+    )
+    assert "X-Sentry-Auth" in exporter._headers
+    assert (
+        "Sentry sentry_key=mysecret, sentry_version=7, sentry_client=sentry.python/"
+        in exporter._headers["X-Sentry-Auth"]
+    )
+
+
+def test_does_not_setup_exporter_when_disabled(sentry_init):
+    existing_tracer_provider = get_tracer_provider()
+    assert isinstance(existing_tracer_provider, ProxyTracerProvider)
+
+    sentry_init(
+        dsn="https://mysecret@bla.ingest.sentry.io/12312012",
+        integrations=[OTLPIntegration(setup_otlp_traces_exporter=False)],
+    )
+
+    tracer_provider = get_tracer_provider()
+    assert tracer_provider is existing_tracer_provider
+
+
+def test_sets_propagator(sentry_init):
+    sentry_init(
+        dsn="https://mysecret@bla.ingest.sentry.io/12312012",
+        integrations=[OTLPIntegration()],
+    )
+
+    propagator = get_global_textmap()
+    assert isinstance(get_global_textmap(), SentryOTLPPropagator)
+    assert propagator is not original_propagator
+
+
+def test_does_not_set_propagator_if_disabled(sentry_init):
+    sentry_init(
+        dsn="https://mysecret@bla.ingest.sentry.io/12312012",
+        integrations=[OTLPIntegration(setup_propagator=False)],
+    )
+
+    propagator = get_global_textmap()
+    assert not isinstance(propagator, SentryOTLPPropagator)
+    assert propagator is original_propagator
+
+
+def test_otel_propagation_context(sentry_init):
+    sentry_init(
+        dsn="https://mysecret@bla.ingest.sentry.io/12312012",
+        integrations=[OTLPIntegration()],
+    )
+
+    tracer = trace.get_tracer(__name__)
+    with tracer.start_as_current_span("foo") as root_span:
+        with tracer.start_as_current_span("bar") as span:
+            external_propagation_context = get_external_propagation_context()
+
+    assert external_propagation_context is not None
+    (trace_id, span_id) = external_propagation_context
+    assert trace_id == format_trace_id(root_span.get_span_context().trace_id)
+    assert trace_id == format_trace_id(span.get_span_context().trace_id)
+    assert span_id == format_span_id(span.get_span_context().span_id)
+
+
+def test_propagator_inject_head_of_trace(sentry_init):
+    sentry_init(
+        dsn="https://mysecret@bla.ingest.sentry.io/12312012",
+        integrations=[OTLPIntegration()],
+    )
+
+    tracer = trace.get_tracer(__name__)
+    propagator = get_global_textmap()
+    carrier = {}
+
+    with tracer.start_as_current_span("foo") as span:
+        propagator.inject(carrier)
+
+        span_context = span.get_span_context()
+        trace_id = format_trace_id(span_context.trace_id)
+        span_id = format_span_id(span_context.span_id)
+
+        assert "sentry-trace" in carrier
+        assert carrier["sentry-trace"] == f"{trace_id}-{span_id}-1"
+
+        #! we cannot populate baggage in otlp as head SDK yet
+        assert "baggage" not in carrier
+
+
+def test_propagator_inject_continue_trace(sentry_init):
+    sentry_init(
+        dsn="https://mysecret@bla.ingest.sentry.io/12312012",
+        integrations=[OTLPIntegration()],
+    )
+
+    tracer = trace.get_tracer(__name__)
+    propagator = get_global_textmap()
+    carrier = {}
+
+    incoming_headers = {
+        "sentry-trace": "771a43a4192642f0b136d5159a501700-1234567890abcdef-1",
+        "baggage": (
+            "sentry-trace_id=771a43a4192642f0b136d5159a501700,sentry-sampled=true"
+        ),
+    }
+
+    ctx = propagator.extract(incoming_headers)
+    token = attach(ctx)
+
+    parent_span_context = get_current_span().get_span_context()
+    assert (
+        format_trace_id(parent_span_context.trace_id)
+        == "771a43a4192642f0b136d5159a501700"
+    )
+    assert format_span_id(parent_span_context.span_id) == "1234567890abcdef"
+
+    with tracer.start_as_current_span("foo") as span:
+        propagator.inject(carrier)
+
+        span_context = span.get_span_context()
+        trace_id = format_trace_id(span_context.trace_id)
+        span_id = format_span_id(span_context.span_id)
+
+        assert trace_id == "771a43a4192642f0b136d5159a501700"
+
+        assert "sentry-trace" in carrier
+        assert carrier["sentry-trace"] == f"{trace_id}-{span_id}-1"
+
+        assert "baggage" in carrier
+        assert carrier["baggage"] == incoming_headers["baggage"]
+
+    detach(token)
+
+
+def test_capture_exceptions_enabled(sentry_init, capture_events):
+    sentry_init(
+        dsn="https://mysecret@bla.ingest.sentry.io/12312012",
+        integrations=[OTLPIntegration(capture_exceptions=True)],
+    )
+
+    events = capture_events()
+
+    tracer = trace.get_tracer(__name__)
+    with tracer.start_as_current_span("test_span") as span:
+        try:
+            raise ValueError("Test exception")
+        except ValueError as e:
+            span.record_exception(e)
+
+    (event,) = events
+    assert event["exception"]["values"][0]["type"] == "ValueError"
+    assert event["exception"]["values"][0]["value"] == "Test exception"
+    assert event["exception"]["values"][0]["mechanism"]["type"] == "otlp"
+    assert event["exception"]["values"][0]["mechanism"]["handled"] is False
+
+    trace_context = event["contexts"]["trace"]
+    assert trace_context["trace_id"] == format_trace_id(
+        span.get_span_context().trace_id
+    )
+    assert trace_context["span_id"] == format_span_id(span.get_span_context().span_id)
+
+
+def test_capture_exceptions_disabled(sentry_init, capture_events):
+    sentry_init(
+        dsn="https://mysecret@bla.ingest.sentry.io/12312012",
+        integrations=[OTLPIntegration(capture_exceptions=False)],
+    )
+
+    events = capture_events()
+
+    tracer = trace.get_tracer(__name__)
+    with tracer.start_as_current_span("test_span") as span:
+        try:
+            raise ValueError("Test exception")
+        except ValueError as e:
+            span.record_exception(e)
+
+    assert len(events) == 0
+
+
+def test_capture_exceptions_preserves_otel_behavior(sentry_init, capture_events):
+    sentry_init(
+        dsn="https://mysecret@bla.ingest.sentry.io/12312012",
+        integrations=[OTLPIntegration(capture_exceptions=True)],
+    )
+
+    events = capture_events()
+
+    tracer = trace.get_tracer(__name__)
+    with tracer.start_as_current_span("test_span") as span:
+        try:
+            raise ValueError("Test exception")
+        except ValueError as e:
+            span.record_exception(e, attributes={"foo": "bar"})
+
+        # Verify the span recorded the exception (OpenTelemetry behavior)
+        # The span should have events with the exception information
+        (otel_event,) = span._events
+        assert otel_event.name == "exception"
+        assert otel_event.attributes["foo"] == "bar"
+
+    # verify sentry also captured it
+    assert len(events) == 1
diff --git a/tests/integrations/pure_eval/test_pure_eval.py b/tests/integrations/pure_eval/test_pure_eval.py
index 2d1a92026e..497a8768d0 100644
--- a/tests/integrations/pure_eval/test_pure_eval.py
+++ b/tests/integrations/pure_eval/test_pure_eval.py
@@ -1,4 +1,3 @@
-import sys
 from types import SimpleNamespace
 
 import pytest
@@ -64,10 +63,7 @@ def foo():
             "u",
             "y",
         ]
-        if sys.version_info[:2] == (3, 5):
-            assert frame_vars.keys() == set(expected_keys)
-        else:
-            assert list(frame_vars.keys()) == expected_keys
+        assert list(frame_vars.keys()) == expected_keys
         assert frame_vars["namespace.d"] == {"1": "2"}
         assert frame_vars["namespace.d[1]"] == "2"
     else:
diff --git a/tests/integrations/pydantic_ai/__init__.py b/tests/integrations/pydantic_ai/__init__.py
new file mode 100644
index 0000000000..3a2ad11c0c
--- /dev/null
+++ b/tests/integrations/pydantic_ai/__init__.py
@@ -0,0 +1,3 @@
+import pytest
+
+pytest.importorskip("pydantic_ai")
diff --git a/tests/integrations/pydantic_ai/test_pydantic_ai.py b/tests/integrations/pydantic_ai/test_pydantic_ai.py
new file mode 100644
index 0000000000..049bcde39c
--- /dev/null
+++ b/tests/integrations/pydantic_ai/test_pydantic_ai.py
@@ -0,0 +1,2606 @@
+import asyncio
+import pytest
+
+from typing import Annotated
+from pydantic import Field
+
+from sentry_sdk.integrations.pydantic_ai import PydanticAIIntegration
+
+from pydantic_ai import Agent
+from pydantic_ai.models.test import TestModel
+from pydantic_ai.exceptions import ModelRetry, UnexpectedModelBehavior
+
+
+@pytest.fixture
+def test_agent():
+    """Create a test agent with model settings."""
+    return Agent(
+        "test",
+        name="test_agent",
+        system_prompt="You are a helpful test assistant.",
+    )
+
+
+@pytest.fixture
+def test_agent_with_settings():
+    """Create a test agent with explicit model settings."""
+    from pydantic_ai import ModelSettings
+
+    return Agent(
+        "test",
+        name="test_agent_settings",
+        system_prompt="You are a test assistant with settings.",
+        model_settings=ModelSettings(
+            temperature=0.7,
+            max_tokens=100,
+            top_p=0.9,
+        ),
+    )
+
+
+@pytest.mark.asyncio
+async def test_agent_run_async(sentry_init, capture_events, test_agent):
+    """
+    Test that the integration creates spans for async agent runs.
+    """
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+
+    events = capture_events()
+
+    result = await test_agent.run("Test input")
+
+    assert result is not None
+    assert result.output is not None
+
+    (transaction,) = events
+    spans = transaction["spans"]
+
+    # Verify transaction (the transaction IS the invoke_agent span)
+    assert transaction["transaction"] == "invoke_agent test_agent"
+    assert transaction["contexts"]["trace"]["origin"] == "auto.ai.pydantic_ai"
+
+    # The transaction itself should have invoke_agent data
+    assert transaction["contexts"]["trace"]["op"] == "gen_ai.invoke_agent"
+
+    # Find child span types (invoke_agent is the transaction, not a child span)
+    chat_spans = [s for s in spans if s["op"] == "gen_ai.chat"]
+    assert len(chat_spans) >= 1
+
+    # Check chat span
+    chat_span = chat_spans[0]
+    assert "chat" in chat_span["description"]
+    assert chat_span["data"]["gen_ai.operation.name"] == "chat"
+    assert chat_span["data"]["gen_ai.response.streaming"] is False
+    assert "gen_ai.request.messages" in chat_span["data"]
+    assert "gen_ai.usage.input_tokens" in chat_span["data"]
+    assert "gen_ai.usage.output_tokens" in chat_span["data"]
+
+
+@pytest.mark.asyncio
+async def test_agent_run_async_usage_data(sentry_init, capture_events, test_agent):
+    """
+    Test that the invoke_agent span includes token usage and model data.
+    """
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+
+    events = capture_events()
+
+    result = await test_agent.run("Test input")
+
+    assert result is not None
+    assert result.output is not None
+
+    (transaction,) = events
+
+    # Verify transaction (the transaction IS the invoke_agent span)
+    assert transaction["transaction"] == "invoke_agent test_agent"
+
+    # The invoke_agent span should have token usage data
+    trace_data = transaction["contexts"]["trace"].get("data", {})
+    assert "gen_ai.usage.input_tokens" in trace_data, (
+        "Missing input_tokens on invoke_agent span"
+    )
+    assert "gen_ai.usage.output_tokens" in trace_data, (
+        "Missing output_tokens on invoke_agent span"
+    )
+    assert "gen_ai.usage.total_tokens" in trace_data, (
+        "Missing total_tokens on invoke_agent span"
+    )
+    assert "gen_ai.response.model" in trace_data, (
+        "Missing response.model on invoke_agent span"
+    )
+
+    # Verify the values are reasonable
+    assert trace_data["gen_ai.usage.input_tokens"] > 0
+    assert trace_data["gen_ai.usage.output_tokens"] > 0
+    assert trace_data["gen_ai.usage.total_tokens"] > 0
+    assert trace_data["gen_ai.response.model"] == "test"  # Test model name
+
+
+def test_agent_run_sync(sentry_init, capture_events, test_agent):
+    """
+    Test that the integration creates spans for sync agent runs.
+    """
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+
+    events = capture_events()
+
+    result = test_agent.run_sync("Test input")
+
+    assert result is not None
+    assert result.output is not None
+
+    (transaction,) = events
+    spans = transaction["spans"]
+
+    # Verify transaction
+    assert transaction["transaction"] == "invoke_agent test_agent"
+    assert transaction["contexts"]["trace"]["origin"] == "auto.ai.pydantic_ai"
+
+    # Find span types
+    chat_spans = [s for s in spans if s["op"] == "gen_ai.chat"]
+    assert len(chat_spans) >= 1
+
+    # Verify streaming flag is False for sync
+    for chat_span in chat_spans:
+        assert chat_span["data"]["gen_ai.response.streaming"] is False
+
+
+@pytest.mark.asyncio
+async def test_agent_run_stream(sentry_init, capture_events, test_agent):
+    """
+    Test that the integration creates spans for streaming agent runs.
+    """
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+
+    events = capture_events()
+
+    async with test_agent.run_stream("Test input") as result:
+        # Consume the stream
+        async for _ in result.stream_output():
+            pass
+
+    (transaction,) = events
+    spans = transaction["spans"]
+
+    # Verify transaction
+    assert transaction["transaction"] == "invoke_agent test_agent"
+    assert transaction["contexts"]["trace"]["origin"] == "auto.ai.pydantic_ai"
+
+    # Find chat spans
+    chat_spans = [s for s in spans if s["op"] == "gen_ai.chat"]
+    assert len(chat_spans) >= 1
+
+    # Verify streaming flag is True for streaming
+    for chat_span in chat_spans:
+        assert chat_span["data"]["gen_ai.response.streaming"] is True
+        assert "gen_ai.request.messages" in chat_span["data"]
+        assert "gen_ai.usage.input_tokens" in chat_span["data"]
+        # Streaming responses should still have output data
+        assert (
+            "gen_ai.response.text" in chat_span["data"]
+            or "gen_ai.response.model" in chat_span["data"]
+        )
+
+
+@pytest.mark.asyncio
+async def test_agent_run_stream_events(sentry_init, capture_events, test_agent):
+    """
+    Test that run_stream_events creates spans (it uses run internally, so non-streaming).
+    """
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+
+    events = capture_events()
+
+    # Consume all events
+    async for _ in test_agent.run_stream_events("Test input"):
+        pass
+
+    (transaction,) = events
+
+    # Verify transaction
+    assert transaction["transaction"] == "invoke_agent test_agent"
+
+    # Find chat spans
+    spans = transaction["spans"]
+    chat_spans = [s for s in spans if s["op"] == "gen_ai.chat"]
+    assert len(chat_spans) >= 1
+
+    # run_stream_events uses run() internally, so streaming should be False
+    for chat_span in chat_spans:
+        assert chat_span["data"]["gen_ai.response.streaming"] is False
+
+
+@pytest.mark.asyncio
+async def test_agent_with_tools(sentry_init, capture_events, test_agent):
+    """
+    Test that tool execution creates execute_tool spans.
+    """
+
+    @test_agent.tool_plain
+    def add_numbers(a: int, b: int) -> int:
+        """Add two numbers together."""
+        return a + b
+
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+
+    events = capture_events()
+
+    result = await test_agent.run("What is 5 + 3?")
+
+    assert result is not None
+
+    (transaction,) = events
+    spans = transaction["spans"]
+
+    # Find child span types (invoke_agent is the transaction, not a child span)
+    chat_spans = [s for s in spans if s["op"] == "gen_ai.chat"]
+    tool_spans = [s for s in spans if s["op"] == "gen_ai.execute_tool"]
+
+    # Should have tool spans
+    assert len(tool_spans) >= 1
+
+    # Check tool span
+    tool_span = tool_spans[0]
+    assert "execute_tool" in tool_span["description"]
+    assert tool_span["data"]["gen_ai.operation.name"] == "execute_tool"
+    assert tool_span["data"]["gen_ai.tool.type"] == "function"
+    assert tool_span["data"]["gen_ai.tool.name"] == "add_numbers"
+    assert "gen_ai.tool.input" in tool_span["data"]
+    assert "gen_ai.tool.output" in tool_span["data"]
+
+    # Check chat spans have available_tools
+    for chat_span in chat_spans:
+        assert "gen_ai.request.available_tools" in chat_span["data"]
+        available_tools_str = chat_span["data"]["gen_ai.request.available_tools"]
+        # Available tools is serialized as a string
+        assert "add_numbers" in available_tools_str
+
+
+@pytest.mark.parametrize(
+    "handled_tool_call_exceptions",
+    [False, True],
+)
+@pytest.mark.asyncio
+async def test_agent_with_tool_model_retry(
+    sentry_init, capture_events, test_agent, handled_tool_call_exceptions
+):
+    """
+    Test that a handled exception is captured when a tool raises ModelRetry.
+    """
+
+    retries = 0
+
+    @test_agent.tool_plain
+    def add_numbers(a: int, b: int) -> float:
+        """Add two numbers together, but raises an exception on the first attempt."""
+        nonlocal retries
+        if retries == 0:
+            retries += 1
+            raise ModelRetry(message="Try again with the same arguments.")
+        return a + b
+
+    sentry_init(
+        integrations=[
+            PydanticAIIntegration(
+                handled_tool_call_exceptions=handled_tool_call_exceptions
+            )
+        ],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+
+    events = capture_events()
+
+    result = await test_agent.run("What is 5 + 3?")
+
+    assert result is not None
+
+    if handled_tool_call_exceptions:
+        (error, transaction) = events
+    else:
+        (transaction,) = events
+    spans = transaction["spans"]
+
+    if handled_tool_call_exceptions:
+        assert error["level"] == "error"
+        assert error["exception"]["values"][0]["mechanism"]["handled"]
+
+    # Find child span types (invoke_agent is the transaction, not a child span)
+    chat_spans = [s for s in spans if s["op"] == "gen_ai.chat"]
+    tool_spans = [s for s in spans if s["op"] == "gen_ai.execute_tool"]
+
+    # Should have tool spans
+    assert len(tool_spans) >= 1
+
+    # Check tool spans
+    model_retry_tool_span = tool_spans[0]
+    assert "execute_tool" in model_retry_tool_span["description"]
+    assert model_retry_tool_span["data"]["gen_ai.operation.name"] == "execute_tool"
+    assert model_retry_tool_span["data"]["gen_ai.tool.type"] == "function"
+    assert model_retry_tool_span["data"]["gen_ai.tool.name"] == "add_numbers"
+    assert "gen_ai.tool.input" in model_retry_tool_span["data"]
+
+    tool_span = tool_spans[1]
+    assert "execute_tool" in tool_span["description"]
+    assert tool_span["data"]["gen_ai.operation.name"] == "execute_tool"
+    assert tool_span["data"]["gen_ai.tool.type"] == "function"
+    assert tool_span["data"]["gen_ai.tool.name"] == "add_numbers"
+    assert "gen_ai.tool.input" in tool_span["data"]
+    assert "gen_ai.tool.output" in tool_span["data"]
+
+    # Check chat spans have available_tools
+    for chat_span in chat_spans:
+        assert "gen_ai.request.available_tools" in chat_span["data"]
+        available_tools_str = chat_span["data"]["gen_ai.request.available_tools"]
+        # Available tools is serialized as a string
+        assert "add_numbers" in available_tools_str
+
+
+@pytest.mark.parametrize(
+    "handled_tool_call_exceptions",
+    [False, True],
+)
+@pytest.mark.asyncio
+async def test_agent_with_tool_validation_error(
+    sentry_init, capture_events, test_agent, handled_tool_call_exceptions
+):
+    """
+    Test that a handled exception is captured when a tool has unsatisfiable constraints.
+    """
+
+    @test_agent.tool_plain
+    def add_numbers(a: Annotated[int, Field(gt=0, lt=0)], b: int) -> int:
+        """Add two numbers together."""
+        return a + b
+
+    sentry_init(
+        integrations=[
+            PydanticAIIntegration(
+                handled_tool_call_exceptions=handled_tool_call_exceptions
+            )
+        ],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+
+    events = capture_events()
+
+    result = None
+    with pytest.raises(UnexpectedModelBehavior):
+        result = await test_agent.run("What is 5 + 3?")
+
+    assert result is None
+
+    if handled_tool_call_exceptions:
+        (error, model_behaviour_error, transaction) = events
+    else:
+        (
+            model_behaviour_error,
+            transaction,
+        ) = events
+    spans = transaction["spans"]
+
+    if handled_tool_call_exceptions:
+        assert error["level"] == "error"
+        assert error["exception"]["values"][0]["mechanism"]["handled"]
+
+    # Find child span types (invoke_agent is the transaction, not a child span)
+    chat_spans = [s for s in spans if s["op"] == "gen_ai.chat"]
+    tool_spans = [s for s in spans if s["op"] == "gen_ai.execute_tool"]
+
+    # Should have tool spans
+    assert len(tool_spans) >= 1
+
+    # Check tool spans
+    model_retry_tool_span = tool_spans[0]
+    assert "execute_tool" in model_retry_tool_span["description"]
+    assert model_retry_tool_span["data"]["gen_ai.operation.name"] == "execute_tool"
+    assert model_retry_tool_span["data"]["gen_ai.tool.type"] == "function"
+    assert model_retry_tool_span["data"]["gen_ai.tool.name"] == "add_numbers"
+    assert "gen_ai.tool.input" in model_retry_tool_span["data"]
+
+    # Check chat spans have available_tools
+    for chat_span in chat_spans:
+        assert "gen_ai.request.available_tools" in chat_span["data"]
+        available_tools_str = chat_span["data"]["gen_ai.request.available_tools"]
+        # Available tools is serialized as a string
+        assert "add_numbers" in available_tools_str
+
+
+@pytest.mark.asyncio
+async def test_agent_with_tools_streaming(sentry_init, capture_events, test_agent):
+    """
+    Test that tool execution works correctly with streaming.
+    """
+
+    @test_agent.tool_plain
+    def multiply(a: int, b: int) -> int:
+        """Multiply two numbers."""
+        return a * b
+
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+
+    events = capture_events()
+
+    async with test_agent.run_stream("What is 7 times 8?") as result:
+        async for _ in result.stream_output():
+            pass
+
+    (transaction,) = events
+    spans = transaction["spans"]
+
+    # Find span types
+    chat_spans = [s for s in spans if s["op"] == "gen_ai.chat"]
+    tool_spans = [s for s in spans if s["op"] == "gen_ai.execute_tool"]
+
+    # Should have tool spans
+    assert len(tool_spans) >= 1
+
+    # Verify streaming flag is True
+    for chat_span in chat_spans:
+        assert chat_span["data"]["gen_ai.response.streaming"] is True
+
+    # Check tool span
+    tool_span = tool_spans[0]
+    assert tool_span["data"]["gen_ai.tool.name"] == "multiply"
+    assert "gen_ai.tool.input" in tool_span["data"]
+    assert "gen_ai.tool.output" in tool_span["data"]
+
+
+@pytest.mark.asyncio
+async def test_model_settings(sentry_init, capture_events, test_agent_with_settings):
+    """
+    Test that model settings are captured in spans.
+    """
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+    )
+
+    events = capture_events()
+
+    await test_agent_with_settings.run("Test input")
+
+    (transaction,) = events
+    spans = transaction["spans"]
+
+    # Find chat span
+    chat_spans = [s for s in spans if s["op"] == "gen_ai.chat"]
+    assert len(chat_spans) >= 1
+
+    chat_span = chat_spans[0]
+    # Check that model settings are captured
+    assert chat_span["data"].get("gen_ai.request.temperature") == 0.7
+    assert chat_span["data"].get("gen_ai.request.max_tokens") == 100
+    assert chat_span["data"].get("gen_ai.request.top_p") == 0.9
+
+
+@pytest.mark.asyncio
+async def test_system_prompt_in_messages(sentry_init, capture_events):
+    """
+    Test that system prompts are included as the first message.
+    """
+    agent = Agent(
+        "test",
+        name="test_system",
+        system_prompt="You are a helpful assistant specialized in testing.",
+    )
+
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+
+    events = capture_events()
+
+    await agent.run("Hello")
+
+    (transaction,) = events
+    spans = transaction["spans"]
+
+    # The transaction IS the invoke_agent span, check for messages in chat spans instead
+    chat_spans = [s for s in spans if s["op"] == "gen_ai.chat"]
+    assert len(chat_spans) >= 1
+
+    chat_span = chat_spans[0]
+    messages_str = chat_span["data"]["gen_ai.request.messages"]
+
+    # Messages is serialized as a string
+    # Should contain system role and helpful assistant text
+    assert "system" in messages_str
+    assert "helpful assistant" in messages_str
+
+
+@pytest.mark.asyncio
+async def test_error_handling(sentry_init, capture_events):
+    """
+    Test error handling in agent execution.
+    """
+    # Use a simpler test that doesn't cause tool failures
+    # as pydantic-ai has complex error handling for tool errors
+    agent = Agent(
+        "test",
+        name="test_error",
+    )
+
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+    )
+
+    events = capture_events()
+
+    # Simple run that should succeed
+    await agent.run("Hello")
+
+    # At minimum, we should have a transaction
+    assert len(events) >= 1
+    transaction = [e for e in events if e.get("type") == "transaction"][0]
+    assert transaction["transaction"] == "invoke_agent test_error"
+    # Transaction should complete successfully (status key may not exist if no error)
+    trace_status = transaction["contexts"]["trace"].get("status")
+    assert trace_status != "error"  # Could be None or some other status
+
+
+@pytest.mark.asyncio
+async def test_without_pii(sentry_init, capture_events, test_agent):
+    """
+    Test that PII is not captured when send_default_pii is False.
+    """
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+        send_default_pii=False,
+    )
+
+    events = capture_events()
+
+    await test_agent.run("Sensitive input")
+
+    (transaction,) = events
+    spans = transaction["spans"]
+
+    # Find child spans (invoke_agent is the transaction, not a child span)
+    chat_spans = [s for s in spans if s["op"] == "gen_ai.chat"]
+
+    # Verify that messages and response text are not captured
+    for span in chat_spans:
+        assert "gen_ai.request.messages" not in span["data"]
+        assert "gen_ai.response.text" not in span["data"]
+
+
+@pytest.mark.asyncio
+async def test_without_pii_tools(sentry_init, capture_events, test_agent):
+    """
+    Test that tool input/output are not captured when send_default_pii is False.
+    """
+
+    @test_agent.tool_plain
+    def sensitive_tool(data: str) -> str:
+        """A tool with sensitive data."""
+        return f"Processed: {data}"
+
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+        send_default_pii=False,
+    )
+
+    events = capture_events()
+
+    await test_agent.run("Use sensitive tool with private data")
+
+    (transaction,) = events
+    spans = transaction["spans"]
+
+    # Find tool spans
+    tool_spans = [s for s in spans if s["op"] == "gen_ai.execute_tool"]
+
+    # If tool was executed, verify input/output are not captured
+    for tool_span in tool_spans:
+        assert "gen_ai.tool.input" not in tool_span["data"]
+        assert "gen_ai.tool.output" not in tool_span["data"]
+
+
+@pytest.mark.asyncio
+async def test_multiple_agents_concurrent(sentry_init, capture_events, test_agent):
+    """
+    Test that multiple agents can run concurrently without interfering.
+    """
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+    )
+
+    events = capture_events()
+
+    async def run_agent(input_text):
+        return await test_agent.run(input_text)
+
+    # Run 3 agents concurrently
+    results = await asyncio.gather(*[run_agent(f"Input {i}") for i in range(3)])
+
+    assert len(results) == 3
+    assert len(events) == 3
+
+    # Verify each transaction is separate
+    for i, transaction in enumerate(events):
+        assert transaction["type"] == "transaction"
+        assert transaction["transaction"] == "invoke_agent test_agent"
+        # Each should have its own spans
+        assert len(transaction["spans"]) >= 1
+
+
+@pytest.mark.asyncio
+async def test_message_history(sentry_init, capture_events):
+    """
+    Test that full conversation history is captured in chat spans.
+    """
+    agent = Agent(
+        "test",
+        name="test_history",
+    )
+
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+
+    events = capture_events()
+
+    # First message
+    await agent.run("Hello, I'm Alice")
+
+    # Second message with history
+    from pydantic_ai import messages
+
+    history = [
+        messages.UserPromptPart(content="Hello, I'm Alice"),
+        messages.ModelResponse(
+            parts=[messages.TextPart(content="Hello Alice! How can I help you?")],
+            model_name="test",
+        ),
+    ]
+
+    await agent.run("What is my name?", message_history=history)
+
+    # We should have 2 transactions
+    assert len(events) >= 2
+
+    # Check the second transaction has the full history
+    second_transaction = events[1]
+    spans = second_transaction["spans"]
+    chat_spans = [s for s in spans if s["op"] == "gen_ai.chat"]
+
+    if chat_spans:
+        chat_span = chat_spans[0]
+        if "gen_ai.request.messages" in chat_span["data"]:
+            messages_data = chat_span["data"]["gen_ai.request.messages"]
+            # Should have multiple messages including history
+            assert len(messages_data) > 1
+
+
+@pytest.mark.asyncio
+async def test_gen_ai_system(sentry_init, capture_events, test_agent):
+    """
+    Test that gen_ai.system is set from the model.
+    """
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+    )
+
+    events = capture_events()
+
+    await test_agent.run("Test input")
+
+    (transaction,) = events
+    spans = transaction["spans"]
+
+    # Find chat span
+    chat_spans = [s for s in spans if s["op"] == "gen_ai.chat"]
+    assert len(chat_spans) >= 1
+
+    chat_span = chat_spans[0]
+    # gen_ai.system should be set from the model (TestModel -> 'test')
+    assert "gen_ai.system" in chat_span["data"]
+    assert chat_span["data"]["gen_ai.system"] == "test"
+
+
+@pytest.mark.asyncio
+async def test_include_prompts_false(sentry_init, capture_events, test_agent):
+    """
+    Test that prompts are not captured when include_prompts=False.
+    """
+    sentry_init(
+        integrations=[PydanticAIIntegration(include_prompts=False)],
+        traces_sample_rate=1.0,
+        send_default_pii=True,  # Even with PII enabled, prompts should not be captured
+    )
+
+    events = capture_events()
+
+    await test_agent.run("Sensitive prompt")
+
+    (transaction,) = events
+    spans = transaction["spans"]
+
+    # Find child spans (invoke_agent is the transaction, not a child span)
+    chat_spans = [s for s in spans if s["op"] == "gen_ai.chat"]
+
+    # Verify that messages and response text are not captured
+    for span in chat_spans:
+        assert "gen_ai.request.messages" not in span["data"]
+        assert "gen_ai.response.text" not in span["data"]
+
+
+@pytest.mark.asyncio
+async def test_include_prompts_true(sentry_init, capture_events, test_agent):
+    """
+    Test that prompts are captured when include_prompts=True (default).
+    """
+    sentry_init(
+        integrations=[PydanticAIIntegration(include_prompts=True)],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+
+    events = capture_events()
+
+    await test_agent.run("Test prompt")
+
+    (transaction,) = events
+    spans = transaction["spans"]
+
+    # Find child spans (invoke_agent is the transaction, not a child span)
+    chat_spans = [s for s in spans if s["op"] == "gen_ai.chat"]
+
+    # Verify that messages are captured in chat spans
+    assert len(chat_spans) >= 1
+    for chat_span in chat_spans:
+        assert "gen_ai.request.messages" in chat_span["data"]
+
+
+@pytest.mark.asyncio
+async def test_include_prompts_false_with_tools(
+    sentry_init, capture_events, test_agent
+):
+    """
+    Test that tool input/output are not captured when include_prompts=False.
+    """
+
+    @test_agent.tool_plain
+    def test_tool(value: int) -> int:
+        """A test tool."""
+        return value * 2
+
+    sentry_init(
+        integrations=[PydanticAIIntegration(include_prompts=False)],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+
+    events = capture_events()
+
+    await test_agent.run("Use the test tool with value 5")
+
+    (transaction,) = events
+    spans = transaction["spans"]
+
+    # Find tool spans
+    tool_spans = [s for s in spans if s["op"] == "gen_ai.execute_tool"]
+
+    # If tool was executed, verify input/output are not captured
+    for tool_span in tool_spans:
+        assert "gen_ai.tool.input" not in tool_span["data"]
+        assert "gen_ai.tool.output" not in tool_span["data"]
+
+
+@pytest.mark.asyncio
+async def test_include_prompts_requires_pii(sentry_init, capture_events, test_agent):
+    """
+    Test that include_prompts requires send_default_pii=True.
+    """
+    sentry_init(
+        integrations=[PydanticAIIntegration(include_prompts=True)],
+        traces_sample_rate=1.0,
+        send_default_pii=False,  # PII disabled
+    )
+
+    events = capture_events()
+
+    await test_agent.run("Test prompt")
+
+    (transaction,) = events
+    spans = transaction["spans"]
+
+    # Find child spans (invoke_agent is the transaction, not a child span)
+    chat_spans = [s for s in spans if s["op"] == "gen_ai.chat"]
+
+    # Even with include_prompts=True, if PII is disabled, messages should not be captured
+    for span in chat_spans:
+        assert "gen_ai.request.messages" not in span["data"]
+        assert "gen_ai.response.text" not in span["data"]
+
+
+@pytest.mark.asyncio
+async def test_mcp_tool_execution_spans(sentry_init, capture_events):
+    """
+    Test that MCP (Model Context Protocol) tool calls create execute_tool spans.
+
+    Tests MCP tools accessed through CombinedToolset, which is how they're typically
+    used in practice (when an agent combines regular functions with MCP servers).
+    """
+    pytest.importorskip("mcp")
+
+    from unittest.mock import AsyncMock, MagicMock
+    from pydantic_ai.mcp import MCPServerStdio
+    from pydantic_ai import Agent
+    from pydantic_ai.toolsets.combined import CombinedToolset
+    import sentry_sdk
+
+    # Create mock MCP server
+    mock_server = MCPServerStdio(
+        command="python",
+        args=["-m", "test_server"],
+    )
+
+    # Mock the server's internal methods
+    mock_server._client = MagicMock()
+    mock_server._is_initialized = True
+    mock_server._server_info = MagicMock()
+
+    # Mock tool call response
+    async def mock_send_request(request, response_type):
+        from mcp.types import CallToolResult, TextContent
+
+        return CallToolResult(
+            content=[TextContent(type="text", text="MCP tool executed successfully")],
+            isError=False,
+        )
+
+    mock_server._client.send_request = mock_send_request
+
+    # Mock context manager methods
+    async def mock_aenter():
+        return mock_server
+
+    async def mock_aexit(*args):
+        pass
+
+    mock_server.__aenter__ = mock_aenter
+    mock_server.__aexit__ = mock_aexit
+
+    # Mock _map_tool_result_part
+    async def mock_map_tool_result_part(part):
+        return part.text if hasattr(part, "text") else str(part)
+
+    mock_server._map_tool_result_part = mock_map_tool_result_part
+
+    # Create a CombinedToolset with the MCP server
+    # This simulates how MCP servers are typically used in practice
+    from pydantic_ai.toolsets.function import FunctionToolset
+
+    function_toolset = FunctionToolset()
+    combined = CombinedToolset([function_toolset, mock_server])
+
+    # Create agent
+    agent = Agent(
+        "test",
+        name="test_mcp_agent",
+    )
+
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+
+    events = capture_events()
+
+    # Simulate MCP tool execution within a transaction through CombinedToolset
+    with sentry_sdk.start_transaction(
+        op="ai.run", name="invoke_agent test_mcp_agent"
+    ) as transaction:
+        # Set up the agent context
+        scope = sentry_sdk.get_current_scope()
+        scope._contexts["pydantic_ai_agent"] = {
+            "_agent": agent,
+        }
+
+        # Create a mock tool that simulates an MCP tool from CombinedToolset
+        from pydantic_ai._run_context import RunContext
+        from pydantic_ai.result import RunUsage
+        from pydantic_ai.models.test import TestModel
+        from pydantic_ai.toolsets.combined import _CombinedToolsetTool
+
+        ctx = RunContext(
+            deps=None,
+            model=TestModel(),
+            usage=RunUsage(),
+            retry=0,
+            tool_name="test_mcp_tool",
+        )
+
+        tool_name = "test_mcp_tool"
+
+        # Create a tool that points to the MCP server
+        # This simulates how CombinedToolset wraps tools from different sources
+        tool = _CombinedToolsetTool(
+            toolset=combined,
+            tool_def=MagicMock(name=tool_name),
+            max_retries=0,
+            args_validator=MagicMock(),
+            source_toolset=mock_server,
+            source_tool=MagicMock(),
+        )
+
+        try:
+            await combined.call_tool(tool_name, {"query": "test"}, ctx, tool)
+        except Exception:
+            # MCP tool might raise if not fully mocked, that's okay
+            pass
+
+    events_list = events
+    if len(events_list) == 0:
+        pytest.skip("No events captured, MCP test setup incomplete")
+
+    (transaction,) = events_list
+    transaction["spans"]
+
+    # Note: This test manually calls combined.call_tool which doesn't go through
+    # ToolManager._call_tool (which is what the integration patches).
+    # In real-world usage, MCP tools are called through agent.run() which uses ToolManager.
+    # This synthetic test setup doesn't trigger the integration's tool patches.
+    # We skip this test as it doesn't represent actual usage patterns.
+    pytest.skip(
+        "MCP test needs to be rewritten to use agent.run() instead of manually calling toolset methods"
+    )
+
+
+@pytest.mark.asyncio
+async def test_context_cleanup_after_run(sentry_init, test_agent):
+    """
+    Test that the pydantic_ai_agent context is properly cleaned up after agent execution.
+    """
+    import sentry_sdk
+
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+    )
+
+    # Verify context is not set before run
+    scope = sentry_sdk.get_current_scope()
+    assert "pydantic_ai_agent" not in scope._contexts
+
+    # Run the agent
+    await test_agent.run("Test input")
+
+    # Verify context is cleaned up after run
+    assert "pydantic_ai_agent" not in scope._contexts
+
+
+def test_context_cleanup_after_run_sync(sentry_init, test_agent):
+    """
+    Test that the pydantic_ai_agent context is properly cleaned up after sync agent execution.
+    """
+    import sentry_sdk
+
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+    )
+
+    # Verify context is not set before run
+    scope = sentry_sdk.get_current_scope()
+    assert "pydantic_ai_agent" not in scope._contexts
+
+    # Run the agent synchronously
+    test_agent.run_sync("Test input")
+
+    # Verify context is cleaned up after run
+    assert "pydantic_ai_agent" not in scope._contexts
+
+
+@pytest.mark.asyncio
+async def test_context_cleanup_after_streaming(sentry_init, test_agent):
+    """
+    Test that the pydantic_ai_agent context is properly cleaned up after streaming execution.
+    """
+    import sentry_sdk
+
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+    )
+
+    # Verify context is not set before run
+    scope = sentry_sdk.get_current_scope()
+    assert "pydantic_ai_agent" not in scope._contexts
+
+    # Run the agent with streaming
+    async with test_agent.run_stream("Test input") as result:
+        async for _ in result.stream_output():
+            pass
+
+    # Verify context is cleaned up after streaming completes
+    assert "pydantic_ai_agent" not in scope._contexts
+
+
+@pytest.mark.asyncio
+async def test_context_cleanup_on_error(sentry_init, test_agent):
+    """
+    Test that the pydantic_ai_agent context is cleaned up even when an error occurs.
+    """
+    import sentry_sdk
+
+    # Create an agent with a tool that raises an error
+    @test_agent.tool_plain
+    def failing_tool() -> str:
+        """A tool that always fails."""
+        raise ValueError("Tool error")
+
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+    )
+
+    # Verify context is not set before run
+    scope = sentry_sdk.get_current_scope()
+    assert "pydantic_ai_agent" not in scope._contexts
+
+    # Run the agent - this may or may not raise depending on pydantic-ai's error handling
+    try:
+        await test_agent.run("Use the failing tool")
+    except Exception:
+        pass
+
+    # Verify context is cleaned up even if there was an error
+    assert "pydantic_ai_agent" not in scope._contexts
+
+
+@pytest.mark.asyncio
+async def test_context_isolation_concurrent_agents(sentry_init, test_agent):
+    """
+    Test that concurrent agent executions maintain isolated contexts.
+    """
+    import sentry_sdk
+
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+    )
+
+    # Create a second agent
+    agent2 = Agent(
+        "test",
+        name="test_agent_2",
+        system_prompt="Second test agent.",
+    )
+
+    async def run_and_check_context(agent, agent_name):
+        """Run an agent and verify its context during and after execution."""
+        # Before execution, context should not exist in the outer scope
+        outer_scope = sentry_sdk.get_current_scope()
+
+        # Run the agent
+        await agent.run(f"Input for {agent_name}")
+
+        # After execution, verify context is cleaned up
+        # Note: Due to isolation_scope, we can't easily check the inner scope here,
+        # but we can verify the outer scope remains clean
+        assert "pydantic_ai_agent" not in outer_scope._contexts
+
+        return agent_name
+
+    # Run both agents concurrently
+    results = await asyncio.gather(
+        run_and_check_context(test_agent, "agent1"),
+        run_and_check_context(agent2, "agent2"),
+    )
+
+    assert results == ["agent1", "agent2"]
+
+    # Final check: outer scope should be clean
+    final_scope = sentry_sdk.get_current_scope()
+    assert "pydantic_ai_agent" not in final_scope._contexts
+
+
+# ==================== Additional Coverage Tests ====================
+
+
+@pytest.mark.asyncio
+async def test_invoke_agent_with_list_user_prompt(sentry_init, capture_events):
+    """
+    Test that invoke_agent span handles list user prompts correctly.
+    """
+    agent = Agent(
+        "test",
+        name="test_list_prompt",
+    )
+
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+
+    events = capture_events()
+
+    # Use a list as user prompt
+    await agent.run(["First part", "Second part"])
+
+    (transaction,) = events
+
+    # Check that the invoke_agent transaction has messages data
+    # The invoke_agent is the transaction itself
+    if "gen_ai.request.messages" in transaction["contexts"]["trace"]["data"]:
+        messages_str = transaction["contexts"]["trace"]["data"][
+            "gen_ai.request.messages"
+        ]
+        assert "First part" in messages_str
+        assert "Second part" in messages_str
+
+
+@pytest.mark.asyncio
+async def test_invoke_agent_with_instructions(sentry_init, capture_events):
+    """
+    Test that invoke_agent span handles instructions correctly.
+    """
+    from pydantic_ai import Agent
+
+    # Create agent with instructions (can be string or list)
+    agent = Agent(
+        "test",
+        name="test_instructions",
+    )
+
+    # Add instructions via _instructions attribute (internal API)
+    agent._instructions = ["Instruction 1", "Instruction 2"]
+    agent._system_prompts = ["System prompt"]
+
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+
+    events = capture_events()
+
+    await agent.run("Test input")
+
+    (transaction,) = events
+
+    # Check that the invoke_agent transaction has messages data
+    if "gen_ai.request.messages" in transaction["contexts"]["trace"]["data"]:
+        messages_str = transaction["contexts"]["trace"]["data"][
+            "gen_ai.request.messages"
+        ]
+        # Should contain both instructions and system prompts
+        assert "Instruction" in messages_str or "System prompt" in messages_str
+
+
+@pytest.mark.asyncio
+async def test_model_name_extraction_with_callable(sentry_init, capture_events):
+    """
+    Test model name extraction when model has a callable name() method.
+    """
+    from unittest.mock import MagicMock
+    from sentry_sdk.integrations.pydantic_ai.utils import _get_model_name
+
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+    )
+
+    # Test the utility function directly
+    mock_model = MagicMock()
+    # Remove model_name attribute so it checks name() next
+    del mock_model.model_name
+    mock_model.name = lambda: "custom-model-name"
+
+    # Get model name - should call the callable name()
+    result = _get_model_name(mock_model)
+
+    # Should return the result from callable
+    assert result == "custom-model-name"
+
+
+@pytest.mark.asyncio
+async def test_model_name_extraction_fallback_to_str(sentry_init, capture_events):
+    """
+    Test model name extraction falls back to str() when no name attribute exists.
+    """
+    from unittest.mock import MagicMock
+    from sentry_sdk.integrations.pydantic_ai.utils import _get_model_name
+
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+    )
+
+    # Test the utility function directly
+    mock_model = MagicMock()
+    # Remove name and model_name attributes
+    del mock_model.name
+    del mock_model.model_name
+
+    # Get model name - should fall back to str()
+    result = _get_model_name(mock_model)
+
+    # Should return string representation
+    assert result is not None
+    assert isinstance(result, str)
+
+
+@pytest.mark.asyncio
+async def test_model_settings_object_style(sentry_init, capture_events):
+    """
+    Test that object-style model settings (non-dict) are handled correctly.
+    """
+    import sentry_sdk
+    from unittest.mock import MagicMock
+    from sentry_sdk.integrations.pydantic_ai.utils import _set_model_data
+
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+    )
+
+    with sentry_sdk.start_transaction(op="test", name="test") as transaction:
+        span = sentry_sdk.start_span(op="test_span")
+
+        # Create mock settings object (not a dict)
+        mock_settings = MagicMock()
+        mock_settings.temperature = 0.8
+        mock_settings.max_tokens = 200
+        mock_settings.top_p = 0.95
+        mock_settings.frequency_penalty = 0.5
+        mock_settings.presence_penalty = 0.3
+
+        # Set model data with object-style settings
+        _set_model_data(span, None, mock_settings)
+
+        span.finish()
+
+    # Should not crash and should set the settings
+    assert transaction is not None
+
+
+@pytest.mark.asyncio
+async def test_usage_data_partial(sentry_init, capture_events):
+    """
+    Test that usage data is correctly handled when only some fields are present.
+    """
+    agent = Agent(
+        "test",
+        name="test_usage",
+    )
+
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+    )
+
+    events = capture_events()
+
+    await agent.run("Test input")
+
+    (transaction,) = events
+    spans = transaction["spans"]
+
+    chat_spans = [s for s in spans if s["op"] == "gen_ai.chat"]
+    assert len(chat_spans) >= 1
+
+    # Check that usage data fields exist (they may or may not be set depending on TestModel)
+    chat_span = chat_spans[0]
+    # At minimum, the span should have been created
+    assert chat_span is not None
+
+
+@pytest.mark.asyncio
+async def test_agent_data_from_scope(sentry_init, capture_events):
+    """
+    Test that agent data can be retrieved from Sentry scope when not passed directly.
+    """
+    import sentry_sdk
+
+    agent = Agent(
+        "test",
+        name="test_scope_agent",
+    )
+
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+    )
+
+    events = capture_events()
+
+    # The integration automatically sets agent in scope during execution
+    await agent.run("Test input")
+
+    (transaction,) = events
+
+    # Verify agent name is captured
+    assert transaction["transaction"] == "invoke_agent test_scope_agent"
+
+
+@pytest.mark.asyncio
+async def test_available_tools_without_description(
+    sentry_init, capture_events, test_agent
+):
+    """
+    Test that available tools are captured even when description is missing.
+    """
+
+    @test_agent.tool_plain
+    def tool_without_desc(x: int) -> int:
+        # No docstring = no description
+        return x * 2
+
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+    )
+
+    events = capture_events()
+
+    await test_agent.run("Use the tool with 5")
+
+    (transaction,) = events
+    spans = transaction["spans"]
+
+    chat_spans = [s for s in spans if s["op"] == "gen_ai.chat"]
+    if chat_spans:
+        chat_span = chat_spans[0]
+        if "gen_ai.request.available_tools" in chat_span["data"]:
+            tools_str = chat_span["data"]["gen_ai.request.available_tools"]
+            assert "tool_without_desc" in tools_str
+
+
+@pytest.mark.asyncio
+async def test_output_with_tool_calls(sentry_init, capture_events, test_agent):
+    """
+    Test that tool calls in model response are captured correctly.
+    """
+
+    @test_agent.tool_plain
+    def calc_tool(value: int) -> int:
+        """Calculate something."""
+        return value + 10
+
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+
+    events = capture_events()
+
+    await test_agent.run("Use calc_tool with 5")
+
+    (transaction,) = events
+    spans = transaction["spans"]
+
+    chat_spans = [s for s in spans if s["op"] == "gen_ai.chat"]
+
+    # At least one chat span should exist
+    assert len(chat_spans) >= 1
+
+    # Check if tool calls are captured in response
+    for chat_span in chat_spans:
+        # Tool calls may or may not be in response depending on TestModel behavior
+        # Just verify the span was created and has basic data
+        assert "gen_ai.operation.name" in chat_span["data"]
+
+
+@pytest.mark.asyncio
+async def test_message_formatting_with_different_parts(sentry_init, capture_events):
+    """
+    Test that different message part types are handled correctly in ai_client span.
+    """
+    from pydantic_ai import Agent, messages
+
+    agent = Agent(
+        "test",
+        name="test_message_parts",
+    )
+
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+
+    events = capture_events()
+
+    # Create message history with different part types
+    history = [
+        messages.UserPromptPart(content="Hello"),
+        messages.ModelResponse(
+            parts=[
+                messages.TextPart(content="Hi there!"),
+            ],
+            model_name="test",
+        ),
+    ]
+
+    await agent.run("What did I say?", message_history=history)
+
+    (transaction,) = events
+    spans = transaction["spans"]
+
+    chat_spans = [s for s in spans if s["op"] == "gen_ai.chat"]
+
+    # Should have chat spans
+    assert len(chat_spans) >= 1
+
+    # Check that messages are captured
+    chat_span = chat_spans[0]
+    if "gen_ai.request.messages" in chat_span["data"]:
+        messages_data = chat_span["data"]["gen_ai.request.messages"]
+        # Should contain message history
+        assert messages_data is not None
+
+
+@pytest.mark.asyncio
+async def test_update_invoke_agent_span_with_none_output(sentry_init, capture_events):
+    """
+    Test that update_invoke_agent_span handles None output gracefully.
+    """
+    import sentry_sdk
+    from sentry_sdk.integrations.pydantic_ai.spans.invoke_agent import (
+        update_invoke_agent_span,
+    )
+
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+
+    with sentry_sdk.start_transaction(op="test", name="test") as transaction:
+        span = sentry_sdk.start_span(op="test_span")
+
+        # Update with None output - should not raise
+        update_invoke_agent_span(span, None)
+
+        span.finish()
+
+    # Should not crash
+    assert transaction is not None
+
+
+@pytest.mark.asyncio
+async def test_update_ai_client_span_with_none_response(sentry_init, capture_events):
+    """
+    Test that update_ai_client_span handles None response gracefully.
+    """
+    import sentry_sdk
+    from sentry_sdk.integrations.pydantic_ai.spans.ai_client import (
+        update_ai_client_span,
+    )
+
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+    )
+
+    with sentry_sdk.start_transaction(op="test", name="test") as transaction:
+        span = sentry_sdk.start_span(op="test_span")
+
+        # Update with None response - should not raise
+        update_ai_client_span(span, None)
+
+        span.finish()
+
+    # Should not crash
+    assert transaction is not None
+
+
+@pytest.mark.asyncio
+async def test_agent_without_name(sentry_init, capture_events):
+    """
+    Test that agent without a name is handled correctly.
+    """
+    # Create agent without explicit name
+    agent = Agent("test")
+
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+    )
+
+    events = capture_events()
+
+    await agent.run("Test input")
+
+    (transaction,) = events
+
+    # Should still create transaction, just with default name
+    assert transaction["type"] == "transaction"
+    # Transaction name should be "invoke_agent agent" or similar default
+    assert "invoke_agent" in transaction["transaction"]
+
+
+@pytest.mark.asyncio
+async def test_model_response_without_parts(sentry_init, capture_events):
+    """
+    Test handling of model response without parts attribute.
+    """
+    import sentry_sdk
+    from unittest.mock import MagicMock
+    from sentry_sdk.integrations.pydantic_ai.spans.ai_client import _set_output_data
+
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+
+    with sentry_sdk.start_transaction(op="test", name="test") as transaction:
+        span = sentry_sdk.start_span(op="test_span")
+
+        # Create mock response without parts
+        mock_response = MagicMock()
+        mock_response.model_name = "test-model"
+        del mock_response.parts  # Remove parts attribute
+
+        # Should not raise, just skip formatting
+        _set_output_data(span, mock_response)
+
+        span.finish()
+
+    # Should not crash
+    assert transaction is not None
+
+
+@pytest.mark.asyncio
+async def test_input_messages_error_handling(sentry_init, capture_events):
+    """
+    Test that _set_input_messages handles errors gracefully.
+    """
+    import sentry_sdk
+    from sentry_sdk.integrations.pydantic_ai.spans.ai_client import _set_input_messages
+
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+
+    with sentry_sdk.start_transaction(op="test", name="test") as transaction:
+        span = sentry_sdk.start_span(op="test_span")
+
+        # Pass invalid messages that would cause an error
+        invalid_messages = [object()]  # Plain object without expected attributes
+
+        # Should not raise, error is caught internally
+        _set_input_messages(span, invalid_messages)
+
+        span.finish()
+
+    # Should not crash
+    assert transaction is not None
+
+
+@pytest.mark.asyncio
+async def test_available_tools_error_handling(sentry_init, capture_events):
+    """
+    Test that _set_available_tools handles errors gracefully.
+    """
+    import sentry_sdk
+    from unittest.mock import MagicMock
+    from sentry_sdk.integrations.pydantic_ai.utils import _set_available_tools
+
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+    )
+
+    with sentry_sdk.start_transaction(op="test", name="test") as transaction:
+        span = sentry_sdk.start_span(op="test_span")
+
+        # Create mock agent with invalid toolset
+        mock_agent = MagicMock()
+        mock_agent._function_toolset.tools.items.side_effect = Exception("Error")
+
+        # Should not raise, error is caught internally
+        _set_available_tools(span, mock_agent)
+
+        span.finish()
+
+    # Should not crash
+    assert transaction is not None
+
+
+@pytest.mark.asyncio
+async def test_set_usage_data_with_none_usage(sentry_init, capture_events):
+    """
+    Test that _set_usage_data handles None usage gracefully.
+    """
+    import sentry_sdk
+    from sentry_sdk.integrations.pydantic_ai.spans.ai_client import _set_usage_data
+
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+    )
+
+    with sentry_sdk.start_transaction(op="test", name="test") as transaction:
+        span = sentry_sdk.start_span(op="test_span")
+
+        # Pass None usage - should not raise
+        _set_usage_data(span, None)
+
+        span.finish()
+
+    # Should not crash
+    assert transaction is not None
+
+
+@pytest.mark.asyncio
+async def test_set_usage_data_with_partial_fields(sentry_init, capture_events):
+    """
+    Test that _set_usage_data handles usage with only some fields.
+    """
+    import sentry_sdk
+    from unittest.mock import MagicMock
+    from sentry_sdk.integrations.pydantic_ai.spans.ai_client import _set_usage_data
+
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+    )
+
+    with sentry_sdk.start_transaction(op="test", name="test") as transaction:
+        span = sentry_sdk.start_span(op="test_span")
+
+        # Create usage object with only some fields
+        mock_usage = MagicMock()
+        mock_usage.input_tokens = 100
+        mock_usage.output_tokens = None  # Missing
+        mock_usage.total_tokens = 100
+
+        # Should only set the non-None fields
+        _set_usage_data(span, mock_usage)
+
+        span.finish()
+
+    # Should not crash
+    assert transaction is not None
+
+
+@pytest.mark.asyncio
+async def test_message_parts_with_tool_return(sentry_init, capture_events):
+    """
+    Test that ToolReturnPart messages are handled correctly.
+    """
+    from pydantic_ai import Agent, messages
+
+    agent = Agent(
+        "test",
+        name="test_tool_return",
+    )
+
+    @agent.tool_plain
+    def test_tool(x: int) -> int:
+        """Test tool."""
+        return x * 2
+
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+
+    events = capture_events()
+
+    # Run with history containing tool return
+    await agent.run("Use test_tool with 5")
+
+    (transaction,) = events
+    spans = transaction["spans"]
+
+    chat_spans = [s for s in spans if s["op"] == "gen_ai.chat"]
+
+    # Should have chat spans
+    assert len(chat_spans) >= 1
+
+
+@pytest.mark.asyncio
+async def test_message_parts_with_list_content(sentry_init, capture_events):
+    """
+    Test that message parts with list content are handled correctly.
+    """
+    import sentry_sdk
+    from unittest.mock import MagicMock
+    from sentry_sdk.integrations.pydantic_ai.spans.ai_client import _set_input_messages
+
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+
+    with sentry_sdk.start_transaction(op="test", name="test") as transaction:
+        span = sentry_sdk.start_span(op="test_span")
+
+        # Create message with list content
+        mock_msg = MagicMock()
+        mock_part = MagicMock()
+        mock_part.content = ["item1", "item2", {"complex": "item"}]
+        mock_msg.parts = [mock_part]
+        mock_msg.instructions = None
+
+        messages = [mock_msg]
+
+        # Should handle list content
+        _set_input_messages(span, messages)
+
+        span.finish()
+
+    # Should not crash
+    assert transaction is not None
+
+
+@pytest.mark.asyncio
+async def test_output_data_with_text_and_tool_calls(sentry_init, capture_events):
+    """
+    Test that _set_output_data handles both text and tool calls in response.
+    """
+    import sentry_sdk
+    from unittest.mock import MagicMock
+    from sentry_sdk.integrations.pydantic_ai.spans.ai_client import _set_output_data
+
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+
+    with sentry_sdk.start_transaction(op="test", name="test") as transaction:
+        span = sentry_sdk.start_span(op="test_span")
+
+        # Create mock response with both TextPart and ToolCallPart
+        from pydantic_ai import messages
+
+        text_part = messages.TextPart(content="Here's the result")
+        tool_call_part = MagicMock()
+        tool_call_part.tool_name = "test_tool"
+        tool_call_part.args = {"x": 5}
+
+        mock_response = MagicMock()
+        mock_response.model_name = "test-model"
+        mock_response.parts = [text_part, tool_call_part]
+
+        # Should handle both text and tool calls
+        _set_output_data(span, mock_response)
+
+        span.finish()
+
+    # Should not crash
+    assert transaction is not None
+
+
+@pytest.mark.asyncio
+async def test_output_data_error_handling(sentry_init, capture_events):
+    """
+    Test that _set_output_data handles errors in formatting gracefully.
+    """
+    import sentry_sdk
+    from unittest.mock import MagicMock
+    from sentry_sdk.integrations.pydantic_ai.spans.ai_client import _set_output_data
+
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+
+    with sentry_sdk.start_transaction(op="test", name="test") as transaction:
+        span = sentry_sdk.start_span(op="test_span")
+
+        # Create mock response that will cause error
+        mock_response = MagicMock()
+        mock_response.model_name = "test-model"
+        mock_response.parts = [MagicMock(side_effect=Exception("Error"))]
+
+        # Should catch error and not crash
+        _set_output_data(span, mock_response)
+
+        span.finish()
+
+    # Should not crash
+    assert transaction is not None
+
+
+@pytest.mark.asyncio
+async def test_message_with_system_prompt_part(sentry_init, capture_events):
+    """
+    Test that SystemPromptPart is handled with correct role.
+    """
+    import sentry_sdk
+    from unittest.mock import MagicMock
+    from sentry_sdk.integrations.pydantic_ai.spans.ai_client import _set_input_messages
+    from pydantic_ai import messages
+
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+
+    with sentry_sdk.start_transaction(op="test", name="test") as transaction:
+        span = sentry_sdk.start_span(op="test_span")
+
+        # Create message with SystemPromptPart
+        system_part = messages.SystemPromptPart(content="You are a helpful assistant")
+
+        mock_msg = MagicMock()
+        mock_msg.parts = [system_part]
+        mock_msg.instructions = None
+
+        msgs = [mock_msg]
+
+        # Should handle system prompt
+        _set_input_messages(span, msgs)
+
+        span.finish()
+
+    # Should not crash
+    assert transaction is not None
+
+
+@pytest.mark.asyncio
+async def test_message_with_instructions(sentry_init, capture_events):
+    """
+    Test that messages with instructions field are handled correctly.
+    """
+    import sentry_sdk
+    from unittest.mock import MagicMock
+    from sentry_sdk.integrations.pydantic_ai.spans.ai_client import _set_input_messages
+
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+
+    with sentry_sdk.start_transaction(op="test", name="test") as transaction:
+        span = sentry_sdk.start_span(op="test_span")
+
+        # Create message with instructions
+        mock_msg = MagicMock()
+        mock_msg.instructions = "System instructions here"
+        mock_part = MagicMock()
+        mock_part.content = "User message"
+        mock_msg.parts = [mock_part]
+
+        msgs = [mock_msg]
+
+        # Should extract system prompt from instructions
+        _set_input_messages(span, msgs)
+
+        span.finish()
+
+    # Should not crash
+    assert transaction is not None
+
+
+@pytest.mark.asyncio
+async def test_set_input_messages_without_prompts(sentry_init, capture_events):
+    """
+    Test that _set_input_messages respects _should_send_prompts().
+    """
+    import sentry_sdk
+    from sentry_sdk.integrations.pydantic_ai.spans.ai_client import _set_input_messages
+
+    sentry_init(
+        integrations=[PydanticAIIntegration(include_prompts=False)],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+
+    with sentry_sdk.start_transaction(op="test", name="test") as transaction:
+        span = sentry_sdk.start_span(op="test_span")
+
+        # Even with messages, should not set them
+        messages = ["test"]
+        _set_input_messages(span, messages)
+
+        span.finish()
+
+    # Should not crash and should not set messages
+    assert transaction is not None
+
+
+@pytest.mark.asyncio
+async def test_set_output_data_without_prompts(sentry_init, capture_events):
+    """
+    Test that _set_output_data respects _should_send_prompts().
+    """
+    import sentry_sdk
+    from unittest.mock import MagicMock
+    from sentry_sdk.integrations.pydantic_ai.spans.ai_client import _set_output_data
+
+    sentry_init(
+        integrations=[PydanticAIIntegration(include_prompts=False)],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+
+    with sentry_sdk.start_transaction(op="test", name="test") as transaction:
+        span = sentry_sdk.start_span(op="test_span")
+
+        # Even with response, should not set output data
+        mock_response = MagicMock()
+        mock_response.model_name = "test"
+        _set_output_data(span, mock_response)
+
+        span.finish()
+
+    # Should not crash and should not set output
+    assert transaction is not None
+
+
+@pytest.mark.asyncio
+async def test_get_model_name_with_exception_in_callable(sentry_init, capture_events):
+    """
+    Test that _get_model_name handles exceptions in name() callable.
+    """
+    from unittest.mock import MagicMock
+    from sentry_sdk.integrations.pydantic_ai.utils import _get_model_name
+
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+    )
+
+    # Create model with callable name that raises exception
+    mock_model = MagicMock()
+    mock_model.name = MagicMock(side_effect=Exception("Error"))
+
+    # Should fall back to str()
+    result = _get_model_name(mock_model)
+
+    # Should return something (str fallback)
+    assert result is not None
+
+
+@pytest.mark.asyncio
+async def test_get_model_name_with_string_model(sentry_init, capture_events):
+    """
+    Test that _get_model_name handles string models.
+    """
+    from sentry_sdk.integrations.pydantic_ai.utils import _get_model_name
+
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+    )
+
+    # Pass a string as model
+    result = _get_model_name("gpt-4")
+
+    # Should return the string
+    assert result == "gpt-4"
+
+
+@pytest.mark.asyncio
+async def test_get_model_name_with_none(sentry_init, capture_events):
+    """
+    Test that _get_model_name handles None model.
+    """
+    from sentry_sdk.integrations.pydantic_ai.utils import _get_model_name
+
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+    )
+
+    # Pass None
+    result = _get_model_name(None)
+
+    # Should return None
+    assert result is None
+
+
+@pytest.mark.asyncio
+async def test_set_model_data_with_system(sentry_init, capture_events):
+    """
+    Test that _set_model_data captures system from model.
+    """
+    import sentry_sdk
+    from unittest.mock import MagicMock
+    from sentry_sdk.integrations.pydantic_ai.utils import _set_model_data
+
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+    )
+
+    with sentry_sdk.start_transaction(op="test", name="test") as transaction:
+        span = sentry_sdk.start_span(op="test_span")
+
+        # Create model with system
+        mock_model = MagicMock()
+        mock_model.system = "openai"
+        mock_model.model_name = "gpt-4"
+
+        # Set model data
+        _set_model_data(span, mock_model, None)
+
+        span.finish()
+
+    # Should not crash
+    assert transaction is not None
+
+
+@pytest.mark.asyncio
+async def test_set_model_data_from_agent_scope(sentry_init, capture_events):
+    """
+    Test that _set_model_data retrieves model from agent in scope when not passed.
+    """
+    import sentry_sdk
+    from unittest.mock import MagicMock
+    from sentry_sdk.integrations.pydantic_ai.utils import _set_model_data
+
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+    )
+
+    with sentry_sdk.start_transaction(op="test", name="test") as transaction:
+        # Set agent in scope
+        scope = sentry_sdk.get_current_scope()
+        mock_agent = MagicMock()
+        mock_agent.model = MagicMock()
+        mock_agent.model.model_name = "test-model"
+        mock_agent.model_settings = {"temperature": 0.5}
+        scope._contexts["pydantic_ai_agent"] = {"_agent": mock_agent}
+
+        span = sentry_sdk.start_span(op="test_span")
+
+        # Pass None for model, should get from scope
+        _set_model_data(span, None, None)
+
+        span.finish()
+
+    # Should not crash
+    assert transaction is not None
+
+
+@pytest.mark.asyncio
+async def test_set_model_data_with_none_settings_values(sentry_init, capture_events):
+    """
+    Test that _set_model_data skips None values in settings.
+    """
+    import sentry_sdk
+    from sentry_sdk.integrations.pydantic_ai.utils import _set_model_data
+
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+    )
+
+    with sentry_sdk.start_transaction(op="test", name="test") as transaction:
+        span = sentry_sdk.start_span(op="test_span")
+
+        # Create settings with None values
+        settings = {
+            "temperature": 0.7,
+            "max_tokens": None,  # Should be skipped
+            "top_p": None,  # Should be skipped
+        }
+
+        # Set model data
+        _set_model_data(span, None, settings)
+
+        span.finish()
+
+    # Should not crash
+    assert transaction is not None
+
+
+@pytest.mark.asyncio
+async def test_should_send_prompts_without_pii(sentry_init, capture_events):
+    """
+    Test that _should_send_prompts returns False when PII disabled.
+    """
+    from sentry_sdk.integrations.pydantic_ai.utils import _should_send_prompts
+
+    sentry_init(
+        integrations=[PydanticAIIntegration(include_prompts=True)],
+        traces_sample_rate=1.0,
+        send_default_pii=False,  # PII disabled
+    )
+
+    # Should return False
+    result = _should_send_prompts()
+    assert result is False
+
+
+@pytest.mark.asyncio
+async def test_set_agent_data_without_agent(sentry_init, capture_events):
+    """
+    Test that _set_agent_data handles None agent gracefully.
+    """
+    import sentry_sdk
+    from sentry_sdk.integrations.pydantic_ai.utils import _set_agent_data
+
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+    )
+
+    with sentry_sdk.start_transaction(op="test", name="test") as transaction:
+        span = sentry_sdk.start_span(op="test_span")
+
+        # Pass None agent, with no agent in scope
+        _set_agent_data(span, None)
+
+        span.finish()
+
+    # Should not crash
+    assert transaction is not None
+
+
+@pytest.mark.asyncio
+async def test_set_agent_data_from_scope(sentry_init, capture_events):
+    """
+    Test that _set_agent_data retrieves agent from scope when not passed.
+    """
+    import sentry_sdk
+    from unittest.mock import MagicMock
+    from sentry_sdk.integrations.pydantic_ai.utils import _set_agent_data
+
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+    )
+
+    with sentry_sdk.start_transaction(op="test", name="test") as transaction:
+        # Set agent in scope
+        scope = sentry_sdk.get_current_scope()
+        mock_agent = MagicMock()
+        mock_agent.name = "test_agent_from_scope"
+        scope._contexts["pydantic_ai_agent"] = {"_agent": mock_agent}
+
+        span = sentry_sdk.start_span(op="test_span")
+
+        # Pass None for agent, should get from scope
+        _set_agent_data(span, None)
+
+        span.finish()
+
+    # Should not crash
+    assert transaction is not None
+
+
+@pytest.mark.asyncio
+async def test_set_agent_data_without_name(sentry_init, capture_events):
+    """
+    Test that _set_agent_data handles agent without name attribute.
+    """
+    import sentry_sdk
+    from unittest.mock import MagicMock
+    from sentry_sdk.integrations.pydantic_ai.utils import _set_agent_data
+
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+    )
+
+    with sentry_sdk.start_transaction(op="test", name="test") as transaction:
+        span = sentry_sdk.start_span(op="test_span")
+
+        # Create agent without name
+        mock_agent = MagicMock()
+        mock_agent.name = None  # No name
+
+        # Should not set agent name
+        _set_agent_data(span, mock_agent)
+
+        span.finish()
+
+    # Should not crash
+    assert transaction is not None
+
+
+@pytest.mark.asyncio
+async def test_set_available_tools_without_toolset(sentry_init, capture_events):
+    """
+    Test that _set_available_tools handles agent without toolset.
+    """
+    import sentry_sdk
+    from unittest.mock import MagicMock
+    from sentry_sdk.integrations.pydantic_ai.utils import _set_available_tools
+
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+    )
+
+    with sentry_sdk.start_transaction(op="test", name="test") as transaction:
+        span = sentry_sdk.start_span(op="test_span")
+
+        # Create agent without _function_toolset
+        mock_agent = MagicMock()
+        del mock_agent._function_toolset
+
+        # Should handle gracefully
+        _set_available_tools(span, mock_agent)
+
+        span.finish()
+
+    # Should not crash
+    assert transaction is not None
+
+
+@pytest.mark.asyncio
+async def test_set_available_tools_with_schema(sentry_init, capture_events):
+    """
+    Test that _set_available_tools extracts tool schema correctly.
+    """
+    import sentry_sdk
+    from unittest.mock import MagicMock
+    from sentry_sdk.integrations.pydantic_ai.utils import _set_available_tools
+
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+    )
+
+    with sentry_sdk.start_transaction(op="test", name="test") as transaction:
+        span = sentry_sdk.start_span(op="test_span")
+
+        # Create agent with toolset containing schema
+        mock_agent = MagicMock()
+        mock_tool = MagicMock()
+        mock_schema = MagicMock()
+        mock_schema.description = "Test tool description"
+        mock_schema.json_schema = {"type": "object", "properties": {}}
+        mock_tool.function_schema = mock_schema
+
+        mock_agent._function_toolset.tools = {"test_tool": mock_tool}
+
+        # Should extract schema
+        _set_available_tools(span, mock_agent)
+
+        span.finish()
+
+    # Should not crash
+    assert transaction is not None
+
+
+@pytest.mark.asyncio
+async def test_execute_tool_span_creation(sentry_init, capture_events):
+    """
+    Test direct creation of execute_tool span.
+    """
+    import sentry_sdk
+    from sentry_sdk.integrations.pydantic_ai.spans.execute_tool import (
+        execute_tool_span,
+        update_execute_tool_span,
+    )
+
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+
+    with sentry_sdk.start_transaction(op="test", name="test") as transaction:
+        # Create execute_tool span
+        with execute_tool_span("test_tool", {"arg": "value"}, None, "function") as span:
+            # Update with result
+            update_execute_tool_span(span, {"result": "success"})
+
+    # Should not crash
+    assert transaction is not None
+
+
+@pytest.mark.asyncio
+async def test_execute_tool_span_with_mcp_type(sentry_init, capture_events):
+    """
+    Test execute_tool span with MCP tool type.
+    """
+    import sentry_sdk
+    from sentry_sdk.integrations.pydantic_ai.spans.execute_tool import execute_tool_span
+
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+
+    with sentry_sdk.start_transaction(op="test", name="test") as transaction:
+        # Create execute_tool span with mcp type
+        with execute_tool_span("mcp_tool", {"arg": "value"}, None, "mcp") as span:
+            # Verify type is set
+            assert span is not None
+
+    # Should not crash
+    assert transaction is not None
+
+
+@pytest.mark.asyncio
+async def test_execute_tool_span_without_prompts(sentry_init, capture_events):
+    """
+    Test that execute_tool span respects _should_send_prompts().
+    """
+    import sentry_sdk
+    from sentry_sdk.integrations.pydantic_ai.spans.execute_tool import (
+        execute_tool_span,
+        update_execute_tool_span,
+    )
+
+    sentry_init(
+        integrations=[PydanticAIIntegration(include_prompts=False)],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+
+    with sentry_sdk.start_transaction(op="test", name="test") as transaction:
+        # Create execute_tool span
+        with execute_tool_span("test_tool", {"arg": "value"}, None, "function") as span:
+            # Update with result - should not set input/output
+            update_execute_tool_span(span, {"result": "success"})
+
+    # Should not crash
+    assert transaction is not None
+
+
+@pytest.mark.asyncio
+async def test_execute_tool_span_with_none_args(sentry_init, capture_events):
+    """
+    Test execute_tool span with None args.
+    """
+    import sentry_sdk
+    from sentry_sdk.integrations.pydantic_ai.spans.execute_tool import execute_tool_span
+
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+
+    with sentry_sdk.start_transaction(op="test", name="test") as transaction:
+        # Create execute_tool span with None args
+        with execute_tool_span("test_tool", None, None, "function") as span:
+            assert span is not None
+
+    # Should not crash
+    assert transaction is not None
+
+
+@pytest.mark.asyncio
+async def test_update_execute_tool_span_with_none_span(sentry_init, capture_events):
+    """
+    Test that update_execute_tool_span handles None span gracefully.
+    """
+    from sentry_sdk.integrations.pydantic_ai.spans.execute_tool import (
+        update_execute_tool_span,
+    )
+
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+    )
+
+    # Update with None span - should not raise
+    update_execute_tool_span(None, {"result": "success"})
+
+    # Should not crash
+    assert True
+
+
+@pytest.mark.asyncio
+async def test_update_execute_tool_span_with_none_result(sentry_init, capture_events):
+    """
+    Test that update_execute_tool_span handles None result gracefully.
+    """
+    import sentry_sdk
+    from sentry_sdk.integrations.pydantic_ai.spans.execute_tool import (
+        execute_tool_span,
+        update_execute_tool_span,
+    )
+
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+
+    with sentry_sdk.start_transaction(op="test", name="test") as transaction:
+        # Create execute_tool span
+        with execute_tool_span("test_tool", {"arg": "value"}, None, "function") as span:
+            # Update with None result
+            update_execute_tool_span(span, None)
+
+    # Should not crash
+    assert transaction is not None
+
+
+@pytest.mark.asyncio
+async def test_tool_execution_without_span_context(sentry_init, capture_events):
+    """
+    Test that tool execution patch handles case when no span context exists.
+    This tests the code path where current_span is None in _patch_tool_execution.
+    """
+    # Import the patching function
+    from unittest.mock import AsyncMock, MagicMock
+
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+    )
+
+    # Create a simple agent with no tools (won't have function_toolset)
+    agent = Agent("test", name="test_no_span")
+
+    # Call without span context (no transaction active)
+    # The patches should handle this gracefully
+    try:
+        # This will fail because we're not in a transaction, but it should not crash
+        await agent.run("test")
+    except Exception:
+        # Expected to fail, that's okay
+        pass
+
+    # Should not crash
+    assert True
+
+
+@pytest.mark.asyncio
+async def test_invoke_agent_span_with_callable_instruction(sentry_init, capture_events):
+    """
+    Test that invoke_agent_span skips callable instructions correctly.
+    """
+    import sentry_sdk
+    from unittest.mock import MagicMock
+    from sentry_sdk.integrations.pydantic_ai.spans.invoke_agent import invoke_agent_span
+
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+
+    with sentry_sdk.start_transaction(op="test", name="test") as transaction:
+        # Create mock agent with callable instruction
+        mock_agent = MagicMock()
+        mock_agent.name = "test_agent"
+        mock_agent._system_prompts = []
+
+        # Add both string and callable instructions
+        mock_callable = lambda: "Dynamic instruction"
+        mock_agent._instructions = ["Static instruction", mock_callable]
+
+        # Create span
+        span = invoke_agent_span("Test prompt", mock_agent, None, None)
+        span.finish()
+
+    # Should not crash (callable should be skipped)
+    assert transaction is not None
+
+
+@pytest.mark.asyncio
+async def test_invoke_agent_span_with_string_instructions(sentry_init, capture_events):
+    """
+    Test that invoke_agent_span handles string instructions (not list).
+    """
+    import sentry_sdk
+    from unittest.mock import MagicMock
+    from sentry_sdk.integrations.pydantic_ai.spans.invoke_agent import invoke_agent_span
+
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+        send_default_pii=True,
+    )
+
+    with sentry_sdk.start_transaction(op="test", name="test") as transaction:
+        # Create mock agent with string instruction
+        mock_agent = MagicMock()
+        mock_agent.name = "test_agent"
+        mock_agent._system_prompts = []
+        mock_agent._instructions = "Single instruction string"
+
+        # Create span
+        span = invoke_agent_span("Test prompt", mock_agent, None, None)
+        span.finish()
+
+    # Should not crash
+    assert transaction is not None
+
+
+@pytest.mark.asyncio
+async def test_ai_client_span_with_streaming_flag(sentry_init, capture_events):
+    """
+    Test that ai_client_span reads streaming flag from scope.
+    """
+    import sentry_sdk
+    from sentry_sdk.integrations.pydantic_ai.spans.ai_client import ai_client_span
+
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+    )
+
+    with sentry_sdk.start_transaction(op="test", name="test") as transaction:
+        # Set streaming flag in scope
+        scope = sentry_sdk.get_current_scope()
+        scope._contexts["pydantic_ai_agent"] = {"_streaming": True}
+
+        # Create ai_client span
+        span = ai_client_span([], None, None, None)
+        span.finish()
+
+    # Should not crash
+    assert transaction is not None
+
+
+@pytest.mark.asyncio
+async def test_ai_client_span_gets_agent_from_scope(sentry_init, capture_events):
+    """
+    Test that ai_client_span gets agent from scope when not passed.
+    """
+    import sentry_sdk
+    from unittest.mock import MagicMock
+    from sentry_sdk.integrations.pydantic_ai.spans.ai_client import ai_client_span
+
+    sentry_init(
+        integrations=[PydanticAIIntegration()],
+        traces_sample_rate=1.0,
+    )
+
+    with sentry_sdk.start_transaction(op="test", name="test") as transaction:
+        # Set agent in scope
+        scope = sentry_sdk.get_current_scope()
+        mock_agent = MagicMock()
+        mock_agent.name = "test_agent"
+        mock_agent._function_toolset = MagicMock()
+        mock_agent._function_toolset.tools = {}
+        scope._contexts["pydantic_ai_agent"] = {"_agent": mock_agent}
+
+        # Create ai_client span without passing agent
+        span = ai_client_span([], None, None, None)
+        span.finish()
+
+    # Should not crash
+    assert transaction is not None
diff --git a/tests/integrations/pymongo/test_pymongo.py b/tests/integrations/pymongo/test_pymongo.py
index 89701c9f3a..0669f73c30 100644
--- a/tests/integrations/pymongo/test_pymongo.py
+++ b/tests/integrations/pymongo/test_pymongo.py
@@ -10,7 +10,7 @@
 @pytest.fixture(scope="session")
 def mongo_server():
     server = MockupDB(verbose=True)
-    server.autoresponds("ismaster", maxWireVersion=6)
+    server.autoresponds("ismaster", maxWireVersion=8)
     server.run()
     server.autoresponds(
         {"find": "test_collection"}, cursor={"id": 123, "firstBatch": []}
@@ -62,18 +62,29 @@ def test_transactions(sentry_init, capture_events, mongo_server, with_pii):
         assert span["data"][SPANDATA.SERVER_PORT] == mongo_server.port
         for field, value in common_tags.items():
             assert span["tags"][field] == value
+            assert span["data"][field] == value
 
-    assert find["op"] == "db.query"
-    assert insert_success["op"] == "db.query"
-    assert insert_fail["op"] == "db.query"
+    assert find["op"] == "db"
+    assert insert_success["op"] == "db"
+    assert insert_fail["op"] == "db"
 
+    assert find["data"]["db.operation"] == "find"
     assert find["tags"]["db.operation"] == "find"
+    assert insert_success["data"]["db.operation"] == "insert"
     assert insert_success["tags"]["db.operation"] == "insert"
+    assert insert_fail["data"]["db.operation"] == "insert"
     assert insert_fail["tags"]["db.operation"] == "insert"
 
-    assert find["description"].startswith("find {")
-    assert insert_success["description"].startswith("insert {")
-    assert insert_fail["description"].startswith("insert {")
+    assert find["description"].startswith('{"find')
+    assert insert_success["description"].startswith('{"insert')
+    assert insert_fail["description"].startswith('{"insert')
+
+    assert find["data"][SPANDATA.DB_MONGODB_COLLECTION] == "test_collection"
+    assert find["tags"][SPANDATA.DB_MONGODB_COLLECTION] == "test_collection"
+    assert insert_success["data"][SPANDATA.DB_MONGODB_COLLECTION] == "test_collection"
+    assert insert_success["tags"][SPANDATA.DB_MONGODB_COLLECTION] == "test_collection"
+    assert insert_fail["data"][SPANDATA.DB_MONGODB_COLLECTION] == "erroneous"
+    assert insert_fail["tags"][SPANDATA.DB_MONGODB_COLLECTION] == "erroneous"
     if with_pii:
         assert "1" in find["description"]
         assert "2" in insert_success["description"]
@@ -88,8 +99,11 @@ def test_transactions(sentry_init, capture_events, mongo_server, with_pii):
             and "4" not in insert_fail["description"]
         )
 
+    assert find["status"] == "ok"
     assert find["tags"]["status"] == "ok"
+    assert insert_success["status"] == "ok"
     assert insert_success["tags"]["status"] == "ok"
+    assert insert_fail["status"] == "internal_error"
     assert insert_fail["tags"]["status"] == "internal_error"
 
 
@@ -113,18 +127,19 @@ def test_breadcrumbs(sentry_init, capture_events, mongo_server, with_pii):
     (crumb,) = event["breadcrumbs"]["values"]
 
     assert crumb["category"] == "query"
-    assert crumb["message"].startswith("find {")
+    assert crumb["message"].startswith('{"find')
     if with_pii:
         assert "1" in crumb["message"]
     else:
         assert "1" not in crumb["message"]
-    assert crumb["type"] == "db.query"
+    assert crumb["type"] == "db"
     assert crumb["data"] == {
         "db.name": "test_db",
         "db.system": "mongodb",
         "db.operation": "find",
         "net.peer.name": mongo_server.host,
         "net.peer.port": str(mongo_server.port),
+        "db.mongodb.collection": "test_collection",
     }
 
 
@@ -422,3 +437,23 @@ def test_breadcrumbs(sentry_init, capture_events, mongo_server, with_pii):
 )
 def test_strip_pii(testcase):
     assert _strip_pii(testcase["command"]) == testcase["command_stripped"]
+
+
+def test_span_origin(sentry_init, capture_events, mongo_server):
+    sentry_init(
+        integrations=[PyMongoIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    connection = MongoClient(mongo_server.uri)
+
+    with start_transaction():
+        list(
+            connection["test_db"]["test_collection"].find({"foobar": 1})
+        )  # force query execution
+
+    (event,) = events
+
+    assert event["contexts"]["trace"]["origin"] == "manual"
+    assert event["spans"][0]["origin"] == "auto.db.pymongo"
diff --git a/tests/integrations/pyramid/test_pyramid.py b/tests/integrations/pyramid/test_pyramid.py
index 1f93a52f2c..cd200f7f7b 100644
--- a/tests/integrations/pyramid/test_pyramid.py
+++ b/tests/integrations/pyramid/test_pyramid.py
@@ -1,18 +1,18 @@
 import json
 import logging
-import pytest
 from io import BytesIO
 
 import pyramid.testing
-
+import pytest
 from pyramid.authorization import ACLAuthorizationPolicy
 from pyramid.response import Response
+from werkzeug.test import Client
 
 from sentry_sdk import capture_message, add_breadcrumb
+from sentry_sdk.consts import DEFAULT_MAX_VALUE_LENGTH
 from sentry_sdk.integrations.pyramid import PyramidIntegration
 from sentry_sdk.serializer import MAX_DATABAG_BREADTH
-
-from werkzeug.test import Client
+from tests.conftest import unpack_werkzeug_response
 
 
 try:
@@ -157,9 +157,9 @@ def test_transaction_style(
 
 
 def test_large_json_request(sentry_init, capture_events, route, get_client):
-    sentry_init(integrations=[PyramidIntegration()])
+    sentry_init(integrations=[PyramidIntegration()], max_request_body_size="always")
 
-    data = {"foo": {"bar": "a" * 2000}}
+    data = {"foo": {"bar": "a" * (DEFAULT_MAX_VALUE_LENGTH + 10)}}
 
     @route("/")
     def index(request):
@@ -176,9 +176,14 @@ def index(request):
 
     (event,) = events
     assert event["_meta"]["request"]["data"]["foo"]["bar"] == {
-        "": {"len": 2000, "rem": [["!limit", "x", 1021, 1024]]}
+        "": {
+            "len": DEFAULT_MAX_VALUE_LENGTH + 10,
+            "rem": [
+                ["!limit", "x", DEFAULT_MAX_VALUE_LENGTH - 3, DEFAULT_MAX_VALUE_LENGTH]
+            ],
+        }
     }
-    assert len(event["request"]["data"]["foo"]["bar"]) == 1024
+    assert len(event["request"]["data"]["foo"]["bar"]) == DEFAULT_MAX_VALUE_LENGTH
 
 
 @pytest.mark.parametrize("data", [{}, []], ids=["empty-dict", "empty-list"])
@@ -231,7 +236,10 @@ def index(request):
 def test_files_and_form(sentry_init, capture_events, route, get_client):
     sentry_init(integrations=[PyramidIntegration()], max_request_body_size="always")
 
-    data = {"foo": "a" * 2000, "file": (BytesIO(b"hello"), "hello.txt")}
+    data = {
+        "foo": "a" * (DEFAULT_MAX_VALUE_LENGTH + 10),
+        "file": (BytesIO(b"hello"), "hello.txt"),
+    }
 
     @route("/")
     def index(request):
@@ -245,9 +253,14 @@ def index(request):
 
     (event,) = events
     assert event["_meta"]["request"]["data"]["foo"] == {
-        "": {"len": 2000, "rem": [["!limit", "x", 1021, 1024]]}
+        "": {
+            "len": DEFAULT_MAX_VALUE_LENGTH + 10,
+            "rem": [
+                ["!limit", "x", DEFAULT_MAX_VALUE_LENGTH - 3, DEFAULT_MAX_VALUE_LENGTH]
+            ],
+        }
     }
-    assert len(event["request"]["data"]["foo"]) == 1024
+    assert len(event["request"]["data"]["foo"]) == DEFAULT_MAX_VALUE_LENGTH
 
     assert event["_meta"]["request"]["data"]["file"] == {"": {"rem": [["!raw", "x"]]}}
     assert not event["request"]["data"]["file"]
@@ -317,8 +330,8 @@ def errorhandler(exc, request):
     pyramid_config.add_view(errorhandler, context=Exception)
 
     client = get_client()
-    app_iter, status, headers = client.get("/")
-    assert b"".join(app_iter) == b"bad request"
+    app_iter, status, headers = unpack_werkzeug_response(client.get("/"))
+    assert app_iter == b"bad request"
     assert status.lower() == "500 internal server error"
 
     (error,) = errors
@@ -367,9 +380,9 @@ def test_error_in_authenticated_userid(
     )
     logger = logging.getLogger("test_pyramid")
 
-    class AuthenticationPolicy(object):
+    class AuthenticationPolicy:
         def authenticated_userid(self, request):
-            logger.error("failed to identify user")
+            logger.warning("failed to identify user")
 
     pyramid_config.set_authorization_policy(ACLAuthorizationPolicy())
     pyramid_config.set_authentication_policy(AuthenticationPolicy())
@@ -381,6 +394,16 @@ def authenticated_userid(self, request):
 
     assert len(events) == 1
 
+    # In `authenticated_userid` there used to be a call to `logging.error`. This would print this error in the
+    # event processor of the Pyramid integration and the logging integration would capture this and send it to Sentry.
+    # This is not possible anymore, because capturing that error in the logging integration would again run all the
+    # event processors (from the global, isolation and current scope) and thus would again run the same pyramid
+    # event processor that raised the error in the first place, leading on an infinite loop.
+    # This test here is now deactivated and always passes, but it is kept here to document the problem.
+    # This change in behavior is also mentioned in the migration documentation for Python SDK 2.0
+
+    # assert "message" not in events[0].keys()
+
 
 def tween_factory(handler, registry):
     def tween(request):
@@ -412,3 +435,18 @@ def index(request):
     client.get("/")
 
     assert not errors
+
+
+def test_span_origin(sentry_init, capture_events, get_client):
+    sentry_init(
+        integrations=[PyramidIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    client = get_client()
+    client.get("/message")
+
+    (_, event) = events
+
+    assert event["contexts"]["trace"]["origin"] == "auto.http.pyramid"
diff --git a/tests/integrations/quart/test_quart.py b/tests/integrations/quart/test_quart.py
index 0f693088c9..7c027455c0 100644
--- a/tests/integrations/quart/test_quart.py
+++ b/tests/integrations/quart/test_quart.py
@@ -1,36 +1,36 @@
+import importlib
 import json
+import sys
 import threading
+from unittest import mock
 
 import pytest
-import pytest_asyncio
 
+import sentry_sdk
 from sentry_sdk import (
     set_tag,
-    configure_scope,
     capture_message,
     capture_exception,
-    last_event_id,
 )
 from sentry_sdk.integrations.logging import LoggingIntegration
 import sentry_sdk.integrations.quart as quart_sentry
 
-from quart import Quart, Response, abort, stream_with_context
-from quart.views import View
 
-from quart_auth import AuthUser, login_user
+def quart_app_factory():
+    # These imports are inlined because the `test_quart_flask_patch` testcase
+    # tests behavior that is triggered by importing a package before any Quart
+    # imports happen, so we can't have these on the module level
+    from quart import Quart
 
-try:
-    from quart_auth import QuartAuth
-
-    auth_manager = QuartAuth()
-except ImportError:
-    from quart_auth import AuthManager
+    try:
+        from quart_auth import QuartAuth
 
-    auth_manager = AuthManager()
+        auth_manager = QuartAuth()
+    except ImportError:
+        from quart_auth import AuthManager
 
+        auth_manager = AuthManager()
 
-@pytest_asyncio.fixture
-async def app():
     app = Quart(__name__)
     app.debug = False
     app.config["TESTING"] = False
@@ -74,8 +74,49 @@ def integration_enabled_params(request):
 
 
 @pytest.mark.asyncio
-async def test_has_context(sentry_init, app, capture_events):
+@pytest.mark.forked
+@pytest.mark.skipif(
+    not importlib.util.find_spec("quart_flask_patch"),
+    reason="requires quart_flask_patch",
+)
+@pytest.mark.skipif(
+    sys.version_info >= (3, 14),
+    reason="quart_flask_patch not working on 3.14 (yet?)",
+)
+async def test_quart_flask_patch(sentry_init, capture_events, reset_integrations):
+    # This testcase is forked because `import quart_flask_patch` needs to run
+    # before anything else Quart-related is imported (since it monkeypatches
+    # some things) and we don't want this to affect other testcases.
+    #
+    # It's also important this testcase be run before any other testcase
+    # that uses `quart_app_factory`.
+    import quart_flask_patch  # noqa: F401
+
+    app = quart_app_factory()
+    sentry_init(
+        integrations=[quart_sentry.QuartIntegration()],
+    )
+
+    @app.route("/")
+    async def index():
+        1 / 0
+
+    events = capture_events()
+
+    client = app.test_client()
+    try:
+        await client.get("/")
+    except ZeroDivisionError:
+        pass
+
+    (event,) = events
+    assert event["exception"]["values"][0]["mechanism"]["type"] == "quart"
+
+
+@pytest.mark.asyncio
+async def test_has_context(sentry_init, capture_events):
     sentry_init(integrations=[quart_sentry.QuartIntegration()])
+    app = quart_app_factory()
     events = capture_events()
 
     client = app.test_client()
@@ -100,7 +141,6 @@ async def test_has_context(sentry_init, app, capture_events):
 )
 async def test_transaction_style(
     sentry_init,
-    app,
     capture_events,
     url,
     transaction_style,
@@ -112,6 +152,7 @@ async def test_transaction_style(
             quart_sentry.QuartIntegration(transaction_style=transaction_style)
         ]
     )
+    app = quart_app_factory()
     events = capture_events()
 
     client = app.test_client()
@@ -127,10 +168,10 @@ async def test_errors(
     sentry_init,
     capture_exceptions,
     capture_events,
-    app,
     integration_enabled_params,
 ):
-    sentry_init(debug=True, **integration_enabled_params)
+    sentry_init(**integration_enabled_params)
+    app = quart_app_factory()
 
     @app.route("/")
     async def index():
@@ -154,9 +195,10 @@ async def index():
 
 @pytest.mark.asyncio
 async def test_quart_auth_not_installed(
-    sentry_init, app, capture_events, monkeypatch, integration_enabled_params
+    sentry_init, capture_events, monkeypatch, integration_enabled_params
 ):
     sentry_init(**integration_enabled_params)
+    app = quart_app_factory()
 
     monkeypatch.setattr(quart_sentry, "quart_auth", None)
 
@@ -171,9 +213,10 @@ async def test_quart_auth_not_installed(
 
 @pytest.mark.asyncio
 async def test_quart_auth_not_configured(
-    sentry_init, app, capture_events, monkeypatch, integration_enabled_params
+    sentry_init, capture_events, monkeypatch, integration_enabled_params
 ):
     sentry_init(**integration_enabled_params)
+    app = quart_app_factory()
 
     assert quart_sentry.quart_auth
 
@@ -187,9 +230,10 @@ async def test_quart_auth_not_configured(
 
 @pytest.mark.asyncio
 async def test_quart_auth_partially_configured(
-    sentry_init, app, capture_events, monkeypatch, integration_enabled_params
+    sentry_init, capture_events, monkeypatch, integration_enabled_params
 ):
     sentry_init(**integration_enabled_params)
+    app = quart_app_factory()
 
     events = capture_events()
 
@@ -206,13 +250,15 @@ async def test_quart_auth_partially_configured(
 async def test_quart_auth_configured(
     send_default_pii,
     sentry_init,
-    app,
     user_id,
     capture_events,
     monkeypatch,
     integration_enabled_params,
 ):
+    from quart_auth import AuthUser, login_user
+
     sentry_init(send_default_pii=send_default_pii, **integration_enabled_params)
+    app = quart_app_factory()
 
     @app.route("/login")
     async def login():
@@ -243,10 +289,9 @@ async def login():
         [quart_sentry.QuartIntegration(), LoggingIntegration(event_level="ERROR")],
     ],
 )
-async def test_errors_not_reported_twice(
-    sentry_init, integrations, capture_events, app
-):
+async def test_errors_not_reported_twice(sentry_init, integrations, capture_events):
     sentry_init(integrations=integrations)
+    app = quart_app_factory()
 
     @app.route("/")
     async def index():
@@ -266,7 +311,7 @@ async def index():
 
 
 @pytest.mark.asyncio
-async def test_logging(sentry_init, capture_events, app):
+async def test_logging(sentry_init, capture_events):
     # ensure that Quart's logger magic doesn't break ours
     sentry_init(
         integrations=[
@@ -274,6 +319,7 @@ async def test_logging(sentry_init, capture_events, app):
             LoggingIntegration(event_level="ERROR"),
         ]
     )
+    app = quart_app_factory()
 
     @app.route("/")
     async def index():
@@ -290,13 +336,17 @@ async def index():
 
 
 @pytest.mark.asyncio
-async def test_no_errors_without_request(app, sentry_init):
+async def test_no_errors_without_request(sentry_init):
     sentry_init(integrations=[quart_sentry.QuartIntegration()])
+    app = quart_app_factory()
+
     async with app.app_context():
         capture_exception(ValueError())
 
 
-def test_cli_commands_raise(app):
+def test_cli_commands_raise():
+    app = quart_app_factory()
+
     if not hasattr(app, "cli"):
         pytest.skip("Too old quart version")
 
@@ -313,8 +363,9 @@ def foo():
 
 
 @pytest.mark.asyncio
-async def test_500(sentry_init, capture_events, app):
+async def test_500(sentry_init):
     sentry_init(integrations=[quart_sentry.QuartIntegration()])
+    app = quart_app_factory()
 
     @app.route("/")
     async def index():
@@ -322,22 +373,18 @@ async def index():
 
     @app.errorhandler(500)
     async def error_handler(err):
-        return "Sentry error: %s" % last_event_id()
-
-    events = capture_events()
+        return "Sentry error."
 
     client = app.test_client()
     response = await client.get("/")
 
-    (event,) = events
-    assert (await response.get_data(as_text=True)) == "Sentry error: %s" % event[
-        "event_id"
-    ]
+    assert (await response.get_data(as_text=True)) == "Sentry error."
 
 
 @pytest.mark.asyncio
-async def test_error_in_errorhandler(sentry_init, capture_events, app):
+async def test_error_in_errorhandler(sentry_init, capture_events):
     sentry_init(integrations=[quart_sentry.QuartIntegration()])
+    app = quart_app_factory()
 
     @app.route("/")
     async def index():
@@ -364,8 +411,11 @@ async def error_handler(err):
 
 
 @pytest.mark.asyncio
-async def test_bad_request_not_captured(sentry_init, capture_events, app):
+async def test_bad_request_not_captured(sentry_init, capture_events):
+    from quart import abort
+
     sentry_init(integrations=[quart_sentry.QuartIntegration()])
+    app = quart_app_factory()
     events = capture_events()
 
     @app.route("/")
@@ -380,22 +430,22 @@ async def index():
 
 
 @pytest.mark.asyncio
-async def test_does_not_leak_scope(sentry_init, capture_events, app):
+async def test_does_not_leak_scope(sentry_init, capture_events):
+    from quart import Response, stream_with_context
+
     sentry_init(integrations=[quart_sentry.QuartIntegration()])
+    app = quart_app_factory()
     events = capture_events()
 
-    with configure_scope() as scope:
-        scope.set_tag("request_data", False)
+    sentry_sdk.get_isolation_scope().set_tag("request_data", False)
 
     @app.route("/")
     async def index():
-        with configure_scope() as scope:
-            scope.set_tag("request_data", True)
+        sentry_sdk.get_isolation_scope().set_tag("request_data", True)
 
         async def generate():
             for row in range(1000):
-                with configure_scope() as scope:
-                    assert scope._tags["request_data"]
+                assert sentry_sdk.get_isolation_scope()._tags["request_data"]
 
                 yield str(row) + "\n"
 
@@ -407,14 +457,13 @@ async def generate():
         str(row) + "\n" for row in range(1000)
     )
     assert not events
-
-    with configure_scope() as scope:
-        assert not scope._tags["request_data"]
+    assert not sentry_sdk.get_isolation_scope()._tags["request_data"]
 
 
 @pytest.mark.asyncio
-async def test_scoped_test_client(sentry_init, app):
+async def test_scoped_test_client(sentry_init):
     sentry_init(integrations=[quart_sentry.QuartIntegration()])
+    app = quart_app_factory()
 
     @app.route("/")
     async def index():
@@ -428,12 +477,13 @@ async def index():
 @pytest.mark.asyncio
 @pytest.mark.parametrize("exc_cls", [ZeroDivisionError, Exception])
 async def test_errorhandler_for_exception_swallows_exception(
-    sentry_init, app, capture_events, exc_cls
+    sentry_init, capture_events, exc_cls
 ):
     # In contrast to error handlers for a status code, error
     # handlers for exceptions can swallow the exception (this is
     # just how the Quart signal works)
     sentry_init(integrations=[quart_sentry.QuartIntegration()])
+    app = quart_app_factory()
     events = capture_events()
 
     @app.route("/")
@@ -452,8 +502,9 @@ async def zerodivision(e):
 
 
 @pytest.mark.asyncio
-async def test_tracing_success(sentry_init, capture_events, app):
+async def test_tracing_success(sentry_init, capture_events):
     sentry_init(traces_sample_rate=1.0, integrations=[quart_sentry.QuartIntegration()])
+    app = quart_app_factory()
 
     @app.before_request
     async def _():
@@ -485,8 +536,9 @@ async def hi_tx():
 
 
 @pytest.mark.asyncio
-async def test_tracing_error(sentry_init, capture_events, app):
+async def test_tracing_error(sentry_init, capture_events):
     sentry_init(traces_sample_rate=1.0, integrations=[quart_sentry.QuartIntegration()])
+    app = quart_app_factory()
 
     events = capture_events()
 
@@ -509,8 +561,11 @@ async def error():
 
 
 @pytest.mark.asyncio
-async def test_class_based_views(sentry_init, app, capture_events):
+async def test_class_based_views(sentry_init, capture_events):
+    from quart.views import View
+
     sentry_init(integrations=[quart_sentry.QuartIntegration()])
+    app = quart_app_factory()
     events = capture_events()
 
     @app.route("/")
@@ -534,27 +589,61 @@ async def dispatch_request(self):
 
 
 @pytest.mark.parametrize("endpoint", ["/sync/thread_ids", "/async/thread_ids"])
-async def test_active_thread_id(sentry_init, capture_envelopes, endpoint, app):
-    sentry_init(
-        traces_sample_rate=1.0,
-        _experiments={"profiles_sample_rate": 1.0},
-    )
+@pytest.mark.asyncio
+async def test_active_thread_id(
+    sentry_init, capture_envelopes, teardown_profiling, endpoint
+):
+    with mock.patch(
+        "sentry_sdk.profiler.transaction_profiler.PROFILE_MINIMUM_SAMPLES", 0
+    ):
+        sentry_init(
+            traces_sample_rate=1.0,
+            profiles_sample_rate=1.0,
+        )
+        app = quart_app_factory()
 
-    envelopes = capture_envelopes()
+        envelopes = capture_envelopes()
 
-    async with app.test_client() as client:
-        response = await client.get(endpoint)
-        assert response.status_code == 200
+        async with app.test_client() as client:
+            response = await client.get(endpoint)
+            assert response.status_code == 200
+
+        data = json.loads(await response.get_data(as_text=True))
 
-    data = json.loads(response.content)
+        envelopes = [envelope for envelope in envelopes]
+        assert len(envelopes) == 1
 
-    envelopes = [envelope for envelope in envelopes]
-    assert len(envelopes) == 1
+        profiles = [item for item in envelopes[0].items if item.type == "profile"]
+        assert len(profiles) == 1, envelopes[0].items
 
-    profiles = [item for item in envelopes[0].items if item.type == "profile"]
-    assert len(profiles) == 1
+        for item in profiles:
+            transactions = item.payload.json["transactions"]
+            assert len(transactions) == 1
+            assert str(data["active"]) == transactions[0]["active_thread_id"]
 
-    for profile in profiles:
-        transactions = profile.payload.json["transactions"]
+        transactions = [
+            item for item in envelopes[0].items if item.type == "transaction"
+        ]
         assert len(transactions) == 1
-        assert str(data["active"]) == transactions[0]["active_thread_id"]
+
+        for item in transactions:
+            transaction = item.payload.json
+            trace_context = transaction["contexts"]["trace"]
+            assert str(data["active"]) == trace_context["data"]["thread.id"]
+
+
+@pytest.mark.asyncio
+async def test_span_origin(sentry_init, capture_events):
+    sentry_init(
+        integrations=[quart_sentry.QuartIntegration()],
+        traces_sample_rate=1.0,
+    )
+    app = quart_app_factory()
+    events = capture_events()
+
+    client = app.test_client()
+    await client.get("/message")
+
+    (_, event) = events
+
+    assert event["contexts"]["trace"]["origin"] == "auto.http.quart"
diff --git a/tests/integrations/ray/__init__.py b/tests/integrations/ray/__init__.py
new file mode 100644
index 0000000000..92f6d93906
--- /dev/null
+++ b/tests/integrations/ray/__init__.py
@@ -0,0 +1,3 @@
+import pytest
+
+pytest.importorskip("ray")
diff --git a/tests/integrations/ray/test_ray.py b/tests/integrations/ray/test_ray.py
new file mode 100644
index 0000000000..dcbf8f456b
--- /dev/null
+++ b/tests/integrations/ray/test_ray.py
@@ -0,0 +1,279 @@
+import json
+import os
+import pytest
+import shutil
+import uuid
+
+import ray
+
+import sentry_sdk
+from sentry_sdk.envelope import Envelope
+from sentry_sdk.integrations.ray import RayIntegration
+from tests.conftest import TestTransport
+
+
+@pytest.fixture(autouse=True)
+def shutdown_ray(tmpdir):
+    yield
+    ray.shutdown()
+
+
+class RayTestTransport(TestTransport):
+    def __init__(self):
+        self.envelopes = []
+        super().__init__()
+
+    def capture_envelope(self, envelope: Envelope) -> None:
+        self.envelopes.append(envelope)
+
+
+class RayLoggingTransport(TestTransport):
+    def capture_envelope(self, envelope: Envelope) -> None:
+        print(envelope.serialize().decode("utf-8", "replace"))
+
+
+def setup_sentry_with_logging_transport():
+    setup_sentry(transport=RayLoggingTransport())
+
+
+def setup_sentry(transport=None):
+    sentry_sdk.init(
+        integrations=[RayIntegration()],
+        transport=RayTestTransport() if transport is None else transport,
+        traces_sample_rate=1.0,
+    )
+
+
+def read_error_from_log(job_id, ray_temp_dir):
+    # Find the actual session directory that Ray created
+    session_dirs = [d for d in os.listdir(ray_temp_dir) if d.startswith("session_")]
+    if not session_dirs:
+        raise FileNotFoundError(f"No session directory found in {ray_temp_dir}")
+
+    session_dir = os.path.join(ray_temp_dir, session_dirs[0])
+    log_dir = os.path.join(session_dir, "logs")
+
+    if not os.path.exists(log_dir):
+        raise FileNotFoundError(f"No logs directory found at {log_dir}")
+
+    log_file = [
+        f
+        for f in os.listdir(log_dir)
+        if "worker" in f and job_id in f and f.endswith(".out")
+    ][0]
+
+    with open(os.path.join(log_dir, log_file), "r") as file:
+        lines = file.readlines()
+
+        try:
+            # parse error object from log line
+            error = json.loads(lines[4][:-1])
+        except IndexError:
+            error = None
+
+    return error
+
+
+@pytest.mark.parametrize(
+    "task_options", [{}, {"num_cpus": 0, "memory": 1024 * 1024 * 10}]
+)
+def test_tracing_in_ray_tasks(task_options):
+    setup_sentry()
+
+    ray.init(
+        runtime_env={
+            "worker_process_setup_hook": setup_sentry,
+            "working_dir": "./",
+        }
+    )
+
+    def example_task():
+        with sentry_sdk.start_span(op="task", name="example task step"):
+            ...
+
+        return sentry_sdk.get_client().transport.envelopes
+
+    # Setup ray task, calling decorator directly instead of @,
+    # to accommodate for test parametrization
+    if task_options:
+        example_task = ray.remote(**task_options)(example_task)
+    else:
+        example_task = ray.remote(example_task)
+
+    # Function name shouldn't be overwritten by Sentry wrapper
+    assert example_task._function_name == "tests.integrations.ray.test_ray.example_task"
+
+    with sentry_sdk.start_transaction(op="task", name="ray test transaction"):
+        worker_envelopes = ray.get(example_task.remote())
+
+    client_envelope = sentry_sdk.get_client().transport.envelopes[0]
+    client_transaction = client_envelope.get_transaction_event()
+    assert client_transaction["transaction"] == "ray test transaction"
+    assert client_transaction["transaction_info"] == {"source": "custom"}
+
+    worker_envelope = worker_envelopes[0]
+    worker_transaction = worker_envelope.get_transaction_event()
+    assert (
+        worker_transaction["transaction"]
+        == "tests.integrations.ray.test_ray.test_tracing_in_ray_tasks..example_task"
+    )
+    assert worker_transaction["transaction_info"] == {"source": "task"}
+
+    (span,) = client_transaction["spans"]
+    assert span["op"] == "queue.submit.ray"
+    assert span["origin"] == "auto.queue.ray"
+    assert (
+        span["description"]
+        == "tests.integrations.ray.test_ray.test_tracing_in_ray_tasks..example_task"
+    )
+    assert span["parent_span_id"] == client_transaction["contexts"]["trace"]["span_id"]
+    assert span["trace_id"] == client_transaction["contexts"]["trace"]["trace_id"]
+
+    (span,) = worker_transaction["spans"]
+    assert span["op"] == "task"
+    assert span["origin"] == "manual"
+    assert span["description"] == "example task step"
+    assert span["parent_span_id"] == worker_transaction["contexts"]["trace"]["span_id"]
+    assert span["trace_id"] == worker_transaction["contexts"]["trace"]["trace_id"]
+
+    assert (
+        client_transaction["contexts"]["trace"]["trace_id"]
+        == worker_transaction["contexts"]["trace"]["trace_id"]
+    )
+
+
+def test_errors_in_ray_tasks():
+    setup_sentry_with_logging_transport()
+
+    ray_temp_dir = os.path.join("/tmp", f"ray_test_{uuid.uuid4().hex[:8]}")
+    os.makedirs(ray_temp_dir, exist_ok=True)
+
+    try:
+        ray.init(
+            runtime_env={
+                "worker_process_setup_hook": setup_sentry_with_logging_transport,
+                "working_dir": "./",
+            },
+            _temp_dir=ray_temp_dir,
+        )
+
+        # Setup ray task
+        @ray.remote
+        def example_task():
+            1 / 0
+
+        with sentry_sdk.start_transaction(op="task", name="ray test transaction"):
+            with pytest.raises(ZeroDivisionError):
+                future = example_task.remote()
+                ray.get(future)
+
+        job_id = future.job_id().hex()
+        error = read_error_from_log(job_id, ray_temp_dir)
+
+        assert error["level"] == "error"
+        assert (
+            error["transaction"]
+            == "tests.integrations.ray.test_ray.test_errors_in_ray_tasks..example_task"
+        )
+        assert error["exception"]["values"][0]["mechanism"]["type"] == "ray"
+        assert not error["exception"]["values"][0]["mechanism"]["handled"]
+
+    finally:
+        if os.path.exists(ray_temp_dir):
+            shutil.rmtree(ray_temp_dir, ignore_errors=True)
+
+
+# Arbitrary keyword argument to test all decorator paths
+@pytest.mark.parametrize("remote_kwargs", [{}, {"namespace": "actors"}])
+def test_tracing_in_ray_actors(remote_kwargs):
+    setup_sentry()
+
+    ray.init(
+        runtime_env={
+            "worker_process_setup_hook": setup_sentry,
+            "working_dir": "./",
+        }
+    )
+
+    # Setup ray actor
+    if remote_kwargs:
+
+        @ray.remote(**remote_kwargs)
+        class Counter:
+            def __init__(self):
+                self.n = 0
+
+            def increment(self):
+                with sentry_sdk.start_span(op="task", name="example actor execution"):
+                    self.n += 1
+
+                return sentry_sdk.get_client().transport.envelopes
+    else:
+
+        @ray.remote
+        class Counter:
+            def __init__(self):
+                self.n = 0
+
+            def increment(self):
+                with sentry_sdk.start_span(op="task", name="example actor execution"):
+                    self.n += 1
+
+                return sentry_sdk.get_client().transport.envelopes
+
+    with sentry_sdk.start_transaction(op="task", name="ray test transaction"):
+        counter = Counter.remote()
+        worker_envelopes = ray.get(counter.increment.remote())
+
+    client_envelope = sentry_sdk.get_client().transport.envelopes[0]
+    client_transaction = client_envelope.get_transaction_event()
+
+    # Spans for submitting the actor task are not created (actors are not supported yet)
+    assert client_transaction["spans"] == []
+
+    # Transaction are not yet created when executing ray actors (actors are not supported yet)
+    assert worker_envelopes == []
+
+
+def test_errors_in_ray_actors():
+    setup_sentry_with_logging_transport()
+
+    ray_temp_dir = os.path.join("/tmp", f"ray_test_{uuid.uuid4().hex[:8]}")
+    os.makedirs(ray_temp_dir, exist_ok=True)
+
+    try:
+        ray.init(
+            runtime_env={
+                "worker_process_setup_hook": setup_sentry_with_logging_transport,
+                "working_dir": "./",
+            },
+            _temp_dir=ray_temp_dir,
+        )
+
+        # Setup ray actor
+        @ray.remote
+        class Counter:
+            def __init__(self):
+                self.n = 0
+
+            def increment(self):
+                with sentry_sdk.start_span(op="task", name="example actor execution"):
+                    1 / 0
+
+                return sentry_sdk.get_client().transport.envelopes
+
+        with sentry_sdk.start_transaction(op="task", name="ray test transaction"):
+            with pytest.raises(ZeroDivisionError):
+                counter = Counter.remote()
+                future = counter.increment.remote()
+                ray.get(future)
+
+        job_id = future.job_id().hex()
+        error = read_error_from_log(job_id, ray_temp_dir)
+
+        # We do not capture errors in ray actors yet
+        assert error is None
+
+    finally:
+        if os.path.exists(ray_temp_dir):
+            shutil.rmtree(ray_temp_dir, ignore_errors=True)
diff --git a/tests/integrations/redis/asyncio/test_redis_asyncio.py b/tests/integrations/redis/asyncio/test_redis_asyncio.py
index 7233b8f908..17130b337b 100644
--- a/tests/integrations/redis/asyncio/test_redis_asyncio.py
+++ b/tests/integrations/redis/asyncio/test_redis_asyncio.py
@@ -3,6 +3,7 @@
 from sentry_sdk import capture_message, start_transaction
 from sentry_sdk.consts import SPANDATA
 from sentry_sdk.integrations.redis import RedisIntegration
+from tests.conftest import ApproxDict
 
 from fakeredis.aioredis import FakeRedis
 
@@ -64,19 +65,48 @@ async def test_async_redis_pipeline(
     (span,) = event["spans"]
     assert span["op"] == "db.redis"
     assert span["description"] == "redis.pipeline.execute"
-    assert span["data"] == {
-        "redis.commands": {
-            "count": 3,
-            "first_ten": expected_first_ten,
-        },
-        SPANDATA.DB_SYSTEM: "redis",
-        SPANDATA.DB_NAME: "0",
-        SPANDATA.SERVER_ADDRESS: connection.connection_pool.connection_kwargs.get(
-            "host"
-        ),
-        SPANDATA.SERVER_PORT: 6379,
-    }
+    assert span["data"] == ApproxDict(
+        {
+            "redis.commands": {
+                "count": 3,
+                "first_ten": expected_first_ten,
+            },
+            SPANDATA.DB_SYSTEM: "redis",
+            SPANDATA.DB_NAME: "0",
+            SPANDATA.SERVER_ADDRESS: connection.connection_pool.connection_kwargs.get(
+                "host"
+            ),
+            SPANDATA.SERVER_PORT: 6379,
+        }
+    )
     assert span["tags"] == {
         "redis.transaction": is_transaction,
         "redis.is_cluster": False,
     }
+
+
+@pytest.mark.asyncio
+async def test_async_span_origin(sentry_init, capture_events):
+    sentry_init(
+        integrations=[RedisIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    connection = FakeRedis()
+    with start_transaction(name="custom_transaction"):
+        # default case
+        await connection.set("somekey", "somevalue")
+
+        # pipeline
+        pipeline = connection.pipeline(transaction=False)
+        pipeline.get("somekey")
+        pipeline.set("anotherkey", 1)
+        await pipeline.execute()
+
+    (event,) = events
+
+    assert event["contexts"]["trace"]["origin"] == "manual"
+
+    for span in event["spans"]:
+        assert span["origin"] == "auto.db.redis"
diff --git a/tests/integrations/redis/cluster/__init__.py b/tests/integrations/redis/cluster/__init__.py
new file mode 100644
index 0000000000..008b24295f
--- /dev/null
+++ b/tests/integrations/redis/cluster/__init__.py
@@ -0,0 +1,3 @@
+import pytest
+
+pytest.importorskip("redis.cluster")
diff --git a/tests/integrations/redis/cluster/test_redis_cluster.py b/tests/integrations/redis/cluster/test_redis_cluster.py
new file mode 100644
index 0000000000..83d1b45cc9
--- /dev/null
+++ b/tests/integrations/redis/cluster/test_redis_cluster.py
@@ -0,0 +1,172 @@
+import pytest
+from sentry_sdk import capture_message
+from sentry_sdk.consts import SPANDATA
+from sentry_sdk.api import start_transaction
+from sentry_sdk.integrations.redis import RedisIntegration
+from tests.conftest import ApproxDict
+
+import redis
+
+
+@pytest.fixture(autouse=True)
+def monkeypatch_rediscluster_class(reset_integrations):
+    pipeline_cls = redis.cluster.ClusterPipeline
+    redis.cluster.NodesManager.initialize = lambda *_, **__: None
+    redis.RedisCluster.command = lambda *_: []
+    redis.RedisCluster.pipeline = lambda *_, **__: pipeline_cls(None, None)
+    redis.RedisCluster.get_default_node = lambda *_, **__: redis.cluster.ClusterNode(
+        "localhost", 6379
+    )
+    pipeline_cls.execute = lambda *_, **__: None
+    redis.RedisCluster.execute_command = lambda *_, **__: []
+
+
+def test_rediscluster_breadcrumb(sentry_init, capture_events):
+    sentry_init(integrations=[RedisIntegration()])
+    events = capture_events()
+
+    rc = redis.RedisCluster(host="localhost", port=6379)
+    rc.get("foobar")
+    capture_message("hi")
+
+    (event,) = events
+    crumbs = event["breadcrumbs"]["values"]
+
+    # on initializing a RedisCluster, a COMMAND call is made - this is not important for the test
+    # but must be accounted for
+    assert len(crumbs) in (1, 2)
+    assert len(crumbs) == 1 or crumbs[0]["message"] == "COMMAND"
+
+    crumb = crumbs[-1]
+
+    assert crumb == {
+        "category": "redis",
+        "message": "GET 'foobar'",
+        "data": {
+            "db.operation": "GET",
+            "redis.key": "foobar",
+            "redis.command": "GET",
+            "redis.is_cluster": True,
+        },
+        "timestamp": crumb["timestamp"],
+        "type": "redis",
+    }
+
+
+@pytest.mark.parametrize(
+    "send_default_pii, description",
+    [
+        (False, "SET 'bar' [Filtered]"),
+        (True, "SET 'bar' 1"),
+    ],
+)
+def test_rediscluster_basic(sentry_init, capture_events, send_default_pii, description):
+    sentry_init(
+        integrations=[RedisIntegration()],
+        traces_sample_rate=1.0,
+        send_default_pii=send_default_pii,
+    )
+    events = capture_events()
+
+    with start_transaction():
+        rc = redis.RedisCluster(host="localhost", port=6379)
+        rc.set("bar", 1)
+
+    (event,) = events
+    spans = event["spans"]
+
+    # on initializing a RedisCluster, a COMMAND call is made - this is not important for the test
+    # but must be accounted for
+    assert len(spans) in (1, 2)
+    assert len(spans) == 1 or spans[0]["description"] == "COMMAND"
+
+    span = spans[-1]
+    assert span["op"] == "db.redis"
+    assert span["description"] == description
+    assert span["data"] == ApproxDict(
+        {
+            SPANDATA.DB_SYSTEM: "redis",
+            # ClusterNode converts localhost to 127.0.0.1
+            SPANDATA.SERVER_ADDRESS: "127.0.0.1",
+            SPANDATA.SERVER_PORT: 6379,
+        }
+    )
+    assert span["tags"] == {
+        "db.operation": "SET",
+        "redis.command": "SET",
+        "redis.is_cluster": True,
+        "redis.key": "bar",
+    }
+
+
+@pytest.mark.parametrize(
+    "send_default_pii, expected_first_ten",
+    [
+        (False, ["GET 'foo'", "SET 'bar' [Filtered]", "SET 'baz' [Filtered]"]),
+        (True, ["GET 'foo'", "SET 'bar' 1", "SET 'baz' 2"]),
+    ],
+)
+def test_rediscluster_pipeline(
+    sentry_init, capture_events, send_default_pii, expected_first_ten
+):
+    sentry_init(
+        integrations=[RedisIntegration()],
+        traces_sample_rate=1.0,
+        send_default_pii=send_default_pii,
+    )
+    events = capture_events()
+
+    rc = redis.RedisCluster(host="localhost", port=6379)
+    with start_transaction():
+        pipeline = rc.pipeline()
+        pipeline.get("foo")
+        pipeline.set("bar", 1)
+        pipeline.set("baz", 2)
+        pipeline.execute()
+
+    (event,) = events
+    (span,) = event["spans"]
+    assert span["op"] == "db.redis"
+    assert span["description"] == "redis.pipeline.execute"
+    assert span["data"] == ApproxDict(
+        {
+            "redis.commands": {
+                "count": 3,
+                "first_ten": expected_first_ten,
+            },
+            SPANDATA.DB_SYSTEM: "redis",
+            # ClusterNode converts localhost to 127.0.0.1
+            SPANDATA.SERVER_ADDRESS: "127.0.0.1",
+            SPANDATA.SERVER_PORT: 6379,
+        }
+    )
+    assert span["tags"] == {
+        "redis.transaction": False,  # For Cluster, this is always False
+        "redis.is_cluster": True,
+    }
+
+
+def test_rediscluster_span_origin(sentry_init, capture_events):
+    sentry_init(
+        integrations=[RedisIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    rc = redis.RedisCluster(host="localhost", port=6379)
+    with start_transaction(name="custom_transaction"):
+        # default case
+        rc.set("somekey", "somevalue")
+
+        # pipeline
+        pipeline = rc.pipeline(transaction=False)
+        pipeline.get("somekey")
+        pipeline.set("anotherkey", 1)
+        pipeline.execute()
+
+    (event,) = events
+
+    assert event["contexts"]["trace"]["origin"] == "manual"
+
+    for span in event["spans"]:
+        assert span["origin"] == "auto.db.redis"
diff --git a/tests/integrations/redis/cluster_asyncio/__init__.py b/tests/integrations/redis/cluster_asyncio/__init__.py
new file mode 100644
index 0000000000..663979a4e2
--- /dev/null
+++ b/tests/integrations/redis/cluster_asyncio/__init__.py
@@ -0,0 +1,3 @@
+import pytest
+
+pytest.importorskip("redis.asyncio.cluster")
diff --git a/tests/integrations/redis/cluster_asyncio/test_redis_cluster_asyncio.py b/tests/integrations/redis/cluster_asyncio/test_redis_cluster_asyncio.py
new file mode 100644
index 0000000000..993a2962ca
--- /dev/null
+++ b/tests/integrations/redis/cluster_asyncio/test_redis_cluster_asyncio.py
@@ -0,0 +1,176 @@
+import pytest
+
+from sentry_sdk import capture_message, start_transaction
+from sentry_sdk.consts import SPANDATA
+from sentry_sdk.integrations.redis import RedisIntegration
+from tests.conftest import ApproxDict
+
+from redis.asyncio import cluster
+
+
+async def fake_initialize(*_, **__):
+    return None
+
+
+async def fake_execute_command(*_, **__):
+    return []
+
+
+async def fake_execute(*_, **__):
+    return None
+
+
+@pytest.fixture(autouse=True)
+def monkeypatch_rediscluster_asyncio_class(reset_integrations):
+    pipeline_cls = cluster.ClusterPipeline
+    cluster.NodesManager.initialize = fake_initialize
+    cluster.RedisCluster.get_default_node = lambda *_, **__: cluster.ClusterNode(
+        "localhost", 6379
+    )
+    cluster.RedisCluster.pipeline = lambda self, *_, **__: pipeline_cls(self)
+    pipeline_cls.execute = fake_execute
+    cluster.RedisCluster.execute_command = fake_execute_command
+
+
+@pytest.mark.asyncio
+async def test_async_breadcrumb(sentry_init, capture_events):
+    sentry_init(integrations=[RedisIntegration()])
+    events = capture_events()
+
+    connection = cluster.RedisCluster(host="localhost", port=6379)
+
+    await connection.get("foobar")
+    capture_message("hi")
+
+    (event,) = events
+    (crumb,) = event["breadcrumbs"]["values"]
+
+    assert crumb == {
+        "category": "redis",
+        "message": "GET 'foobar'",
+        "data": ApproxDict(
+            {
+                "db.operation": "GET",
+                "redis.key": "foobar",
+                "redis.command": "GET",
+                "redis.is_cluster": True,
+            }
+        ),
+        "timestamp": crumb["timestamp"],
+        "type": "redis",
+    }
+
+
+@pytest.mark.parametrize(
+    "send_default_pii, description",
+    [
+        (False, "SET 'bar' [Filtered]"),
+        (True, "SET 'bar' 1"),
+    ],
+)
+@pytest.mark.asyncio
+async def test_async_basic(sentry_init, capture_events, send_default_pii, description):
+    sentry_init(
+        integrations=[RedisIntegration()],
+        traces_sample_rate=1.0,
+        send_default_pii=send_default_pii,
+    )
+    events = capture_events()
+
+    connection = cluster.RedisCluster(host="localhost", port=6379)
+    with start_transaction():
+        await connection.set("bar", 1)
+
+    (event,) = events
+    (span,) = event["spans"]
+    assert span["op"] == "db.redis"
+    assert span["description"] == description
+    assert span["data"] == ApproxDict(
+        {
+            SPANDATA.DB_SYSTEM: "redis",
+            # ClusterNode converts localhost to 127.0.0.1
+            SPANDATA.SERVER_ADDRESS: "127.0.0.1",
+            SPANDATA.SERVER_PORT: 6379,
+        }
+    )
+    assert span["tags"] == {
+        "redis.is_cluster": True,
+        "db.operation": "SET",
+        "redis.command": "SET",
+        "redis.key": "bar",
+    }
+
+
+@pytest.mark.parametrize(
+    "send_default_pii, expected_first_ten",
+    [
+        (False, ["GET 'foo'", "SET 'bar' [Filtered]", "SET 'baz' [Filtered]"]),
+        (True, ["GET 'foo'", "SET 'bar' 1", "SET 'baz' 2"]),
+    ],
+)
+@pytest.mark.asyncio
+async def test_async_redis_pipeline(
+    sentry_init, capture_events, send_default_pii, expected_first_ten
+):
+    sentry_init(
+        integrations=[RedisIntegration()],
+        traces_sample_rate=1.0,
+        send_default_pii=send_default_pii,
+    )
+    events = capture_events()
+
+    connection = cluster.RedisCluster(host="localhost", port=6379)
+    with start_transaction():
+        pipeline = connection.pipeline()
+        pipeline.get("foo")
+        pipeline.set("bar", 1)
+        pipeline.set("baz", 2)
+        await pipeline.execute()
+
+    (event,) = events
+    (span,) = event["spans"]
+    assert span["op"] == "db.redis"
+    assert span["description"] == "redis.pipeline.execute"
+    assert span["data"] == ApproxDict(
+        {
+            "redis.commands": {
+                "count": 3,
+                "first_ten": expected_first_ten,
+            },
+            SPANDATA.DB_SYSTEM: "redis",
+            # ClusterNode converts localhost to 127.0.0.1
+            SPANDATA.SERVER_ADDRESS: "127.0.0.1",
+            SPANDATA.SERVER_PORT: 6379,
+        }
+    )
+    assert span["tags"] == {
+        "redis.transaction": False,
+        "redis.is_cluster": True,
+    }
+
+
+@pytest.mark.asyncio
+async def test_async_span_origin(sentry_init, capture_events):
+    sentry_init(
+        integrations=[RedisIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    connection = cluster.RedisCluster(host="localhost", port=6379)
+    with start_transaction(name="custom_transaction"):
+        # default case
+        await connection.set("somekey", "somevalue")
+
+        # pipeline
+        pipeline = connection.pipeline(transaction=False)
+        pipeline.get("somekey")
+        pipeline.set("anotherkey", 1)
+        await pipeline.execute()
+
+    (event,) = events
+
+    assert event["contexts"]["trace"]["origin"] == "manual"
+
+    for span in event["spans"]:
+        assert span["origin"] == "auto.db.redis"
diff --git a/tests/integrations/redis/test_redis.py b/tests/integrations/redis/test_redis.py
index d25e630f6a..1861e7116f 100644
--- a/tests/integrations/redis/test_redis.py
+++ b/tests/integrations/redis/test_redis.py
@@ -1,16 +1,12 @@
+from unittest import mock
+
 import pytest
+from fakeredis import FakeStrictRedis
 
 from sentry_sdk import capture_message, start_transaction
 from sentry_sdk.consts import SPANDATA
 from sentry_sdk.integrations.redis import RedisIntegration
 
-from fakeredis import FakeStrictRedis
-
-try:
-    from unittest import mock  # python 3.3 and above
-except ImportError:
-    import mock  # python < 3.3
-
 
 MOCK_CONNECTION_POOL = mock.MagicMock()
 MOCK_CONNECTION_POOL.connection_kwargs = {
@@ -89,7 +85,8 @@ def test_redis_pipeline(
 def test_sensitive_data(sentry_init, capture_events):
     # fakeredis does not support the AUTH command, so we need to mock it
     with mock.patch(
-        "sentry_sdk.integrations.redis._COMMANDS_INCLUDING_SENSITIVE_DATA", ["get"]
+        "sentry_sdk.integrations.redis.utils._COMMANDS_INCLUDING_SENSITIVE_DATA",
+        ["get"],
     ):
         sentry_init(
             integrations=[RedisIntegration()],
@@ -157,7 +154,7 @@ def test_pii_data_sent(sentry_init, capture_events):
     assert spans[3]["description"] == "DEL 'somekey1' 'somekey2'"
 
 
-def test_data_truncation(sentry_init, capture_events):
+def test_no_data_truncation_by_default(sentry_init, capture_events):
     sentry_init(
         integrations=[RedisIntegration()],
         traces_sample_rate=1.0,
@@ -175,10 +172,8 @@ def test_data_truncation(sentry_init, capture_events):
     (event,) = events
     spans = event["spans"]
     assert spans[0]["op"] == "db.redis"
-    assert spans[0]["description"] == "SET 'somekey1' '%s..." % (
-        long_string[: 1024 - len("...") - len("SET 'somekey1' '")],
-    )
-    assert spans[1]["description"] == "SET 'somekey2' '%s'" % (short_string,)
+    assert spans[0]["description"] == f"SET 'somekey1' '{long_string}'"
+    assert spans[1]["description"] == f"SET 'somekey2' '{short_string}'"
 
 
 def test_data_truncation_custom(sentry_init, capture_events):
@@ -296,3 +291,29 @@ def test_db_connection_attributes_pipeline(sentry_init, capture_events):
     assert span["data"][SPANDATA.DB_NAME] == "1"
     assert span["data"][SPANDATA.SERVER_ADDRESS] == "localhost"
     assert span["data"][SPANDATA.SERVER_PORT] == 63791
+
+
+def test_span_origin(sentry_init, capture_events):
+    sentry_init(
+        integrations=[RedisIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    connection = FakeStrictRedis()
+    with start_transaction(name="custom_transaction"):
+        # default case
+        connection.set("somekey", "somevalue")
+
+        # pipeline
+        pipeline = connection.pipeline(transaction=False)
+        pipeline.get("somekey")
+        pipeline.set("anotherkey", 1)
+        pipeline.execute()
+
+    (event,) = events
+
+    assert event["contexts"]["trace"]["origin"] == "manual"
+
+    for span in event["spans"]:
+        assert span["origin"] == "auto.db.redis"
diff --git a/tests/integrations/redis/test_redis_cache_module.py b/tests/integrations/redis/test_redis_cache_module.py
new file mode 100644
index 0000000000..f118aa53f5
--- /dev/null
+++ b/tests/integrations/redis/test_redis_cache_module.py
@@ -0,0 +1,318 @@
+import uuid
+
+import pytest
+
+import fakeredis
+from fakeredis import FakeStrictRedis
+
+from sentry_sdk.integrations.redis import RedisIntegration
+from sentry_sdk.integrations.redis.utils import _get_safe_key, _key_as_string
+from sentry_sdk.utils import parse_version
+import sentry_sdk
+
+
+FAKEREDIS_VERSION = parse_version(fakeredis.__version__)
+
+
+def test_no_cache_basic(sentry_init, capture_events):
+    sentry_init(
+        integrations=[
+            RedisIntegration(),
+        ],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    connection = FakeStrictRedis()
+    with sentry_sdk.start_transaction():
+        connection.get("mycachekey")
+
+    (event,) = events
+    spans = event["spans"]
+    assert len(spans) == 1
+    assert spans[0]["op"] == "db.redis"
+
+
+def test_cache_basic(sentry_init, capture_events):
+    sentry_init(
+        integrations=[
+            RedisIntegration(
+                cache_prefixes=["mycache"],
+            ),
+        ],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    connection = FakeStrictRedis()
+    with sentry_sdk.start_transaction():
+        connection.hget("mycachekey", "myfield")
+        connection.get("mycachekey")
+        connection.set("mycachekey1", "bla")
+        connection.setex("mycachekey2", 10, "blub")
+        connection.mget("mycachekey1", "mycachekey2")
+
+    (event,) = events
+    spans = event["spans"]
+    assert len(spans) == 9
+
+    # no cache support for hget command
+    assert spans[0]["op"] == "db.redis"
+    assert spans[0]["tags"]["redis.command"] == "HGET"
+
+    assert spans[1]["op"] == "cache.get"
+    assert spans[2]["op"] == "db.redis"
+    assert spans[2]["tags"]["redis.command"] == "GET"
+
+    assert spans[3]["op"] == "cache.put"
+    assert spans[4]["op"] == "db.redis"
+    assert spans[4]["tags"]["redis.command"] == "SET"
+
+    assert spans[5]["op"] == "cache.put"
+    assert spans[6]["op"] == "db.redis"
+    assert spans[6]["tags"]["redis.command"] == "SETEX"
+
+    assert spans[7]["op"] == "cache.get"
+    assert spans[8]["op"] == "db.redis"
+    assert spans[8]["tags"]["redis.command"] == "MGET"
+
+
+def test_cache_keys(sentry_init, capture_events):
+    sentry_init(
+        integrations=[
+            RedisIntegration(
+                cache_prefixes=["bla", "blub"],
+            ),
+        ],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    connection = FakeStrictRedis()
+    with sentry_sdk.start_transaction():
+        connection.get("somethingelse")
+        connection.get("blub")
+        connection.get("blubkeything")
+        connection.get("bl")
+
+    (event,) = events
+    spans = event["spans"]
+    assert len(spans) == 6
+    assert spans[0]["op"] == "db.redis"
+    assert spans[0]["description"] == "GET 'somethingelse'"
+
+    assert spans[1]["op"] == "cache.get"
+    assert spans[1]["description"] == "blub"
+    assert spans[2]["op"] == "db.redis"
+    assert spans[2]["description"] == "GET 'blub'"
+
+    assert spans[3]["op"] == "cache.get"
+    assert spans[3]["description"] == "blubkeything"
+    assert spans[4]["op"] == "db.redis"
+    assert spans[4]["description"] == "GET 'blubkeything'"
+
+    assert spans[5]["op"] == "db.redis"
+    assert spans[5]["description"] == "GET 'bl'"
+
+
+def test_cache_data(sentry_init, capture_events):
+    sentry_init(
+        integrations=[
+            RedisIntegration(
+                cache_prefixes=["mycache"],
+            ),
+        ],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    connection = FakeStrictRedis(host="mycacheserver.io", port=6378)
+    with sentry_sdk.start_transaction():
+        connection.get("mycachekey")
+        connection.set("mycachekey", "事实胜于雄辩")
+        connection.get("mycachekey")
+
+    (event,) = events
+    spans = event["spans"]
+
+    assert len(spans) == 6
+
+    assert spans[0]["op"] == "cache.get"
+    assert spans[0]["description"] == "mycachekey"
+    assert spans[0]["data"]["cache.key"] == [
+        "mycachekey",
+    ]
+    assert spans[0]["data"]["cache.hit"] == False  # noqa: E712
+    assert "cache.item_size" not in spans[0]["data"]
+    # very old fakeredis can not handle port and/or host.
+    # only applicable for Redis v3
+    if FAKEREDIS_VERSION <= (2, 7, 1):
+        assert "network.peer.port" not in spans[0]["data"]
+    else:
+        assert spans[0]["data"]["network.peer.port"] == 6378
+    if FAKEREDIS_VERSION <= (1, 7, 1):
+        assert "network.peer.address" not in spans[0]["data"]
+    else:
+        assert spans[0]["data"]["network.peer.address"] == "mycacheserver.io"
+
+    assert spans[1]["op"] == "db.redis"  # we ignore db spans in this test.
+
+    assert spans[2]["op"] == "cache.put"
+    assert spans[2]["description"] == "mycachekey"
+    assert spans[2]["data"]["cache.key"] == [
+        "mycachekey",
+    ]
+    assert "cache.hit" not in spans[1]["data"]
+    assert spans[2]["data"]["cache.item_size"] == 18
+    # very old fakeredis can not handle port.
+    # only used with redis v3
+    if FAKEREDIS_VERSION <= (2, 7, 1):
+        assert "network.peer.port" not in spans[2]["data"]
+    else:
+        assert spans[2]["data"]["network.peer.port"] == 6378
+    if FAKEREDIS_VERSION <= (1, 7, 1):
+        assert "network.peer.address" not in spans[2]["data"]
+    else:
+        assert spans[2]["data"]["network.peer.address"] == "mycacheserver.io"
+
+    assert spans[3]["op"] == "db.redis"  # we ignore db spans in this test.
+
+    assert spans[4]["op"] == "cache.get"
+    assert spans[4]["description"] == "mycachekey"
+    assert spans[4]["data"]["cache.key"] == [
+        "mycachekey",
+    ]
+    assert spans[4]["data"]["cache.hit"] == True  # noqa: E712
+    assert spans[4]["data"]["cache.item_size"] == 18
+    # very old fakeredis can not handle port.
+    # only used with redis v3
+    if FAKEREDIS_VERSION <= (2, 7, 1):
+        assert "network.peer.port" not in spans[4]["data"]
+    else:
+        assert spans[4]["data"]["network.peer.port"] == 6378
+    if FAKEREDIS_VERSION <= (1, 7, 1):
+        assert "network.peer.address" not in spans[4]["data"]
+    else:
+        assert spans[4]["data"]["network.peer.address"] == "mycacheserver.io"
+
+    assert spans[5]["op"] == "db.redis"  # we ignore db spans in this test.
+
+
+def test_cache_prefixes(sentry_init, capture_events):
+    sentry_init(
+        integrations=[
+            RedisIntegration(
+                cache_prefixes=["yes"],
+            ),
+        ],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    connection = FakeStrictRedis()
+    with sentry_sdk.start_transaction():
+        connection.mget("yes", "no")
+        connection.mget("no", 1, "yes")
+        connection.mget("no", "yes.1", "yes.2")
+        connection.mget("no.1", "no.2", "no.3")
+        connection.mget("no.1", "no.2", "no.actually.yes")
+        connection.mget(b"no.3", b"yes.5")
+        connection.mget(uuid.uuid4().bytes)
+        connection.mget(uuid.uuid4().bytes, "yes")
+
+    (event,) = events
+
+    spans = event["spans"]
+    assert len(spans) == 13  # 8 db spans + 5 cache spans
+
+    cache_spans = [span for span in spans if span["op"] == "cache.get"]
+    assert len(cache_spans) == 5
+
+    assert cache_spans[0]["description"] == "yes, no"
+    assert cache_spans[1]["description"] == "no, 1, yes"
+    assert cache_spans[2]["description"] == "no, yes.1, yes.2"
+    assert cache_spans[3]["description"] == "no.3, yes.5"
+    assert cache_spans[4]["description"] == ", yes"
+
+
+@pytest.mark.parametrize(
+    "method_name,args,kwargs,expected_key",
+    [
+        (None, None, None, None),
+        ("", None, None, None),
+        ("set", ["bla", "valuebla"], None, ("bla",)),
+        ("setex", ["bla", 10, "valuebla"], None, ("bla",)),
+        ("get", ["bla"], None, ("bla",)),
+        ("mget", ["bla", "blub", "foo"], None, ("bla", "blub", "foo")),
+        ("set", [b"bla", "valuebla"], None, (b"bla",)),
+        ("setex", [b"bla", 10, "valuebla"], None, (b"bla",)),
+        ("get", [b"bla"], None, (b"bla",)),
+        ("mget", [b"bla", "blub", "foo"], None, (b"bla", "blub", "foo")),
+        ("not-important", None, {"something": "bla"}, None),
+        ("not-important", None, {"key": None}, None),
+        ("not-important", None, {"key": "bla"}, ("bla",)),
+        ("not-important", None, {"key": b"bla"}, (b"bla",)),
+        ("not-important", None, {"key": []}, None),
+        (
+            "not-important",
+            None,
+            {
+                "key": [
+                    "bla",
+                ]
+            },
+            ("bla",),
+        ),
+        (
+            "not-important",
+            None,
+            {"key": [b"bla", "blub", "foo"]},
+            (b"bla", "blub", "foo"),
+        ),
+        (
+            "not-important",
+            None,
+            {"key": b"\x00c\x0f\xeaC\xe1L\x1c\xbff\xcb\xcc\xc1\xed\xc6\t"},
+            (b"\x00c\x0f\xeaC\xe1L\x1c\xbff\xcb\xcc\xc1\xed\xc6\t",),
+        ),
+        (
+            "get",
+            [b"\x00c\x0f\xeaC\xe1L\x1c\xbff\xcb\xcc\xc1\xed\xc6\t"],
+            None,
+            (b"\x00c\x0f\xeaC\xe1L\x1c\xbff\xcb\xcc\xc1\xed\xc6\t",),
+        ),
+        (
+            "get",
+            [123],
+            None,
+            (123,),
+        ),
+    ],
+)
+def test_get_safe_key(method_name, args, kwargs, expected_key):
+    assert _get_safe_key(method_name, args, kwargs) == expected_key
+
+
+@pytest.mark.parametrize(
+    "key,expected_key",
+    [
+        (None, ""),
+        (("bla",), "bla"),
+        (("bla", "blub", "foo"), "bla, blub, foo"),
+        ((b"bla",), "bla"),
+        ((b"bla", "blub", "foo"), "bla, blub, foo"),
+        (
+            [
+                "bla",
+            ],
+            "bla",
+        ),
+        (["bla", "blub", "foo"], "bla, blub, foo"),
+        ([uuid.uuid4().bytes], ""),
+        ({"key1": 1, "key2": 2}, "key1, key2"),
+        (1, "1"),
+        ([1, 2, 3, b"hello"], "1, 2, 3, hello"),
+    ],
+)
+def test_key_as_string(key, expected_key):
+    assert _key_as_string(key) == expected_key
diff --git a/tests/integrations/redis/test_redis_cache_module_async.py b/tests/integrations/redis/test_redis_cache_module_async.py
new file mode 100644
index 0000000000..d607f92fbd
--- /dev/null
+++ b/tests/integrations/redis/test_redis_cache_module_async.py
@@ -0,0 +1,187 @@
+import pytest
+
+try:
+    import fakeredis
+    from fakeredis.aioredis import FakeRedis as FakeRedisAsync
+except ModuleNotFoundError:
+    FakeRedisAsync = None
+
+if FakeRedisAsync is None:
+    pytest.skip(
+        "Skipping tests because fakeredis.aioredis not available",
+        allow_module_level=True,
+    )
+
+from sentry_sdk.integrations.redis import RedisIntegration
+from sentry_sdk.utils import parse_version
+import sentry_sdk
+
+
+FAKEREDIS_VERSION = parse_version(fakeredis.__version__)
+
+
+@pytest.mark.asyncio
+async def test_no_cache_basic(sentry_init, capture_events):
+    sentry_init(
+        integrations=[
+            RedisIntegration(),
+        ],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    connection = FakeRedisAsync()
+    with sentry_sdk.start_transaction():
+        await connection.get("myasynccachekey")
+
+    (event,) = events
+    spans = event["spans"]
+    assert len(spans) == 1
+    assert spans[0]["op"] == "db.redis"
+
+
+@pytest.mark.asyncio
+async def test_cache_basic(sentry_init, capture_events):
+    sentry_init(
+        integrations=[
+            RedisIntegration(
+                cache_prefixes=["myasynccache"],
+            ),
+        ],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    connection = FakeRedisAsync()
+    with sentry_sdk.start_transaction():
+        await connection.get("myasynccachekey")
+
+    (event,) = events
+    spans = event["spans"]
+    assert len(spans) == 2
+
+    assert spans[0]["op"] == "cache.get"
+    assert spans[1]["op"] == "db.redis"
+
+
+@pytest.mark.asyncio
+async def test_cache_keys(sentry_init, capture_events):
+    sentry_init(
+        integrations=[
+            RedisIntegration(
+                cache_prefixes=["abla", "ablub"],
+            ),
+        ],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    connection = FakeRedisAsync()
+    with sentry_sdk.start_transaction():
+        await connection.get("asomethingelse")
+        await connection.get("ablub")
+        await connection.get("ablubkeything")
+        await connection.get("abl")
+
+    (event,) = events
+    spans = event["spans"]
+    assert len(spans) == 6
+    assert spans[0]["op"] == "db.redis"
+    assert spans[0]["description"] == "GET 'asomethingelse'"
+
+    assert spans[1]["op"] == "cache.get"
+    assert spans[1]["description"] == "ablub"
+    assert spans[2]["op"] == "db.redis"
+    assert spans[2]["description"] == "GET 'ablub'"
+
+    assert spans[3]["op"] == "cache.get"
+    assert spans[3]["description"] == "ablubkeything"
+    assert spans[4]["op"] == "db.redis"
+    assert spans[4]["description"] == "GET 'ablubkeything'"
+
+    assert spans[5]["op"] == "db.redis"
+    assert spans[5]["description"] == "GET 'abl'"
+
+
+@pytest.mark.asyncio
+async def test_cache_data(sentry_init, capture_events):
+    sentry_init(
+        integrations=[
+            RedisIntegration(
+                cache_prefixes=["myasynccache"],
+            ),
+        ],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    connection = FakeRedisAsync(host="mycacheserver.io", port=6378)
+    with sentry_sdk.start_transaction():
+        await connection.get("myasynccachekey")
+        await connection.set("myasynccachekey", "事实胜于雄辩")
+        await connection.get("myasynccachekey")
+
+    (event,) = events
+    spans = event["spans"]
+
+    assert len(spans) == 6
+
+    assert spans[0]["op"] == "cache.get"
+    assert spans[0]["description"] == "myasynccachekey"
+    assert spans[0]["data"]["cache.key"] == [
+        "myasynccachekey",
+    ]
+    assert spans[0]["data"]["cache.hit"] == False  # noqa: E712
+    assert "cache.item_size" not in spans[0]["data"]
+    # very old fakeredis can not handle port and/or host.
+    # only applicable for Redis v3
+    if FAKEREDIS_VERSION <= (2, 7, 1):
+        assert "network.peer.port" not in spans[0]["data"]
+    else:
+        assert spans[0]["data"]["network.peer.port"] == 6378
+    if FAKEREDIS_VERSION <= (1, 7, 1):
+        assert "network.peer.address" not in spans[0]["data"]
+    else:
+        assert spans[0]["data"]["network.peer.address"] == "mycacheserver.io"
+
+    assert spans[1]["op"] == "db.redis"  # we ignore db spans in this test.
+
+    assert spans[2]["op"] == "cache.put"
+    assert spans[2]["description"] == "myasynccachekey"
+    assert spans[2]["data"]["cache.key"] == [
+        "myasynccachekey",
+    ]
+    assert "cache.hit" not in spans[1]["data"]
+    assert spans[2]["data"]["cache.item_size"] == 18
+    # very old fakeredis can not handle port.
+    # only used with redis v3
+    if FAKEREDIS_VERSION <= (2, 7, 1):
+        assert "network.peer.port" not in spans[2]["data"]
+    else:
+        assert spans[2]["data"]["network.peer.port"] == 6378
+    if FAKEREDIS_VERSION <= (1, 7, 1):
+        assert "network.peer.address" not in spans[2]["data"]
+    else:
+        assert spans[2]["data"]["network.peer.address"] == "mycacheserver.io"
+
+    assert spans[3]["op"] == "db.redis"  # we ignore db spans in this test.
+
+    assert spans[4]["op"] == "cache.get"
+    assert spans[4]["description"] == "myasynccachekey"
+    assert spans[4]["data"]["cache.key"] == [
+        "myasynccachekey",
+    ]
+    assert spans[4]["data"]["cache.hit"] == True  # noqa: E712
+    assert spans[4]["data"]["cache.item_size"] == 18
+    # very old fakeredis can not handle port.
+    # only used with redis v3
+    if FAKEREDIS_VERSION <= (2, 7, 1):
+        assert "network.peer.port" not in spans[4]["data"]
+    else:
+        assert spans[4]["data"]["network.peer.port"] == 6378
+    if FAKEREDIS_VERSION <= (1, 7, 1):
+        assert "network.peer.address" not in spans[4]["data"]
+    else:
+        assert spans[4]["data"]["network.peer.address"] == "mycacheserver.io"
+
+    assert spans[5]["op"] == "db.redis"  # we ignore db spans in this test.
diff --git a/tests/integrations/rediscluster/__init__.py b/tests/integrations/redis_py_cluster_legacy/__init__.py
similarity index 100%
rename from tests/integrations/rediscluster/__init__.py
rename to tests/integrations/redis_py_cluster_legacy/__init__.py
diff --git a/tests/integrations/rediscluster/test_rediscluster.py b/tests/integrations/redis_py_cluster_legacy/test_redis_py_cluster_legacy.py
similarity index 75%
rename from tests/integrations/rediscluster/test_rediscluster.py
rename to tests/integrations/redis_py_cluster_legacy/test_redis_py_cluster_legacy.py
index 14d831a647..36a27d569d 100644
--- a/tests/integrations/rediscluster/test_rediscluster.py
+++ b/tests/integrations/redis_py_cluster_legacy/test_redis_py_cluster_legacy.py
@@ -1,16 +1,13 @@
+from unittest import mock
+
 import pytest
+import rediscluster
 
 from sentry_sdk import capture_message
 from sentry_sdk.api import start_transaction
 from sentry_sdk.consts import SPANDATA
 from sentry_sdk.integrations.redis import RedisIntegration
-
-try:
-    from unittest import mock
-except ImportError:
-    import mock
-
-import rediscluster
+from tests.conftest import ApproxDict
 
 
 MOCK_CONNECTION_POOL = mock.MagicMock()
@@ -56,12 +53,14 @@ def test_rediscluster_basic(rediscluster_cls, sentry_init, capture_events):
     assert crumb == {
         "category": "redis",
         "message": "GET 'foobar'",
-        "data": {
-            "db.operation": "GET",
-            "redis.key": "foobar",
-            "redis.command": "GET",
-            "redis.is_cluster": True,
-        },
+        "data": ApproxDict(
+            {
+                "db.operation": "GET",
+                "redis.key": "foobar",
+                "redis.command": "GET",
+                "redis.is_cluster": True,
+            }
+        ),
         "timestamp": crumb["timestamp"],
         "type": "redis",
     }
@@ -96,16 +95,18 @@ def test_rediscluster_pipeline(
     (span,) = event["spans"]
     assert span["op"] == "db.redis"
     assert span["description"] == "redis.pipeline.execute"
-    assert span["data"] == {
-        "redis.commands": {
-            "count": 3,
-            "first_ten": expected_first_ten,
-        },
-        SPANDATA.DB_SYSTEM: "redis",
-        SPANDATA.DB_NAME: "1",
-        SPANDATA.SERVER_ADDRESS: "localhost",
-        SPANDATA.SERVER_PORT: 63791,
-    }
+    assert span["data"] == ApproxDict(
+        {
+            "redis.commands": {
+                "count": 3,
+                "first_ten": expected_first_ten,
+            },
+            SPANDATA.DB_SYSTEM: "redis",
+            SPANDATA.DB_NAME: "1",
+            SPANDATA.SERVER_ADDRESS: "localhost",
+            SPANDATA.SERVER_PORT: 63791,
+        }
+    )
     assert span["tags"] == {
         "redis.transaction": False,  # For Cluster, this is always False
         "redis.is_cluster": True,
@@ -127,12 +128,14 @@ def test_db_connection_attributes_client(sentry_init, capture_events, redisclust
     (event,) = events
     (span,) = event["spans"]
 
-    assert span["data"] == {
-        SPANDATA.DB_SYSTEM: "redis",
-        SPANDATA.DB_NAME: "1",
-        SPANDATA.SERVER_ADDRESS: "localhost",
-        SPANDATA.SERVER_PORT: 63791,
-    }
+    assert span["data"] == ApproxDict(
+        {
+            SPANDATA.DB_SYSTEM: "redis",
+            SPANDATA.DB_NAME: "1",
+            SPANDATA.SERVER_ADDRESS: "localhost",
+            SPANDATA.SERVER_PORT: 63791,
+        }
+    )
 
 
 @pytest.mark.parametrize("rediscluster_cls", rediscluster_classes)
@@ -155,13 +158,15 @@ def test_db_connection_attributes_pipeline(
     (span,) = event["spans"]
     assert span["op"] == "db.redis"
     assert span["description"] == "redis.pipeline.execute"
-    assert span["data"] == {
-        "redis.commands": {
-            "count": 1,
-            "first_ten": ["GET 'foo'"],
-        },
-        SPANDATA.DB_SYSTEM: "redis",
-        SPANDATA.DB_NAME: "1",
-        SPANDATA.SERVER_ADDRESS: "localhost",
-        SPANDATA.SERVER_PORT: 63791,
-    }
+    assert span["data"] == ApproxDict(
+        {
+            "redis.commands": {
+                "count": 1,
+                "first_ten": ["GET 'foo'"],
+            },
+            SPANDATA.DB_SYSTEM: "redis",
+            SPANDATA.DB_NAME: "1",
+            SPANDATA.SERVER_ADDRESS: "localhost",
+            SPANDATA.SERVER_PORT: 63791,
+        }
+    )
diff --git a/tests/integrations/requests/test_requests.py b/tests/integrations/requests/test_requests.py
index ed5b273712..8cfc0f932f 100644
--- a/tests/integrations/requests/test_requests.py
+++ b/tests/integrations/requests/test_requests.py
@@ -1,52 +1,97 @@
-import requests
-import responses
+import sys
+from unittest import mock
 
 import pytest
+import requests
 
 from sentry_sdk import capture_message
 from sentry_sdk.consts import SPANDATA
 from sentry_sdk.integrations.stdlib import StdlibIntegration
+from tests.conftest import ApproxDict, create_mock_http_server
 
-try:
-    from unittest import mock  # python 3.3 and above
-except ImportError:
-    import mock  # python < 3.3
+PORT = create_mock_http_server()
 
 
 def test_crumb_capture(sentry_init, capture_events):
     sentry_init(integrations=[StdlibIntegration()])
+    events = capture_events()
 
-    url = "http://example.com/"
-    responses.add(responses.GET, url, status=200)
+    url = f"http://localhost:{PORT}/hello-world"  # noqa:E231
+    response = requests.get(url)
+    capture_message("Testing!")
+
+    (event,) = events
+    (crumb,) = event["breadcrumbs"]["values"]
+    assert crumb["type"] == "http"
+    assert crumb["category"] == "httplib"
+    assert crumb["data"] == ApproxDict(
+        {
+            "url": url,
+            SPANDATA.HTTP_METHOD: "GET",
+            SPANDATA.HTTP_FRAGMENT: "",
+            SPANDATA.HTTP_QUERY: "",
+            SPANDATA.HTTP_STATUS_CODE: response.status_code,
+            "reason": response.reason,
+        }
+    )
+
+
+@pytest.mark.skipif(
+    sys.version_info < (3, 7),
+    reason="The response status is not set on the span early enough in 3.6",
+)
+@pytest.mark.parametrize(
+    "status_code,level",
+    [
+        (200, None),
+        (301, None),
+        (403, "warning"),
+        (405, "warning"),
+        (500, "error"),
+    ],
+)
+def test_crumb_capture_client_error(sentry_init, capture_events, status_code, level):
+    sentry_init(integrations=[StdlibIntegration()])
 
     events = capture_events()
 
+    url = f"http://localhost:{PORT}/status/{status_code}"  # noqa:E231
     response = requests.get(url)
+
+    assert response.status_code == status_code
+
     capture_message("Testing!")
 
     (event,) = events
     (crumb,) = event["breadcrumbs"]["values"]
     assert crumb["type"] == "http"
     assert crumb["category"] == "httplib"
-    assert crumb["data"] == {
-        "url": url,
-        SPANDATA.HTTP_METHOD: "GET",
-        SPANDATA.HTTP_FRAGMENT: "",
-        SPANDATA.HTTP_QUERY: "",
-        SPANDATA.HTTP_STATUS_CODE: response.status_code,
-        "reason": response.reason,
-    }
+
+    if level is None:
+        assert "level" not in crumb
+    else:
+        assert crumb["level"] == level
+
+    assert crumb["data"] == ApproxDict(
+        {
+            "url": url,
+            SPANDATA.HTTP_METHOD: "GET",
+            SPANDATA.HTTP_FRAGMENT: "",
+            SPANDATA.HTTP_QUERY: "",
+            SPANDATA.HTTP_STATUS_CODE: response.status_code,
+            "reason": response.reason,
+        }
+    )
 
 
 @pytest.mark.tests_internal_exceptions
 def test_omit_url_data_if_parsing_fails(sentry_init, capture_events):
     sentry_init(integrations=[StdlibIntegration()])
 
-    url = "https://example.com"
-    responses.add(responses.GET, url, status=200)
-
     events = capture_events()
 
+    url = f"http://localhost:{PORT}/ok"  # noqa:E231
+
     with mock.patch(
         "sentry_sdk.integrations.stdlib.parse_url",
         side_effect=ValueError,
@@ -56,9 +101,14 @@ def test_omit_url_data_if_parsing_fails(sentry_init, capture_events):
     capture_message("Testing!")
 
     (event,) = events
-    assert event["breadcrumbs"]["values"][0]["data"] == {
-        SPANDATA.HTTP_METHOD: "GET",
-        SPANDATA.HTTP_STATUS_CODE: response.status_code,
-        "reason": response.reason,
-        # no url related data
-    }
+    assert event["breadcrumbs"]["values"][0]["data"] == ApproxDict(
+        {
+            SPANDATA.HTTP_METHOD: "GET",
+            SPANDATA.HTTP_STATUS_CODE: response.status_code,
+            "reason": response.reason,
+            # no url related data
+        }
+    )
+    assert "url" not in event["breadcrumbs"]["values"][0]["data"]
+    assert SPANDATA.HTTP_FRAGMENT not in event["breadcrumbs"]["values"][0]["data"]
+    assert SPANDATA.HTTP_QUERY not in event["breadcrumbs"]["values"][0]["data"]
diff --git a/tests/integrations/rq/test_rq.py b/tests/integrations/rq/test_rq.py
index 270a92e295..23603ad91d 100644
--- a/tests/integrations/rq/test_rq.py
+++ b/tests/integrations/rq/test_rq.py
@@ -1,32 +1,37 @@
-import pytest
-from fakeredis import FakeStrictRedis
-from sentry_sdk import configure_scope, start_transaction
-from sentry_sdk.integrations.rq import RqIntegration
+from unittest import mock
 
+import pytest
 import rq
+from fakeredis import FakeStrictRedis
 
-try:
-    from unittest import mock  # python 3.3 and above
-except ImportError:
-    import mock  # python < 3.3
+import sentry_sdk
+from sentry_sdk import start_transaction
+from sentry_sdk.integrations.rq import RqIntegration
+from sentry_sdk.utils import parse_version
 
 
 @pytest.fixture(autouse=True)
 def _patch_rq_get_server_version(monkeypatch):
     """
-    Patch up RQ 1.5 to work with fakeredis.
+    Patch RQ lower than 1.5.1 to work with fakeredis.
 
     https://github.com/jamesls/fakeredis/issues/273
     """
+    try:
+        from distutils.version import StrictVersion
+    except ImportError:
+        return
 
-    from distutils.version import StrictVersion
-
-    if tuple(map(int, rq.VERSION.split("."))) >= (1, 5):
+    if parse_version(rq.VERSION) <= (1, 5, 1):
         for k in (
             "rq.job.Job.get_redis_server_version",
             "rq.worker.Worker.get_redis_server_version",
         ):
-            monkeypatch.setattr(k, lambda _: StrictVersion("4.0.0"))
+            try:
+                monkeypatch.setattr(k, lambda _: StrictVersion("4.0.0"))
+            except AttributeError:
+                # old RQ Job/Worker doesn't have a get_redis_server_version attr
+                pass
 
 
 def crashing_job(foo):
@@ -92,7 +97,9 @@ def test_transport_shutdown(sentry_init, capture_events_forksafe):
 
 
 def test_transaction_with_error(
-    sentry_init, capture_events, DictionaryContaining  # noqa:N803
+    sentry_init,
+    capture_events,
+    DictionaryContaining,  # noqa:N803
 ):
     sentry_init(integrations=[RqIntegration()], traces_sample_rate=1.0)
     events = capture_events()
@@ -176,23 +183,23 @@ def test_tracing_disabled(
     queue = rq.Queue(connection=FakeStrictRedis())
     worker = rq.SimpleWorker([queue], connection=queue.connection)
 
-    with configure_scope() as scope:
-        queue.enqueue(crashing_job, foo=None)
-        worker.work(burst=True)
+    scope = sentry_sdk.get_isolation_scope()
+    queue.enqueue(crashing_job, foo=None)
+    worker.work(burst=True)
 
-        (error_event,) = events
+    (error_event,) = events
 
-        assert (
-            error_event["transaction"] == "tests.integrations.rq.test_rq.crashing_job"
-        )
-        assert (
-            error_event["contexts"]["trace"]["trace_id"]
-            == scope._propagation_context["trace_id"]
-        )
+    assert error_event["transaction"] == "tests.integrations.rq.test_rq.crashing_job"
+    assert (
+        error_event["contexts"]["trace"]["trace_id"]
+        == scope._propagation_context.trace_id
+    )
 
 
 def test_transaction_no_error(
-    sentry_init, capture_events, DictionaryContaining  # noqa:N803
+    sentry_init,
+    capture_events,
+    DictionaryContaining,  # noqa:N803
 ):
     sentry_init(integrations=[RqIntegration()], traces_sample_rate=1.0)
     events = capture_events()
@@ -219,7 +226,9 @@ def test_transaction_no_error(
 
 
 def test_traces_sampler_gets_correct_values_in_sampling_context(
-    sentry_init, DictionaryContaining, ObjectDescribedBy  # noqa:N803
+    sentry_init,
+    DictionaryContaining,
+    ObjectDescribedBy,  # noqa:N803
 ):
     traces_sampler = mock.Mock(return_value=True)
     sentry_init(integrations=[RqIntegration()], traces_sampler=traces_sampler)
@@ -249,7 +258,7 @@ def test_traces_sampler_gets_correct_values_in_sampling_context(
 
 
 @pytest.mark.skipif(
-    rq.__version__.split(".") < ["1", "5"], reason="At least rq-1.5 required"
+    parse_version(rq.__version__) < (1, 5), reason="At least rq-1.5 required"
 )
 def test_job_with_retries(sentry_init, capture_events):
     sentry_init(integrations=[RqIntegration()])
@@ -262,3 +271,18 @@ def test_job_with_retries(sentry_init, capture_events):
     worker.work(burst=True)
 
     assert len(events) == 1
+
+
+def test_span_origin(sentry_init, capture_events):
+    sentry_init(integrations=[RqIntegration()], traces_sample_rate=1.0)
+    events = capture_events()
+
+    queue = rq.Queue(connection=FakeStrictRedis())
+    worker = rq.SimpleWorker([queue], connection=queue.connection)
+
+    queue.enqueue(do_trick, "Maisey", trick="kangaroo")
+    worker.work(burst=True)
+
+    (event,) = events
+
+    assert event["contexts"]["trace"]["origin"] == "auto.queue.rq"
diff --git a/tests/integrations/rust_tracing/__init__.py b/tests/integrations/rust_tracing/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/integrations/rust_tracing/test_rust_tracing.py b/tests/integrations/rust_tracing/test_rust_tracing.py
new file mode 100644
index 0000000000..893fc86966
--- /dev/null
+++ b/tests/integrations/rust_tracing/test_rust_tracing.py
@@ -0,0 +1,475 @@
+from unittest import mock
+import pytest
+
+from string import Template
+from typing import Dict
+
+import sentry_sdk
+from sentry_sdk.integrations.rust_tracing import (
+    RustTracingIntegration,
+    RustTracingLayer,
+    RustTracingLevel,
+    EventTypeMapping,
+)
+from sentry_sdk import start_transaction, capture_message
+
+
+def _test_event_type_mapping(metadata: Dict[str, object]) -> EventTypeMapping:
+    level = RustTracingLevel(metadata.get("level"))
+    if level == RustTracingLevel.Error:
+        return EventTypeMapping.Exc
+    elif level in (RustTracingLevel.Warn, RustTracingLevel.Info):
+        return EventTypeMapping.Breadcrumb
+    elif level == RustTracingLevel.Debug:
+        return EventTypeMapping.Event
+    elif level == RustTracingLevel.Trace:
+        return EventTypeMapping.Ignore
+    else:
+        return EventTypeMapping.Ignore
+
+
+class FakeRustTracing:
+    # Parameters: `level`, `index`
+    span_template = Template(
+        """{"index":$index,"is_root":false,"metadata":{"fields":["index","use_memoized","version"],"file":"src/lib.rs","is_event":false,"is_span":true,"level":"$level","line":40,"module_path":"_bindings","name":"fibonacci","target":"_bindings"},"parent":null,"use_memoized":true}"""
+    )
+
+    # Parameters: `level`, `index`
+    event_template = Template(
+        """{"message":"Getting the ${index}th fibonacci number","metadata":{"fields":["message"],"file":"src/lib.rs","is_event":true,"is_span":false,"level":"$level","line":23,"module_path":"_bindings","name":"event src/lib.rs:23","target":"_bindings"}}"""
+    )
+
+    def __init__(self):
+        self.spans = {}
+
+    def set_layer_impl(self, layer: RustTracingLayer):
+        self.layer = layer
+
+    def new_span(self, level: RustTracingLevel, span_id: int, index_arg: int = 10):
+        span_attrs = self.span_template.substitute(level=level.value, index=index_arg)
+        state = self.layer.on_new_span(span_attrs, str(span_id))
+        self.spans[span_id] = state
+
+    def close_span(self, span_id: int):
+        state = self.spans.pop(span_id)
+        self.layer.on_close(str(span_id), state)
+
+    def event(self, level: RustTracingLevel, span_id: int, index_arg: int = 10):
+        event = self.event_template.substitute(level=level.value, index=index_arg)
+        state = self.spans[span_id]
+        self.layer.on_event(event, state)
+
+    def record(self, span_id: int):
+        state = self.spans[span_id]
+        self.layer.on_record(str(span_id), """{"version": "memoized"}""", state)
+
+
+def test_on_new_span_on_close(sentry_init, capture_events):
+    rust_tracing = FakeRustTracing()
+    integration = RustTracingIntegration(
+        "test_on_new_span_on_close",
+        initializer=rust_tracing.set_layer_impl,
+        include_tracing_fields=True,
+    )
+    sentry_init(integrations=[integration], traces_sample_rate=1.0)
+
+    events = capture_events()
+    with start_transaction():
+        rust_tracing.new_span(RustTracingLevel.Info, 3)
+
+        sentry_first_rust_span = sentry_sdk.get_current_span()
+        _, rust_first_rust_span = rust_tracing.spans[3]
+
+        assert sentry_first_rust_span == rust_first_rust_span
+
+        rust_tracing.close_span(3)
+        assert sentry_sdk.get_current_span() != sentry_first_rust_span
+
+    (event,) = events
+    assert len(event["spans"]) == 1
+
+    # Ensure the span metadata is wired up
+    span = event["spans"][0]
+    assert span["op"] == "function"
+    assert span["origin"] == "auto.function.rust_tracing.test_on_new_span_on_close"
+    assert span["description"] == "_bindings::fibonacci"
+
+    # Ensure the span was opened/closed appropriately
+    assert span["start_timestamp"] is not None
+    assert span["timestamp"] is not None
+
+    # Ensure the extra data from Rust is hooked up
+    data = span["data"]
+    assert data["use_memoized"]
+    assert data["index"] == 10
+    assert data["version"] is None
+
+
+def test_nested_on_new_span_on_close(sentry_init, capture_events):
+    rust_tracing = FakeRustTracing()
+    integration = RustTracingIntegration(
+        "test_nested_on_new_span_on_close",
+        initializer=rust_tracing.set_layer_impl,
+        include_tracing_fields=True,
+    )
+    sentry_init(integrations=[integration], traces_sample_rate=1.0)
+
+    events = capture_events()
+    with start_transaction():
+        original_sentry_span = sentry_sdk.get_current_span()
+
+        rust_tracing.new_span(RustTracingLevel.Info, 3, index_arg=10)
+        sentry_first_rust_span = sentry_sdk.get_current_span()
+        _, rust_first_rust_span = rust_tracing.spans[3]
+
+        # Use a different `index_arg` value for the inner span to help
+        # distinguish the two at the end of the test
+        rust_tracing.new_span(RustTracingLevel.Info, 5, index_arg=9)
+        sentry_second_rust_span = sentry_sdk.get_current_span()
+        rust_parent_span, rust_second_rust_span = rust_tracing.spans[5]
+
+        assert rust_second_rust_span == sentry_second_rust_span
+        assert rust_parent_span == sentry_first_rust_span
+        assert rust_parent_span == rust_first_rust_span
+        assert rust_parent_span != rust_second_rust_span
+
+        rust_tracing.close_span(5)
+
+        # Ensure the current sentry span was moved back to the parent
+        sentry_span_after_close = sentry_sdk.get_current_span()
+        assert sentry_span_after_close == sentry_first_rust_span
+
+        rust_tracing.close_span(3)
+
+        assert sentry_sdk.get_current_span() == original_sentry_span
+
+    (event,) = events
+    assert len(event["spans"]) == 2
+
+    # Ensure the span metadata is wired up for all spans
+    first_span, second_span = event["spans"]
+    assert first_span["op"] == "function"
+    assert (
+        first_span["origin"]
+        == "auto.function.rust_tracing.test_nested_on_new_span_on_close"
+    )
+    assert first_span["description"] == "_bindings::fibonacci"
+    assert second_span["op"] == "function"
+    assert (
+        second_span["origin"]
+        == "auto.function.rust_tracing.test_nested_on_new_span_on_close"
+    )
+    assert second_span["description"] == "_bindings::fibonacci"
+
+    # Ensure the spans were opened/closed appropriately
+    assert first_span["start_timestamp"] is not None
+    assert first_span["timestamp"] is not None
+    assert second_span["start_timestamp"] is not None
+    assert second_span["timestamp"] is not None
+
+    # Ensure the extra data from Rust is hooked up in both spans
+    first_span_data = first_span["data"]
+    assert first_span_data["use_memoized"]
+    assert first_span_data["index"] == 10
+    assert first_span_data["version"] is None
+
+    second_span_data = second_span["data"]
+    assert second_span_data["use_memoized"]
+    assert second_span_data["index"] == 9
+    assert second_span_data["version"] is None
+
+
+def test_on_new_span_without_transaction(sentry_init):
+    rust_tracing = FakeRustTracing()
+    integration = RustTracingIntegration(
+        "test_on_new_span_without_transaction", rust_tracing.set_layer_impl
+    )
+    sentry_init(integrations=[integration], traces_sample_rate=1.0)
+
+    assert sentry_sdk.get_current_span() is None
+
+    # Should still create a span hierarchy, it just will not be under a txn
+    rust_tracing.new_span(RustTracingLevel.Info, 3)
+    current_span = sentry_sdk.get_current_span()
+    assert current_span is not None
+    assert current_span.containing_transaction is None
+
+
+def test_on_event_exception(sentry_init, capture_events):
+    rust_tracing = FakeRustTracing()
+    integration = RustTracingIntegration(
+        "test_on_event_exception",
+        rust_tracing.set_layer_impl,
+        event_type_mapping=_test_event_type_mapping,
+    )
+    sentry_init(integrations=[integration], traces_sample_rate=1.0)
+
+    events = capture_events()
+    sentry_sdk.get_isolation_scope().clear_breadcrumbs()
+
+    with start_transaction():
+        rust_tracing.new_span(RustTracingLevel.Info, 3)
+
+        # Mapped to Exception
+        rust_tracing.event(RustTracingLevel.Error, 3)
+
+        rust_tracing.close_span(3)
+
+    assert len(events) == 2
+    exc, _tx = events
+    assert exc["level"] == "error"
+    assert exc["logger"] == "_bindings"
+    assert exc["message"] == "Getting the 10th fibonacci number"
+    assert exc["breadcrumbs"]["values"] == []
+
+    location_context = exc["contexts"]["rust_tracing_location"]
+    assert location_context["module_path"] == "_bindings"
+    assert location_context["file"] == "src/lib.rs"
+    assert location_context["line"] == 23
+
+    field_context = exc["contexts"]["rust_tracing_fields"]
+    assert field_context["message"] == "Getting the 10th fibonacci number"
+
+
+def test_on_event_breadcrumb(sentry_init, capture_events):
+    rust_tracing = FakeRustTracing()
+    integration = RustTracingIntegration(
+        "test_on_event_breadcrumb",
+        rust_tracing.set_layer_impl,
+        event_type_mapping=_test_event_type_mapping,
+    )
+    sentry_init(integrations=[integration], traces_sample_rate=1.0)
+
+    events = capture_events()
+    sentry_sdk.get_isolation_scope().clear_breadcrumbs()
+
+    with start_transaction():
+        rust_tracing.new_span(RustTracingLevel.Info, 3)
+
+        # Mapped to Breadcrumb
+        rust_tracing.event(RustTracingLevel.Info, 3)
+
+        rust_tracing.close_span(3)
+        capture_message("test message")
+
+    assert len(events) == 2
+    message, _tx = events
+
+    breadcrumbs = message["breadcrumbs"]["values"]
+    assert len(breadcrumbs) == 1
+    assert breadcrumbs[0]["level"] == "info"
+    assert breadcrumbs[0]["message"] == "Getting the 10th fibonacci number"
+    assert breadcrumbs[0]["type"] == "default"
+
+
+def test_on_event_event(sentry_init, capture_events):
+    rust_tracing = FakeRustTracing()
+    integration = RustTracingIntegration(
+        "test_on_event_event",
+        rust_tracing.set_layer_impl,
+        event_type_mapping=_test_event_type_mapping,
+    )
+    sentry_init(integrations=[integration], traces_sample_rate=1.0)
+
+    events = capture_events()
+    sentry_sdk.get_isolation_scope().clear_breadcrumbs()
+
+    with start_transaction():
+        rust_tracing.new_span(RustTracingLevel.Info, 3)
+
+        # Mapped to Event
+        rust_tracing.event(RustTracingLevel.Debug, 3)
+
+        rust_tracing.close_span(3)
+
+    assert len(events) == 2
+    event, _tx = events
+
+    assert event["logger"] == "_bindings"
+    assert event["level"] == "debug"
+    assert event["message"] == "Getting the 10th fibonacci number"
+    assert event["breadcrumbs"]["values"] == []
+
+    location_context = event["contexts"]["rust_tracing_location"]
+    assert location_context["module_path"] == "_bindings"
+    assert location_context["file"] == "src/lib.rs"
+    assert location_context["line"] == 23
+
+    field_context = event["contexts"]["rust_tracing_fields"]
+    assert field_context["message"] == "Getting the 10th fibonacci number"
+
+
+def test_on_event_ignored(sentry_init, capture_events):
+    rust_tracing = FakeRustTracing()
+    integration = RustTracingIntegration(
+        "test_on_event_ignored",
+        rust_tracing.set_layer_impl,
+        event_type_mapping=_test_event_type_mapping,
+    )
+    sentry_init(integrations=[integration], traces_sample_rate=1.0)
+
+    events = capture_events()
+    sentry_sdk.get_isolation_scope().clear_breadcrumbs()
+
+    with start_transaction():
+        rust_tracing.new_span(RustTracingLevel.Info, 3)
+
+        # Ignored
+        rust_tracing.event(RustTracingLevel.Trace, 3)
+
+        rust_tracing.close_span(3)
+
+    assert len(events) == 1
+    (tx,) = events
+    assert tx["type"] == "transaction"
+    assert "message" not in tx
+
+
+def test_span_filter(sentry_init, capture_events):
+    def span_filter(metadata: Dict[str, object]) -> bool:
+        return RustTracingLevel(metadata.get("level")) in (
+            RustTracingLevel.Error,
+            RustTracingLevel.Warn,
+            RustTracingLevel.Info,
+            RustTracingLevel.Debug,
+        )
+
+    rust_tracing = FakeRustTracing()
+    integration = RustTracingIntegration(
+        "test_span_filter",
+        initializer=rust_tracing.set_layer_impl,
+        span_filter=span_filter,
+        include_tracing_fields=True,
+    )
+    sentry_init(integrations=[integration], traces_sample_rate=1.0)
+
+    events = capture_events()
+    with start_transaction():
+        original_sentry_span = sentry_sdk.get_current_span()
+
+        # Span is not ignored
+        rust_tracing.new_span(RustTracingLevel.Info, 3, index_arg=10)
+        info_span = sentry_sdk.get_current_span()
+
+        # Span is ignored, current span should remain the same
+        rust_tracing.new_span(RustTracingLevel.Trace, 5, index_arg=9)
+        assert sentry_sdk.get_current_span() == info_span
+
+        # Closing the filtered span should leave the current span alone
+        rust_tracing.close_span(5)
+        assert sentry_sdk.get_current_span() == info_span
+
+        rust_tracing.close_span(3)
+        assert sentry_sdk.get_current_span() == original_sentry_span
+
+    (event,) = events
+    assert len(event["spans"]) == 1
+    # The ignored span has index == 9
+    assert event["spans"][0]["data"]["index"] == 10
+
+
+def test_record(sentry_init):
+    rust_tracing = FakeRustTracing()
+    integration = RustTracingIntegration(
+        "test_record",
+        initializer=rust_tracing.set_layer_impl,
+        include_tracing_fields=True,
+    )
+    sentry_init(integrations=[integration], traces_sample_rate=1.0)
+
+    with start_transaction():
+        rust_tracing.new_span(RustTracingLevel.Info, 3)
+
+        span_before_record = sentry_sdk.get_current_span().to_json()
+        assert span_before_record["data"]["version"] is None
+
+        rust_tracing.record(3)
+
+        span_after_record = sentry_sdk.get_current_span().to_json()
+        assert span_after_record["data"]["version"] == "memoized"
+
+
+def test_record_in_ignored_span(sentry_init):
+    def span_filter(metadata: Dict[str, object]) -> bool:
+        # Just ignore Trace
+        return RustTracingLevel(metadata.get("level")) != RustTracingLevel.Trace
+
+    rust_tracing = FakeRustTracing()
+    integration = RustTracingIntegration(
+        "test_record_in_ignored_span",
+        rust_tracing.set_layer_impl,
+        span_filter=span_filter,
+        include_tracing_fields=True,
+    )
+    sentry_init(integrations=[integration], traces_sample_rate=1.0)
+
+    with start_transaction():
+        rust_tracing.new_span(RustTracingLevel.Info, 3)
+
+        span_before_record = sentry_sdk.get_current_span().to_json()
+        assert span_before_record["data"]["version"] is None
+
+        rust_tracing.new_span(RustTracingLevel.Trace, 5)
+        rust_tracing.record(5)
+
+        # `on_record()` should not do anything to the current Sentry span if the associated Rust span was ignored
+        span_after_record = sentry_sdk.get_current_span().to_json()
+        assert span_after_record["data"]["version"] is None
+
+
+@pytest.mark.parametrize(
+    "send_default_pii, include_tracing_fields, tracing_fields_expected",
+    [
+        (True, True, True),
+        (True, False, False),
+        (True, None, True),
+        (False, True, True),
+        (False, False, False),
+        (False, None, False),
+    ],
+)
+def test_include_tracing_fields(
+    sentry_init, send_default_pii, include_tracing_fields, tracing_fields_expected
+):
+    rust_tracing = FakeRustTracing()
+    integration = RustTracingIntegration(
+        "test_record",
+        initializer=rust_tracing.set_layer_impl,
+        include_tracing_fields=include_tracing_fields,
+    )
+
+    sentry_init(
+        integrations=[integration],
+        traces_sample_rate=1.0,
+        send_default_pii=send_default_pii,
+    )
+    with start_transaction():
+        rust_tracing.new_span(RustTracingLevel.Info, 3)
+
+        span_before_record = sentry_sdk.get_current_span().to_json()
+        if tracing_fields_expected:
+            assert span_before_record["data"]["version"] is None
+        else:
+            assert span_before_record["data"]["version"] == "[Filtered]"
+
+        rust_tracing.record(3)
+
+        span_after_record = sentry_sdk.get_current_span().to_json()
+
+        if tracing_fields_expected:
+            assert span_after_record["data"] == {
+                "thread.id": mock.ANY,
+                "thread.name": mock.ANY,
+                "use_memoized": True,
+                "version": "memoized",
+                "index": 10,
+            }
+
+        else:
+            assert span_after_record["data"] == {
+                "thread.id": mock.ANY,
+                "thread.name": mock.ANY,
+                "use_memoized": "[Filtered]",
+                "version": "[Filtered]",
+                "index": "[Filtered]",
+            }
diff --git a/tests/integrations/sanic/test_sanic.py b/tests/integrations/sanic/test_sanic.py
index 1f6717a923..ff1c5efa26 100644
--- a/tests/integrations/sanic/test_sanic.py
+++ b/tests/integrations/sanic/test_sanic.py
@@ -1,20 +1,32 @@
+import asyncio
+import contextlib
 import os
-import sys
 import random
-import asyncio
+import sys
 from unittest.mock import Mock
 
 import pytest
 
-from sentry_sdk import capture_message, configure_scope
+import sentry_sdk
+from sentry_sdk import capture_message
 from sentry_sdk.integrations.sanic import SanicIntegration
-from sentry_sdk.tracing import TRANSACTION_SOURCE_COMPONENT, TRANSACTION_SOURCE_URL
+from sentry_sdk.tracing import TransactionSource
 
 from sanic import Sanic, request, response, __version__ as SANIC_VERSION_RAW
 from sanic.response import HTTPResponse
 from sanic.exceptions import SanicException
 
-from sentry_sdk._types import TYPE_CHECKING
+try:
+    from sanic_testing import TestManager
+except ImportError:
+    TestManager = None
+
+try:
+    from sanic_testing.reusable import ReusableClient
+except ImportError:
+    ReusableClient = None
+
+from typing import TYPE_CHECKING
 
 if TYPE_CHECKING:
     from collections.abc import Iterable, Container
@@ -43,33 +55,49 @@ def new_test_client(self):
     if SANIC_VERSION >= (20, 12) and SANIC_VERSION < (22, 6):
         # Some builds (20.12.0 intruduced and 22.6.0 removed again) have a feature where the instance is stored in an internal class
         # registry for later retrieval, and so add register=False to disable that
-        app = Sanic("Test", register=False)
+        sanic_app = Sanic("Test", register=False)
     else:
-        app = Sanic("Test")
+        sanic_app = Sanic("Test")
 
-    @app.route("/message")
+    if TestManager is not None:
+        TestManager(sanic_app)
+
+    @sanic_app.route("/message")
     def hi(request):
         capture_message("hi")
         return response.text("ok")
 
-    @app.route("/message/")
+    @sanic_app.route("/message/")
     def hi_with_id(request, message_id):
         capture_message("hi with id")
         return response.text("ok with id")
 
-    @app.route("/500")
+    @sanic_app.route("/500")
     def fivehundred(_):
         1 / 0
 
-    return app
+    return sanic_app
+
+
+def get_client(app):
+    @contextlib.contextmanager
+    def simple_client(app):
+        yield app.test_client
+
+    if ReusableClient is not None:
+        return ReusableClient(app)
+    else:
+        return simple_client(app)
 
 
 def test_request_data(sentry_init, app, capture_events):
     sentry_init(integrations=[SanicIntegration()])
     events = capture_events()
 
-    request, response = app.test_client.get("/message?foo=bar")
-    assert response.status == 200
+    c = get_client(app)
+    with c as client:
+        _, response = client.get("/message?foo=bar")
+        assert response.status == 200
 
     (event,) = events
     assert event["transaction"] == "hi"
@@ -106,8 +134,10 @@ def test_transaction_name(
     sentry_init(integrations=[SanicIntegration()])
     events = capture_events()
 
-    request, response = app.test_client.get(url)
-    assert response.status == 200
+    c = get_client(app)
+    with c as client:
+        _, response = client.get(url)
+        assert response.status == 200
 
     (event,) = events
     assert event["transaction"] == expected_transaction
@@ -122,8 +152,10 @@ def test_errors(sentry_init, app, capture_events):
     def myerror(request):
         raise ValueError("oh no")
 
-    request, response = app.test_client.get("/error")
-    assert response.status == 500
+    c = get_client(app)
+    with c as client:
+        _, response = client.get("/error")
+        assert response.status == 500
 
     (event,) = events
     assert event["transaction"] == "myerror"
@@ -145,8 +177,10 @@ def test_bad_request_not_captured(sentry_init, app, capture_events):
     def index(request):
         raise SanicException("...", status_code=400)
 
-    request, response = app.test_client.get("/")
-    assert response.status == 400
+    c = get_client(app)
+    with c as client:
+        _, response = client.get("/")
+        assert response.status == 400
 
     assert not events
 
@@ -163,8 +197,10 @@ def myerror(request):
     def myhandler(request, exception):
         1 / 0
 
-    request, response = app.test_client.get("/error")
-    assert response.status == 500
+    c = get_client(app)
+    with c as client:
+        _, response = client.get("/error")
+        assert response.status == 500
 
     event1, event2 = events
 
@@ -194,18 +230,17 @@ def test_concurrency(sentry_init, app):
     because that's the only way we could reproduce leakage with such a low
     amount of concurrent tasks.
     """
-
     sentry_init(integrations=[SanicIntegration()])
 
     @app.route("/context-check/")
     async def context_check(request, i):
-        with configure_scope() as scope:
-            scope.set_tag("i", i)
+        scope = sentry_sdk.get_isolation_scope()
+        scope.set_tag("i", i)
 
         await asyncio.sleep(random.random())
 
-        with configure_scope() as scope:
-            assert scope._tags["i"] == i
+        scope = sentry_sdk.get_isolation_scope()
+        assert scope._tags["i"] == i
 
         return response.text("ok")
 
@@ -294,8 +329,8 @@ async def runner():
     else:
         asyncio.run(runner())
 
-    with configure_scope() as scope:
-        assert not scope._tags
+    scope = sentry_sdk.get_isolation_scope()
+    assert not scope._tags
 
 
 class TransactionTestConfig:
@@ -306,13 +341,12 @@ class TransactionTestConfig:
 
     def __init__(
         self,
-        integration_args,
-        url,
-        expected_status,
-        expected_transaction_name,
-        expected_source=None,
-    ):
-        # type: (Iterable[Optional[Container[int]]], str, int, Optional[str], Optional[str]) -> None
+        integration_args: "Iterable[Optional[Container[int]]]",
+        url: str,
+        expected_status: int,
+        expected_transaction_name: "Optional[str]",
+        expected_source: "Optional[str]" = None,
+    ) -> None:
         """
         expected_transaction_name of None indicates we expect to not receive a transaction
         """
@@ -335,7 +369,7 @@ def __init__(
             url="/message",
             expected_status=200,
             expected_transaction_name="hi",
-            expected_source=TRANSACTION_SOURCE_COMPONENT,
+            expected_source=TransactionSource.COMPONENT,
         ),
         TransactionTestConfig(
             # Transaction still recorded when we have an internal server error
@@ -343,7 +377,7 @@ def __init__(
             url="/500",
             expected_status=500,
             expected_transaction_name="fivehundred",
-            expected_source=TRANSACTION_SOURCE_COMPONENT,
+            expected_source=TransactionSource.COMPONENT,
         ),
         TransactionTestConfig(
             # By default, no transaction when we have a 404 error
@@ -358,7 +392,7 @@ def __init__(
             url="/404",
             expected_status=404,
             expected_transaction_name="/404",
-            expected_source=TRANSACTION_SOURCE_URL,
+            expected_source=TransactionSource.URL,
         ),
         TransactionTestConfig(
             # Transaction can be suppressed for other HTTP statuses, too, by passing config to the integration
@@ -369,9 +403,12 @@ def __init__(
         ),
     ],
 )
-def test_transactions(test_config, sentry_init, app, capture_events):
-    # type: (TransactionTestConfig, Any, Any, Any) -> None
-
+def test_transactions(
+    test_config: "TransactionTestConfig",
+    sentry_init: "Any",
+    app: "Any",
+    capture_events: "Any",
+) -> None:
     # Init the SanicIntegration with the desired arguments
     sentry_init(
         integrations=[SanicIntegration(*test_config.integration_args)],
@@ -380,8 +417,10 @@ def test_transactions(test_config, sentry_init, app, capture_events):
     events = capture_events()
 
     # Make request to the desired URL
-    _, response = app.test_client.get(test_config.url)
-    assert response.status == test_config.expected_status
+    c = get_client(app)
+    with c as client:
+        _, response = client.get(test_config.url)
+        assert response.status == test_config.expected_status
 
     # Extract the transaction events by inspecting the event types. We should at most have 1 transaction event.
     transaction_events = [
@@ -407,3 +446,19 @@ def test_transactions(test_config, sentry_init, app, capture_events):
         or transaction_event["transaction_info"]["source"]
         == test_config.expected_source
     )
+
+
+@pytest.mark.skipif(
+    not PERFORMANCE_SUPPORTED, reason="Performance not supported on this Sanic version"
+)
+def test_span_origin(sentry_init, app, capture_events):
+    sentry_init(integrations=[SanicIntegration()], traces_sample_rate=1.0)
+    events = capture_events()
+
+    c = get_client(app)
+    with c as client:
+        client.get("/message?foo=bar")
+
+    (_, event) = events
+
+    assert event["contexts"]["trace"]["origin"] == "auto.http.sanic"
diff --git a/tests/integrations/serverless/test_serverless.py b/tests/integrations/serverless/test_serverless.py
index cc578ff4c4..a0a33e31ec 100644
--- a/tests/integrations/serverless/test_serverless.py
+++ b/tests/integrations/serverless/test_serverless.py
@@ -11,9 +11,7 @@ def test_basic(sentry_init, capture_exceptions, monkeypatch):
 
     @serverless_function
     def foo():
-        monkeypatch.setattr(
-            "sentry_sdk.Hub.current.flush", lambda: flush_calls.append(1)
-        )
+        monkeypatch.setattr("sentry_sdk.flush", lambda: flush_calls.append(1))
         1 / 0
 
     with pytest.raises(ZeroDivisionError):
@@ -31,7 +29,7 @@ def test_flush_disabled(sentry_init, capture_exceptions, monkeypatch):
 
     flush_calls = []
 
-    monkeypatch.setattr("sentry_sdk.Hub.current.flush", lambda: flush_calls.append(1))
+    monkeypatch.setattr("sentry_sdk.flush", lambda: flush_calls.append(1))
 
     @serverless_function(flush=False)
     def foo():
diff --git a/tests/integrations/socket/test_socket.py b/tests/integrations/socket/test_socket.py
index 914ba0bf84..cc109e0968 100644
--- a/tests/integrations/socket/test_socket.py
+++ b/tests/integrations/socket/test_socket.py
@@ -2,6 +2,9 @@
 
 from sentry_sdk import start_transaction
 from sentry_sdk.integrations.socket import SocketIntegration
+from tests.conftest import ApproxDict, create_mock_http_server
+
+PORT = create_mock_http_server()
 
 
 def test_getaddrinfo_trace(sentry_init, capture_events):
@@ -9,17 +12,19 @@ def test_getaddrinfo_trace(sentry_init, capture_events):
     events = capture_events()
 
     with start_transaction():
-        socket.getaddrinfo("example.com", 443)
+        socket.getaddrinfo("localhost", PORT)
 
     (event,) = events
     (span,) = event["spans"]
 
     assert span["op"] == "socket.dns"
-    assert span["description"] == "example.com:443"
-    assert span["data"] == {
-        "host": "example.com",
-        "port": 443,
-    }
+    assert span["description"] == f"localhost:{PORT}"  # noqa: E231
+    assert span["data"] == ApproxDict(
+        {
+            "host": "localhost",
+            "port": PORT,
+        }
+    )
 
 
 def test_create_connection_trace(sentry_init, capture_events):
@@ -29,23 +34,48 @@ def test_create_connection_trace(sentry_init, capture_events):
     events = capture_events()
 
     with start_transaction():
-        socket.create_connection(("example.com", 443), timeout, None)
+        socket.create_connection(("localhost", PORT), timeout, None)
 
     (event,) = events
     (connect_span, dns_span) = event["spans"]
     # as getaddrinfo gets called in create_connection it should also contain a dns span
 
     assert connect_span["op"] == "socket.connection"
-    assert connect_span["description"] == "example.com:443"
-    assert connect_span["data"] == {
-        "address": ["example.com", 443],
-        "timeout": timeout,
-        "source_address": None,
-    }
+    assert connect_span["description"] == f"localhost:{PORT}"  # noqa: E231
+    assert connect_span["data"] == ApproxDict(
+        {
+            "address": ["localhost", PORT],
+            "timeout": timeout,
+            "source_address": None,
+        }
+    )
 
     assert dns_span["op"] == "socket.dns"
-    assert dns_span["description"] == "example.com:443"
-    assert dns_span["data"] == {
-        "host": "example.com",
-        "port": 443,
-    }
+    assert dns_span["description"] == f"localhost:{PORT}"  # noqa: E231
+    assert dns_span["data"] == ApproxDict(
+        {
+            "host": "localhost",
+            "port": PORT,
+        }
+    )
+
+
+def test_span_origin(sentry_init, capture_events):
+    sentry_init(
+        integrations=[SocketIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    with start_transaction(name="foo"):
+        socket.create_connection(("localhost", PORT), 1, None)
+
+    (event,) = events
+
+    assert event["contexts"]["trace"]["origin"] == "manual"
+
+    assert event["spans"][0]["op"] == "socket.connection"
+    assert event["spans"][0]["origin"] == "auto.socket.socket"
+
+    assert event["spans"][1]["op"] == "socket.dns"
+    assert event["spans"][1]["origin"] == "auto.socket.socket"
diff --git a/tests/integrations/spark/test_spark.py b/tests/integrations/spark/test_spark.py
index c1c111ee11..c5bb70f4d1 100644
--- a/tests/integrations/spark/test_spark.py
+++ b/tests/integrations/spark/test_spark.py
@@ -1,24 +1,43 @@
 import pytest
 import sys
+from unittest.mock import patch
+
 from sentry_sdk.integrations.spark.spark_driver import (
     _set_app_properties,
     _start_sentry_listener,
     SentryListener,
+    SparkIntegration,
 )
-
 from sentry_sdk.integrations.spark.spark_worker import SparkWorkerIntegration
 
-from pyspark import SparkContext
+from pyspark import SparkConf, SparkContext
 
 from py4j.protocol import Py4JJavaError
 
+
 ################
 # DRIVER TESTS #
 ################
 
 
-def test_set_app_properties():
-    spark_context = SparkContext(appName="Testing123")
+@pytest.fixture(scope="function")
+def sentry_init_with_reset(sentry_init):
+    from sentry_sdk.integrations import _processed_integrations
+
+    yield lambda: sentry_init(integrations=[SparkIntegration()])
+    _processed_integrations.discard("spark")
+
+
+@pytest.fixture(scope="session")
+def create_spark_context():
+    conf = SparkConf().set("spark.driver.bindAddress", "127.0.0.1")
+    sc = SparkContext(conf=conf, appName="Testing123")
+    yield lambda: sc
+    sc.stop()
+
+
+def test_set_app_properties(create_spark_context):
+    spark_context = create_spark_context()
     _set_app_properties()
 
     assert spark_context.getLocalProperty("sentry_app_name") == "Testing123"
@@ -29,9 +48,8 @@ def test_set_app_properties():
     )
 
 
-def test_start_sentry_listener():
-    spark_context = SparkContext.getOrCreate()
-
+def test_start_sentry_listener(create_spark_context):
+    spark_context = create_spark_context()
     gateway = spark_context._gateway
     assert gateway._callback_server is None
 
@@ -40,90 +58,179 @@ def test_start_sentry_listener():
     assert gateway._callback_server is not None
 
 
-@pytest.fixture
-def sentry_listener(monkeypatch):
-    class MockHub:
-        def __init__(self):
-            self.args = []
-            self.kwargs = {}
+@patch("sentry_sdk.integrations.spark.spark_driver._patch_spark_context_init")
+def test_initialize_spark_integration_before_spark_context_init(
+    mock_patch_spark_context_init,
+    sentry_init_with_reset,
+):
+    # As we are using the same SparkContext connection for the whole session,
+    # we clean it during this test.
+    original_context = SparkContext._active_spark_context
+    SparkContext._active_spark_context = None
+
+    try:
+        sentry_init_with_reset()
+        mock_patch_spark_context_init.assert_called_once()
+    finally:
+        # Restore the original one.
+        SparkContext._active_spark_context = original_context
+
+
+@patch("sentry_sdk.integrations.spark.spark_driver._activate_integration")
+def test_initialize_spark_integration_after_spark_context_init(
+    mock_activate_integration,
+    create_spark_context,
+    sentry_init_with_reset,
+):
+    create_spark_context()
+    sentry_init_with_reset()
 
-        def add_breadcrumb(self, *args, **kwargs):
-            self.args = args
-            self.kwargs = kwargs
+    mock_activate_integration.assert_called_once()
 
-    listener = SentryListener()
-    mock_hub = MockHub()
 
-    monkeypatch.setattr(listener, "hub", mock_hub)
+@pytest.fixture
+def sentry_listener():
+    listener = SentryListener()
 
-    return listener, mock_hub
+    return listener
 
 
 def test_sentry_listener_on_job_start(sentry_listener):
-    listener, mock_hub = sentry_listener
+    listener = sentry_listener
+    with patch.object(listener, "_add_breadcrumb") as mock_add_breadcrumb:
 
-    class MockJobStart:
-        def jobId(self):  # noqa: N802
-            return "sample-job-id-start"
+        class MockJobStart:
+            def jobId(self):  # noqa: N802
+                return "sample-job-id-start"
 
-    mock_job_start = MockJobStart()
-    listener.onJobStart(mock_job_start)
+        mock_job_start = MockJobStart()
+        listener.onJobStart(mock_job_start)
 
-    assert mock_hub.kwargs["level"] == "info"
-    assert "sample-job-id-start" in mock_hub.kwargs["message"]
+        mock_add_breadcrumb.assert_called_once()
+        mock_hub = mock_add_breadcrumb.call_args
+
+        assert mock_hub.kwargs["level"] == "info"
+        assert "sample-job-id-start" in mock_hub.kwargs["message"]
 
 
 @pytest.mark.parametrize(
     "job_result, level", [("JobSucceeded", "info"), ("JobFailed", "warning")]
 )
 def test_sentry_listener_on_job_end(sentry_listener, job_result, level):
-    listener, mock_hub = sentry_listener
+    listener = sentry_listener
+    with patch.object(listener, "_add_breadcrumb") as mock_add_breadcrumb:
+
+        class MockJobResult:
+            def toString(self):  # noqa: N802
+                return job_result
 
-    class MockJobResult:
-        def toString(self):  # noqa: N802
-            return job_result
+        class MockJobEnd:
+            def jobId(self):  # noqa: N802
+                return "sample-job-id-end"
 
-    class MockJobEnd:
-        def jobId(self):  # noqa: N802
-            return "sample-job-id-end"
+            def jobResult(self):  # noqa: N802
+                result = MockJobResult()
+                return result
 
-        def jobResult(self):  # noqa: N802
-            result = MockJobResult()
-            return result
+        mock_job_end = MockJobEnd()
+        listener.onJobEnd(mock_job_end)
 
-    mock_job_end = MockJobEnd()
-    listener.onJobEnd(mock_job_end)
+        mock_add_breadcrumb.assert_called_once()
+        mock_hub = mock_add_breadcrumb.call_args
 
-    assert mock_hub.kwargs["level"] == level
-    assert mock_hub.kwargs["data"]["result"] == job_result
-    assert "sample-job-id-end" in mock_hub.kwargs["message"]
+        assert mock_hub.kwargs["level"] == level
+        assert mock_hub.kwargs["data"]["result"] == job_result
+        assert "sample-job-id-end" in mock_hub.kwargs["message"]
 
 
 def test_sentry_listener_on_stage_submitted(sentry_listener):
-    listener, mock_hub = sentry_listener
+    listener = sentry_listener
+    with patch.object(listener, "_add_breadcrumb") as mock_add_breadcrumb:
+
+        class StageInfo:
+            def stageId(self):  # noqa: N802
+                return "sample-stage-id-submit"
+
+            def name(self):
+                return "run-job"
+
+            def attemptId(self):  # noqa: N802
+                return 14
+
+        class MockStageSubmitted:
+            def stageInfo(self):  # noqa: N802
+                stageinf = StageInfo()
+                return stageinf
 
-    class StageInfo:
-        def stageId(self):  # noqa: N802
-            return "sample-stage-id-submit"
+        mock_stage_submitted = MockStageSubmitted()
+        listener.onStageSubmitted(mock_stage_submitted)
 
-        def name(self):
-            return "run-job"
+        mock_add_breadcrumb.assert_called_once()
+        mock_hub = mock_add_breadcrumb.call_args
 
-        def attemptId(self):  # noqa: N802
-            return 14
+        assert mock_hub.kwargs["level"] == "info"
+        assert "sample-stage-id-submit" in mock_hub.kwargs["message"]
+        assert mock_hub.kwargs["data"]["attemptId"] == 14
+        assert mock_hub.kwargs["data"]["name"] == "run-job"
 
-    class MockStageSubmitted:
-        def stageInfo(self):  # noqa: N802
-            stageinf = StageInfo()
-            return stageinf
 
-    mock_stage_submitted = MockStageSubmitted()
-    listener.onStageSubmitted(mock_stage_submitted)
+def test_sentry_listener_on_stage_submitted_no_attempt_id(sentry_listener):
+    listener = sentry_listener
+    with patch.object(listener, "_add_breadcrumb") as mock_add_breadcrumb:
 
-    assert mock_hub.kwargs["level"] == "info"
-    assert "sample-stage-id-submit" in mock_hub.kwargs["message"]
-    assert mock_hub.kwargs["data"]["attemptId"] == 14
-    assert mock_hub.kwargs["data"]["name"] == "run-job"
+        class StageInfo:
+            def stageId(self):  # noqa: N802
+                return "sample-stage-id-submit"
+
+            def name(self):
+                return "run-job"
+
+            def attemptNumber(self):  # noqa: N802
+                return 14
+
+        class MockStageSubmitted:
+            def stageInfo(self):  # noqa: N802
+                stageinf = StageInfo()
+                return stageinf
+
+        mock_stage_submitted = MockStageSubmitted()
+        listener.onStageSubmitted(mock_stage_submitted)
+
+        mock_add_breadcrumb.assert_called_once()
+        mock_hub = mock_add_breadcrumb.call_args
+
+        assert mock_hub.kwargs["level"] == "info"
+        assert "sample-stage-id-submit" in mock_hub.kwargs["message"]
+        assert mock_hub.kwargs["data"]["attemptId"] == 14
+        assert mock_hub.kwargs["data"]["name"] == "run-job"
+
+
+def test_sentry_listener_on_stage_submitted_no_attempt_id_or_number(sentry_listener):
+    listener = sentry_listener
+    with patch.object(listener, "_add_breadcrumb") as mock_add_breadcrumb:
+
+        class StageInfo:
+            def stageId(self):  # noqa: N802
+                return "sample-stage-id-submit"
+
+            def name(self):
+                return "run-job"
+
+        class MockStageSubmitted:
+            def stageInfo(self):  # noqa: N802
+                stageinf = StageInfo()
+                return stageinf
+
+        mock_stage_submitted = MockStageSubmitted()
+        listener.onStageSubmitted(mock_stage_submitted)
+
+        mock_add_breadcrumb.assert_called_once()
+        mock_hub = mock_add_breadcrumb.call_args
+
+        assert mock_hub.kwargs["level"] == "info"
+        assert "sample-stage-id-submit" in mock_hub.kwargs["message"]
+        assert "attemptId" not in mock_hub.kwargs["data"]
+        assert mock_hub.kwargs["data"]["name"] == "run-job"
 
 
 @pytest.fixture
@@ -165,31 +272,37 @@ def stageInfo(self):  # noqa: N802
 def test_sentry_listener_on_stage_completed_success(
     sentry_listener, get_mock_stage_completed
 ):
-    listener, mock_hub = sentry_listener
+    listener = sentry_listener
+    with patch.object(listener, "_add_breadcrumb") as mock_add_breadcrumb:
+        mock_stage_completed = get_mock_stage_completed(failure_reason=False)
+        listener.onStageCompleted(mock_stage_completed)
 
-    mock_stage_completed = get_mock_stage_completed(failure_reason=False)
-    listener.onStageCompleted(mock_stage_completed)
+        mock_add_breadcrumb.assert_called_once()
+        mock_hub = mock_add_breadcrumb.call_args
 
-    assert mock_hub.kwargs["level"] == "info"
-    assert "sample-stage-id-submit" in mock_hub.kwargs["message"]
-    assert mock_hub.kwargs["data"]["attemptId"] == 14
-    assert mock_hub.kwargs["data"]["name"] == "run-job"
-    assert "reason" not in mock_hub.kwargs["data"]
+        assert mock_hub.kwargs["level"] == "info"
+        assert "sample-stage-id-submit" in mock_hub.kwargs["message"]
+        assert mock_hub.kwargs["data"]["attemptId"] == 14
+        assert mock_hub.kwargs["data"]["name"] == "run-job"
+        assert "reason" not in mock_hub.kwargs["data"]
 
 
 def test_sentry_listener_on_stage_completed_failure(
     sentry_listener, get_mock_stage_completed
 ):
-    listener, mock_hub = sentry_listener
-
-    mock_stage_completed = get_mock_stage_completed(failure_reason=True)
-    listener.onStageCompleted(mock_stage_completed)
-
-    assert mock_hub.kwargs["level"] == "warning"
-    assert "sample-stage-id-submit" in mock_hub.kwargs["message"]
-    assert mock_hub.kwargs["data"]["attemptId"] == 14
-    assert mock_hub.kwargs["data"]["name"] == "run-job"
-    assert mock_hub.kwargs["data"]["reason"] == "failure-reason"
+    listener = sentry_listener
+    with patch.object(listener, "_add_breadcrumb") as mock_add_breadcrumb:
+        mock_stage_completed = get_mock_stage_completed(failure_reason=True)
+        listener.onStageCompleted(mock_stage_completed)
+
+        mock_add_breadcrumb.assert_called_once()
+        mock_hub = mock_add_breadcrumb.call_args
+
+        assert mock_hub.kwargs["level"] == "warning"
+        assert "sample-stage-id-submit" in mock_hub.kwargs["message"]
+        assert mock_hub.kwargs["data"]["attemptId"] == 14
+        assert mock_hub.kwargs["data"]["name"] == "run-job"
+        assert mock_hub.kwargs["data"]["reason"] == "failure-reason"
 
 
 ################
diff --git a/tests/integrations/sqlalchemy/__init__.py b/tests/integrations/sqlalchemy/__init__.py
index b430bf6d43..33c43a6872 100644
--- a/tests/integrations/sqlalchemy/__init__.py
+++ b/tests/integrations/sqlalchemy/__init__.py
@@ -1,3 +1,9 @@
+import os
+import sys
 import pytest
 
 pytest.importorskip("sqlalchemy")
+
+# Load `sqlalchemy_helpers` into the module search path to test query source path names relative to module. See
+# `test_query_source_with_module_in_search_path`
+sys.path.insert(0, os.path.join(os.path.dirname(__file__)))
diff --git a/tests/integrations/sqlalchemy/sqlalchemy_helpers/__init__.py b/tests/integrations/sqlalchemy/sqlalchemy_helpers/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/integrations/sqlalchemy/sqlalchemy_helpers/helpers.py b/tests/integrations/sqlalchemy/sqlalchemy_helpers/helpers.py
new file mode 100644
index 0000000000..ca65a88d25
--- /dev/null
+++ b/tests/integrations/sqlalchemy/sqlalchemy_helpers/helpers.py
@@ -0,0 +1,7 @@
+def add_model_to_session(model, session):
+    session.add(model)
+    session.commit()
+
+
+def query_first_model_from_session(model_klass, session):
+    return session.query(model_klass).first()
diff --git a/tests/integrations/sqlalchemy/test_sqlalchemy.py b/tests/integrations/sqlalchemy/test_sqlalchemy.py
index eb1792b3be..d2a31a55d5 100644
--- a/tests/integrations/sqlalchemy/test_sqlalchemy.py
+++ b/tests/integrations/sqlalchemy/test_sqlalchemy.py
@@ -1,16 +1,20 @@
-import sys
-import pytest
+import os
+from datetime import datetime
+from unittest import mock
 
+import pytest
 from sqlalchemy import Column, ForeignKey, Integer, String, create_engine
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.declarative import declarative_base
 from sqlalchemy.orm import relationship, sessionmaker
 from sqlalchemy import text
 
-from sentry_sdk import capture_message, start_transaction, configure_scope
+import sentry_sdk
+from sentry_sdk import capture_message, start_transaction
 from sentry_sdk.consts import DEFAULT_MAX_VALUE_LENGTH, SPANDATA
 from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
 from sentry_sdk.serializer import MAX_EVENT_BYTES
+from sentry_sdk.tracing_utils import record_sql_queries
 from sentry_sdk.utils import json_dumps
 
 
@@ -36,7 +40,9 @@ class Address(Base):
         person_id = Column(Integer, ForeignKey("person.id"))
         person = relationship(Person)
 
-    engine = create_engine("sqlite:///:memory:")
+    engine = create_engine(
+        "sqlite:///:memory:", connect_args={"check_same_thread": False}
+    )
     Base.metadata.create_all(engine)
 
     Session = sessionmaker(bind=engine)  # noqa: N806
@@ -72,9 +78,6 @@ class Address(Base):
     ]
 
 
-@pytest.mark.skipif(
-    sys.version_info < (3,), reason="This sqla usage seems to be broken on Py2"
-)
 def test_transactions(sentry_init, capture_events, render_span_tree):
     sentry_init(
         integrations=[SqlalchemyIntegration()],
@@ -99,7 +102,9 @@ class Address(Base):
         person_id = Column(Integer, ForeignKey("person.id"))
         person = relationship(Person)
 
-    engine = create_engine("sqlite:///:memory:")
+    engine = create_engine(
+        "sqlite:///:memory:", connect_args={"check_same_thread": False}
+    )
     Base.metadata.create_all(engine)
 
     Session = sessionmaker(bind=engine)  # noqa: N806
@@ -146,6 +151,61 @@ class Address(Base):
     )
 
 
+def test_transactions_no_engine_url(sentry_init, capture_events):
+    sentry_init(
+        integrations=[SqlalchemyIntegration()],
+        _experiments={"record_sql_params": True},
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    Base = declarative_base()  # noqa: N806
+
+    class Person(Base):
+        __tablename__ = "person"
+        id = Column(Integer, primary_key=True)
+        name = Column(String(250), nullable=False)
+
+    class Address(Base):
+        __tablename__ = "address"
+        id = Column(Integer, primary_key=True)
+        street_name = Column(String(250))
+        street_number = Column(String(250))
+        post_code = Column(String(250), nullable=False)
+        person_id = Column(Integer, ForeignKey("person.id"))
+        person = relationship(Person)
+
+    engine = create_engine(
+        "sqlite:///:memory:", connect_args={"check_same_thread": False}
+    )
+    engine.url = None
+    Base.metadata.create_all(engine)
+
+    Session = sessionmaker(bind=engine)  # noqa: N806
+    session = Session()
+
+    with start_transaction(name="test_transaction", sampled=True):
+        with session.begin_nested():
+            session.query(Person).first()
+
+        for _ in range(2):
+            with pytest.raises(IntegrityError):
+                with session.begin_nested():
+                    session.add(Person(id=1, name="bob"))
+                    session.add(Person(id=1, name="bob"))
+
+        with session.begin_nested():
+            session.query(Person).first()
+
+    (event,) = events
+
+    for span in event["spans"]:
+        assert span["data"][SPANDATA.DB_SYSTEM] == "sqlite"
+        assert SPANDATA.DB_NAME not in span["data"]
+        assert SPANDATA.SERVER_ADDRESS not in span["data"]
+        assert SPANDATA.SERVER_PORT not in span["data"]
+
+
 def test_long_sql_query_preserved(sentry_init, capture_events):
     sentry_init(
         traces_sample_rate=1,
@@ -153,7 +213,9 @@ def test_long_sql_query_preserved(sentry_init, capture_events):
     )
     events = capture_events()
 
-    engine = create_engine("sqlite:///:memory:")
+    engine = create_engine(
+        "sqlite:///:memory:", connect_args={"check_same_thread": False}
+    )
     with start_transaction(name="test"):
         with engine.connect() as con:
             con.execute(text(" UNION ".join("SELECT {}".format(i) for i in range(100))))
@@ -173,14 +235,16 @@ def test_large_event_not_truncated(sentry_init, capture_events):
 
     long_str = "x" * (DEFAULT_MAX_VALUE_LENGTH + 10)
 
-    with configure_scope() as scope:
+    scope = sentry_sdk.get_isolation_scope()
 
-        @scope.add_event_processor
-        def processor(event, hint):
-            event["message"] = long_str
-            return event
+    @scope.add_event_processor
+    def processor(event, hint):
+        event["message"] = long_str
+        return event
 
-    engine = create_engine("sqlite:///:memory:")
+    engine = create_engine(
+        "sqlite:///:memory:", connect_args={"check_same_thread": False}
+    )
     with start_transaction(name="test"):
         with engine.connect() as con:
             for _ in range(1500):
@@ -211,7 +275,12 @@ def processor(event, hint):
 
     # The _meta for other truncated fields should be there as well.
     assert event["_meta"]["message"] == {
-        "": {"len": 1034, "rem": [["!limit", "x", 1021, 1024]]}
+        "": {
+            "len": DEFAULT_MAX_VALUE_LENGTH + 10,
+            "rem": [
+                ["!limit", "x", DEFAULT_MAX_VALUE_LENGTH - 3, DEFAULT_MAX_VALUE_LENGTH]
+            ],
+        }
     }
 
 
@@ -220,8 +289,409 @@ def test_engine_name_not_string(sentry_init):
         integrations=[SqlalchemyIntegration()],
     )
 
-    engine = create_engine("sqlite:///:memory:")
+    engine = create_engine(
+        "sqlite:///:memory:", connect_args={"check_same_thread": False}
+    )
     engine.dialect.name = b"sqlite"
 
     with engine.connect() as con:
         con.execute(text("SELECT 0"))
+
+
+def test_query_source_disabled(sentry_init, capture_events):
+    sentry_options = {
+        "integrations": [SqlalchemyIntegration()],
+        "enable_tracing": True,
+        "enable_db_query_source": False,
+        "db_query_source_threshold_ms": 0,
+    }
+
+    sentry_init(**sentry_options)
+
+    events = capture_events()
+
+    with start_transaction(name="test_transaction", sampled=True):
+        Base = declarative_base()  # noqa: N806
+
+        class Person(Base):
+            __tablename__ = "person"
+            id = Column(Integer, primary_key=True)
+            name = Column(String(250), nullable=False)
+
+        engine = create_engine(
+            "sqlite:///:memory:", connect_args={"check_same_thread": False}
+        )
+        Base.metadata.create_all(engine)
+
+        Session = sessionmaker(bind=engine)  # noqa: N806
+        session = Session()
+
+        bob = Person(name="Bob")
+        session.add(bob)
+
+        assert session.query(Person).first() == bob
+
+    (event,) = events
+
+    for span in event["spans"]:
+        if span.get("op") == "db" and span.get("description").startswith(
+            "SELECT person"
+        ):
+            data = span.get("data", {})
+
+            assert SPANDATA.CODE_LINENO not in data
+            assert SPANDATA.CODE_NAMESPACE not in data
+            assert SPANDATA.CODE_FILEPATH not in data
+            assert SPANDATA.CODE_FUNCTION not in data
+            break
+    else:
+        raise AssertionError("No db span found")
+
+
+@pytest.mark.parametrize("enable_db_query_source", [None, True])
+def test_query_source_enabled(sentry_init, capture_events, enable_db_query_source):
+    sentry_options = {
+        "integrations": [SqlalchemyIntegration()],
+        "enable_tracing": True,
+        "db_query_source_threshold_ms": 0,
+    }
+    if enable_db_query_source is not None:
+        sentry_options["enable_db_query_source"] = enable_db_query_source
+
+    sentry_init(**sentry_options)
+
+    events = capture_events()
+
+    with start_transaction(name="test_transaction", sampled=True):
+        Base = declarative_base()  # noqa: N806
+
+        class Person(Base):
+            __tablename__ = "person"
+            id = Column(Integer, primary_key=True)
+            name = Column(String(250), nullable=False)
+
+        engine = create_engine(
+            "sqlite:///:memory:", connect_args={"check_same_thread": False}
+        )
+        Base.metadata.create_all(engine)
+
+        Session = sessionmaker(bind=engine)  # noqa: N806
+        session = Session()
+
+        bob = Person(name="Bob")
+        session.add(bob)
+
+        assert session.query(Person).first() == bob
+
+    (event,) = events
+
+    for span in event["spans"]:
+        if span.get("op") == "db" and span.get("description").startswith(
+            "SELECT person"
+        ):
+            data = span.get("data", {})
+
+            assert SPANDATA.CODE_LINENO in data
+            assert SPANDATA.CODE_NAMESPACE in data
+            assert SPANDATA.CODE_FILEPATH in data
+            assert SPANDATA.CODE_FUNCTION in data
+            break
+    else:
+        raise AssertionError("No db span found")
+
+
+def test_query_source(sentry_init, capture_events):
+    sentry_init(
+        integrations=[SqlalchemyIntegration()],
+        enable_tracing=True,
+        enable_db_query_source=True,
+        db_query_source_threshold_ms=0,
+    )
+    events = capture_events()
+
+    with start_transaction(name="test_transaction", sampled=True):
+        Base = declarative_base()  # noqa: N806
+
+        class Person(Base):
+            __tablename__ = "person"
+            id = Column(Integer, primary_key=True)
+            name = Column(String(250), nullable=False)
+
+        engine = create_engine(
+            "sqlite:///:memory:", connect_args={"check_same_thread": False}
+        )
+        Base.metadata.create_all(engine)
+
+        Session = sessionmaker(bind=engine)  # noqa: N806
+        session = Session()
+
+        bob = Person(name="Bob")
+        session.add(bob)
+
+        assert session.query(Person).first() == bob
+
+    (event,) = events
+
+    for span in event["spans"]:
+        if span.get("op") == "db" and span.get("description").startswith(
+            "SELECT person"
+        ):
+            data = span.get("data", {})
+
+            assert SPANDATA.CODE_LINENO in data
+            assert SPANDATA.CODE_NAMESPACE in data
+            assert SPANDATA.CODE_FILEPATH in data
+            assert SPANDATA.CODE_FUNCTION in data
+
+            assert type(data.get(SPANDATA.CODE_LINENO)) == int
+            assert data.get(SPANDATA.CODE_LINENO) > 0
+            assert (
+                data.get(SPANDATA.CODE_NAMESPACE)
+                == "tests.integrations.sqlalchemy.test_sqlalchemy"
+            )
+            assert data.get(SPANDATA.CODE_FILEPATH).endswith(
+                "tests/integrations/sqlalchemy/test_sqlalchemy.py"
+            )
+
+            is_relative_path = data.get(SPANDATA.CODE_FILEPATH)[0] != os.sep
+            assert is_relative_path
+
+            assert data.get(SPANDATA.CODE_FUNCTION) == "test_query_source"
+            break
+    else:
+        raise AssertionError("No db span found")
+
+
+def test_query_source_with_module_in_search_path(sentry_init, capture_events):
+    """
+    Test that query source is relative to the path of the module it ran in
+    """
+    sentry_init(
+        integrations=[SqlalchemyIntegration()],
+        enable_tracing=True,
+        enable_db_query_source=True,
+        db_query_source_threshold_ms=0,
+    )
+    events = capture_events()
+
+    from sqlalchemy_helpers.helpers import (
+        add_model_to_session,
+        query_first_model_from_session,
+    )
+
+    with start_transaction(name="test_transaction", sampled=True):
+        Base = declarative_base()  # noqa: N806
+
+        class Person(Base):
+            __tablename__ = "person"
+            id = Column(Integer, primary_key=True)
+            name = Column(String(250), nullable=False)
+
+        engine = create_engine(
+            "sqlite:///:memory:", connect_args={"check_same_thread": False}
+        )
+        Base.metadata.create_all(engine)
+
+        Session = sessionmaker(bind=engine)  # noqa: N806
+        session = Session()
+
+        bob = Person(name="Bob")
+
+        add_model_to_session(bob, session)
+
+        assert query_first_model_from_session(Person, session) == bob
+
+    (event,) = events
+
+    for span in event["spans"]:
+        if span.get("op") == "db" and span.get("description").startswith(
+            "SELECT person"
+        ):
+            data = span.get("data", {})
+
+            assert SPANDATA.CODE_LINENO in data
+            assert SPANDATA.CODE_NAMESPACE in data
+            assert SPANDATA.CODE_FILEPATH in data
+            assert SPANDATA.CODE_FUNCTION in data
+
+            assert type(data.get(SPANDATA.CODE_LINENO)) == int
+            assert data.get(SPANDATA.CODE_LINENO) > 0
+            assert data.get(SPANDATA.CODE_NAMESPACE) == "sqlalchemy_helpers.helpers"
+            assert data.get(SPANDATA.CODE_FILEPATH) == "sqlalchemy_helpers/helpers.py"
+
+            is_relative_path = data.get(SPANDATA.CODE_FILEPATH)[0] != os.sep
+            assert is_relative_path
+
+            assert data.get(SPANDATA.CODE_FUNCTION) == "query_first_model_from_session"
+            break
+    else:
+        raise AssertionError("No db span found")
+
+
+def test_no_query_source_if_duration_too_short(sentry_init, capture_events):
+    sentry_init(
+        integrations=[SqlalchemyIntegration()],
+        enable_tracing=True,
+        enable_db_query_source=True,
+        db_query_source_threshold_ms=100,
+    )
+    events = capture_events()
+
+    with start_transaction(name="test_transaction", sampled=True):
+        Base = declarative_base()  # noqa: N806
+
+        class Person(Base):
+            __tablename__ = "person"
+            id = Column(Integer, primary_key=True)
+            name = Column(String(250), nullable=False)
+
+        engine = create_engine(
+            "sqlite:///:memory:", connect_args={"check_same_thread": False}
+        )
+        Base.metadata.create_all(engine)
+
+        Session = sessionmaker(bind=engine)  # noqa: N806
+        session = Session()
+
+        bob = Person(name="Bob")
+        session.add(bob)
+
+        class fake_record_sql_queries:  # noqa: N801
+            def __init__(self, *args, **kwargs):
+                with record_sql_queries(*args, **kwargs) as span:
+                    self.span = span
+
+                self.span.start_timestamp = datetime(2024, 1, 1, microsecond=0)
+                self.span.timestamp = datetime(2024, 1, 1, microsecond=99999)
+
+            def __enter__(self):
+                return self.span
+
+            def __exit__(self, type, value, traceback):
+                pass
+
+        with mock.patch(
+            "sentry_sdk.integrations.sqlalchemy.record_sql_queries",
+            fake_record_sql_queries,
+        ):
+            assert session.query(Person).first() == bob
+
+    (event,) = events
+
+    for span in event["spans"]:
+        if span.get("op") == "db" and span.get("description").startswith(
+            "SELECT person"
+        ):
+            data = span.get("data", {})
+
+            assert SPANDATA.CODE_LINENO not in data
+            assert SPANDATA.CODE_NAMESPACE not in data
+            assert SPANDATA.CODE_FILEPATH not in data
+            assert SPANDATA.CODE_FUNCTION not in data
+
+            break
+    else:
+        raise AssertionError("No db span found")
+
+
+def test_query_source_if_duration_over_threshold(sentry_init, capture_events):
+    sentry_init(
+        integrations=[SqlalchemyIntegration()],
+        enable_tracing=True,
+        enable_db_query_source=True,
+        db_query_source_threshold_ms=100,
+    )
+    events = capture_events()
+
+    with start_transaction(name="test_transaction", sampled=True):
+        Base = declarative_base()  # noqa: N806
+
+        class Person(Base):
+            __tablename__ = "person"
+            id = Column(Integer, primary_key=True)
+            name = Column(String(250), nullable=False)
+
+        engine = create_engine(
+            "sqlite:///:memory:", connect_args={"check_same_thread": False}
+        )
+        Base.metadata.create_all(engine)
+
+        Session = sessionmaker(bind=engine)  # noqa: N806
+        session = Session()
+
+        bob = Person(name="Bob")
+        session.add(bob)
+
+        class fake_record_sql_queries:  # noqa: N801
+            def __init__(self, *args, **kwargs):
+                with record_sql_queries(*args, **kwargs) as span:
+                    self.span = span
+
+                self.span.start_timestamp = datetime(2024, 1, 1, microsecond=0)
+                self.span.timestamp = datetime(2024, 1, 1, microsecond=101000)
+
+            def __enter__(self):
+                return self.span
+
+            def __exit__(self, type, value, traceback):
+                pass
+
+        with mock.patch(
+            "sentry_sdk.integrations.sqlalchemy.record_sql_queries",
+            fake_record_sql_queries,
+        ):
+            assert session.query(Person).first() == bob
+
+    (event,) = events
+
+    for span in event["spans"]:
+        if span.get("op") == "db" and span.get("description").startswith(
+            "SELECT person"
+        ):
+            data = span.get("data", {})
+
+            assert SPANDATA.CODE_LINENO in data
+            assert SPANDATA.CODE_NAMESPACE in data
+            assert SPANDATA.CODE_FILEPATH in data
+            assert SPANDATA.CODE_FUNCTION in data
+
+            assert type(data.get(SPANDATA.CODE_LINENO)) == int
+            assert data.get(SPANDATA.CODE_LINENO) > 0
+            assert (
+                data.get(SPANDATA.CODE_NAMESPACE)
+                == "tests.integrations.sqlalchemy.test_sqlalchemy"
+            )
+            assert data.get(SPANDATA.CODE_FILEPATH).endswith(
+                "tests/integrations/sqlalchemy/test_sqlalchemy.py"
+            )
+
+            is_relative_path = data.get(SPANDATA.CODE_FILEPATH)[0] != os.sep
+            assert is_relative_path
+
+            assert (
+                data.get(SPANDATA.CODE_FUNCTION)
+                == "test_query_source_if_duration_over_threshold"
+            )
+            break
+    else:
+        raise AssertionError("No db span found")
+
+
+def test_span_origin(sentry_init, capture_events):
+    sentry_init(
+        integrations=[SqlalchemyIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    engine = create_engine(
+        "sqlite:///:memory:", connect_args={"check_same_thread": False}
+    )
+    with start_transaction(name="foo"):
+        with engine.connect() as con:
+            con.execute(text("SELECT 0"))
+
+    (event,) = events
+
+    assert event["contexts"]["trace"]["origin"] == "manual"
+    assert event["spans"][0]["origin"] == "auto.db.sqlalchemy"
diff --git a/tests/integrations/starlette/test_starlette.py b/tests/integrations/starlette/test_starlette.py
index 329048e23c..0cb33e159b 100644
--- a/tests/integrations/starlette/test_starlette.py
+++ b/tests/integrations/starlette/test_starlette.py
@@ -6,23 +6,18 @@
 import os
 import re
 import threading
+import warnings
+from unittest import mock
 
 import pytest
 
-from sentry_sdk import last_event_id, capture_exception
+from sentry_sdk import capture_message, get_baggage, get_traceparent
 from sentry_sdk.integrations.asgi import SentryAsgiMiddleware
-from sentry_sdk.utils import parse_version
-
-try:
-    from unittest import mock  # python 3.3 and above
-except ImportError:
-    import mock  # python < 3.3
-
-from sentry_sdk import capture_message
 from sentry_sdk.integrations.starlette import (
     StarletteIntegration,
     StarletteRequestExtractor,
 )
+from sentry_sdk.utils import parse_version
 
 import starlette
 from starlette.authentication import (
@@ -31,10 +26,12 @@
     AuthenticationError,
     SimpleUser,
 )
+from starlette.exceptions import HTTPException
 from starlette.middleware import Middleware
 from starlette.middleware.authentication import AuthenticationMiddleware
 from starlette.middleware.trustedhost import TrustedHostMiddleware
 from starlette.testclient import TestClient
+from tests.integrations.conftest import parametrize_test_configurable_status_codes
 
 
 STARLETTE_VERSION = parse_version(starlette.__version__)
@@ -97,7 +94,6 @@ async def _mock_receive(msg):
     return msg
 
 
-from sentry_sdk import Hub
 from starlette.templating import Jinja2Templates
 
 
@@ -118,6 +114,9 @@ async def _message(request):
         capture_message("hi")
         return starlette.responses.JSONResponse({"status": "ok"})
 
+    async def _nomessage(request):
+        return starlette.responses.JSONResponse({"status": "ok"})
+
     async def _message_with_id(request):
         capture_message("hi")
         return starlette.responses.JSONResponse({"status": "ok"})
@@ -139,8 +138,7 @@ async def _thread_ids_async(request):
         )
 
     async def _render_template(request):
-        hub = Hub.current
-        capture_message(hub.get_traceparent() + "\n" + hub.get_baggage())
+        capture_message(get_traceparent() + "\n" + get_baggage())
 
         template_context = {
             "request": request,
@@ -148,12 +146,25 @@ async def _render_template(request):
         }
         return templates.TemplateResponse("trace_meta.html", template_context)
 
+    all_methods = [
+        "CONNECT",
+        "DELETE",
+        "GET",
+        "HEAD",
+        "OPTIONS",
+        "PATCH",
+        "POST",
+        "PUT",
+        "TRACE",
+    ]
+
     app = starlette.applications.Starlette(
         debug=debug,
         routes=[
             starlette.routing.Route("/some_url", _homepage),
             starlette.routing.Route("/custom_error", _custom_error),
             starlette.routing.Route("/message", _message),
+            starlette.routing.Route("/nomessage", _nomessage, methods=all_methods),
             starlette.routing.Route("/message/{message_id}", _message_with_id),
             starlette.routing.Route("/sync/thread_ids", _thread_ids_sync),
             starlette.routing.Route("/async/thread_ids", _thread_ids_async),
@@ -226,6 +237,12 @@ async def do_stuff(message):
         await self.app(scope, receive, do_stuff)
 
 
+class SampleMiddlewareWithArgs(Middleware):
+    def __init__(self, app, bla=None):
+        self.app = app
+        self.bla = bla
+
+
 class SampleReceiveSendMiddleware:
     def __init__(self, app):
         self.app = app
@@ -266,7 +283,7 @@ async def my_send(*args, **kwargs):
 
 
 @pytest.mark.asyncio
-async def test_starlettrequestextractor_content_length(sentry_init):
+async def test_starletterequestextractor_content_length(sentry_init):
     scope = SCOPE.copy()
     scope["headers"] = [
         [b"content-length", str(len(json.dumps(BODY_JSON))).encode()],
@@ -278,7 +295,7 @@ async def test_starlettrequestextractor_content_length(sentry_init):
 
 
 @pytest.mark.asyncio
-async def test_starlettrequestextractor_cookies(sentry_init):
+async def test_starletterequestextractor_cookies(sentry_init):
     starlette_request = starlette.requests.Request(SCOPE)
     extractor = StarletteRequestExtractor(starlette_request)
 
@@ -289,7 +306,7 @@ async def test_starlettrequestextractor_cookies(sentry_init):
 
 
 @pytest.mark.asyncio
-async def test_starlettrequestextractor_json(sentry_init):
+async def test_starletterequestextractor_json(sentry_init):
     starlette_request = starlette.requests.Request(SCOPE)
 
     # Mocking async `_receive()` that works in Python 3.7+
@@ -303,7 +320,7 @@ async def test_starlettrequestextractor_json(sentry_init):
 
 
 @pytest.mark.asyncio
-async def test_starlettrequestextractor_form(sentry_init):
+async def test_starletterequestextractor_form(sentry_init):
     scope = SCOPE.copy()
     scope["headers"] = [
         [b"content-type", b"multipart/form-data; boundary=fd721ef49ea403a6"],
@@ -331,7 +348,7 @@ async def test_starlettrequestextractor_form(sentry_init):
 
 
 @pytest.mark.asyncio
-async def test_starlettrequestextractor_body_consumed_twice(
+async def test_starletterequestextractor_body_consumed_twice(
     sentry_init, capture_events
 ):
     """
@@ -369,7 +386,7 @@ async def test_starlettrequestextractor_body_consumed_twice(
 
 
 @pytest.mark.asyncio
-async def test_starlettrequestextractor_extract_request_info_too_big(sentry_init):
+async def test_starletterequestextractor_extract_request_info_too_big(sentry_init):
     sentry_init(
         send_default_pii=True,
         integrations=[StarletteIntegration()],
@@ -400,7 +417,7 @@ async def test_starlettrequestextractor_extract_request_info_too_big(sentry_init
 
 
 @pytest.mark.asyncio
-async def test_starlettrequestextractor_extract_request_info(sentry_init):
+async def test_starletterequestextractor_extract_request_info(sentry_init):
     sentry_init(
         send_default_pii=True,
         integrations=[StarletteIntegration()],
@@ -431,7 +448,7 @@ async def test_starlettrequestextractor_extract_request_info(sentry_init):
 
 
 @pytest.mark.asyncio
-async def test_starlettrequestextractor_extract_request_info_no_pii(sentry_init):
+async def test_starletterequestextractor_extract_request_info_no_pii(sentry_init):
     sentry_init(
         send_default_pii=False,
         integrations=[StarletteIntegration()],
@@ -629,7 +646,7 @@ def test_user_information_transaction_no_pii(sentry_init, capture_events):
 def test_middleware_spans(sentry_init, capture_events):
     sentry_init(
         traces_sample_rate=1.0,
-        integrations=[StarletteIntegration()],
+        integrations=[StarletteIntegration(middleware_spans=True)],
     )
     starlette_app = starlette_app_factory(
         middleware=[Middleware(AuthenticationMiddleware, backend=BasicAuthBackend())]
@@ -644,20 +661,49 @@ def test_middleware_spans(sentry_init, capture_events):
 
     (_, transaction_event) = events
 
-    expected = [
+    expected_middleware_spans = [
         "ServerErrorMiddleware",
         "AuthenticationMiddleware",
         "ExceptionMiddleware",
+        "AuthenticationMiddleware",  # 'op': 'middleware.starlette.send'
+        "ServerErrorMiddleware",  # 'op': 'middleware.starlette.send'
+        "AuthenticationMiddleware",  # 'op': 'middleware.starlette.send'
+        "ServerErrorMiddleware",  # 'op': 'middleware.starlette.send'
     ]
 
+    assert len(transaction_event["spans"]) == len(expected_middleware_spans)
+
     idx = 0
     for span in transaction_event["spans"]:
-        if span["op"] == "middleware.starlette":
-            assert span["description"] == expected[idx]
-            assert span["tags"]["starlette.middleware_name"] == expected[idx]
+        if span["op"].startswith("middleware.starlette"):
+            assert (
+                span["tags"]["starlette.middleware_name"]
+                == expected_middleware_spans[idx]
+            )
             idx += 1
 
 
+def test_middleware_spans_disabled(sentry_init, capture_events):
+    sentry_init(
+        traces_sample_rate=1.0,
+        integrations=[StarletteIntegration(middleware_spans=False)],
+    )
+    starlette_app = starlette_app_factory(
+        middleware=[Middleware(AuthenticationMiddleware, backend=BasicAuthBackend())]
+    )
+    events = capture_events()
+
+    client = TestClient(starlette_app, raise_server_exceptions=False)
+    try:
+        client.get("/message", auth=("Gabriela", "hello123"))
+    except Exception:
+        pass
+
+    (_, transaction_event) = events
+
+    assert len(transaction_event["spans"]) == 0
+
+
 def test_middleware_callback_spans(sentry_init, capture_events):
     sentry_init(
         traces_sample_rate=1.0,
@@ -779,9 +825,11 @@ def test_middleware_partial_receive_send(sentry_init, capture_events):
         },
         {
             "op": "middleware.starlette.receive",
-            "description": "_ASGIAdapter.send..receive"
-            if STARLETTE_VERSION < (0, 21)
-            else "_TestClientTransport.handle_request..receive",
+            "description": (
+                "_ASGIAdapter.send..receive"
+                if STARLETTE_VERSION < (0, 21)
+                else "_TestClientTransport.handle_request..receive"
+            ),
             "tags": {"starlette.middleware_name": "ServerErrorMiddleware"},
         },
         {
@@ -819,28 +867,20 @@ def test_middleware_partial_receive_send(sentry_init, capture_events):
         idx += 1
 
 
-def test_last_event_id(sentry_init, capture_events):
+@pytest.mark.skipif(
+    STARLETTE_VERSION < (0, 35),
+    reason="Positional args for middleware have been introduced in Starlette >= 0.35",
+)
+def test_middleware_positional_args(sentry_init):
     sentry_init(
+        traces_sample_rate=1.0,
         integrations=[StarletteIntegration()],
     )
-    events = capture_events()
-
-    def handler(request, exc):
-        capture_exception(exc)
-        return starlette.responses.PlainTextResponse(last_event_id(), status_code=500)
+    _ = starlette_app_factory(middleware=[Middleware(SampleMiddlewareWithArgs, "bla")])
 
-    app = starlette_app_factory(debug=False)
-    app.add_exception_handler(500, handler)
-
-    client = TestClient(SentryAsgiMiddleware(app), raise_server_exceptions=False)
-    response = client.get("/custom_error")
-    assert response.status_code == 500
-
-    event = events[0]
-    assert response.content.strip().decode("ascii") == event["event_id"]
-    (exception,) = event["exception"]["values"]
-    assert exception["type"] == "Exception"
-    assert exception["value"] == "Too Hot"
+    # Only creating the App with an Middleware with args
+    # should not raise an error
+    # So as long as test passes, we are good
 
 
 def test_legacy_setup(
@@ -864,11 +904,11 @@ def test_legacy_setup(
 
 
 @pytest.mark.parametrize("endpoint", ["/sync/thread_ids", "/async/thread_ids"])
-@mock.patch("sentry_sdk.profiler.PROFILE_MINIMUM_SAMPLES", 0)
+@mock.patch("sentry_sdk.profiler.transaction_profiler.PROFILE_MINIMUM_SAMPLES", 0)
 def test_active_thread_id(sentry_init, capture_envelopes, teardown_profiling, endpoint):
     sentry_init(
         traces_sample_rate=1.0,
-        _experiments={"profiles_sample_rate": 1.0},
+        profiles_sample_rate=1.0,
     )
     app = starlette_app_factory()
     asgi_app = SentryAsgiMiddleware(app)
@@ -887,11 +927,19 @@ def test_active_thread_id(sentry_init, capture_envelopes, teardown_profiling, en
     profiles = [item for item in envelopes[0].items if item.type == "profile"]
     assert len(profiles) == 1
 
-    for profile in profiles:
-        transactions = profile.payload.json["transactions"]
+    for item in profiles:
+        transactions = item.payload.json["transactions"]
         assert len(transactions) == 1
         assert str(data["active"]) == transactions[0]["active_thread_id"]
 
+    transactions = [item for item in envelopes[0].items if item.type == "transaction"]
+    assert len(transactions) == 1
+
+    for item in transactions:
+        transaction = item.payload.json
+        trace_context = transaction["contexts"]["trace"]
+        assert str(data["active"]) == trace_context["data"]["thread.id"]
+
 
 def test_original_request_not_scrubbed(sentry_init, capture_events):
     sentry_init(integrations=[StarletteIntegration()])
@@ -948,9 +996,8 @@ def test_template_tracing_meta(sentry_init, capture_events):
     assert match is not None
     assert match.group(1) == traceparent
 
-    # Python 2 does not preserve sort order
     rendered_baggage = match.group(2)
-    assert sorted(rendered_baggage.split(",")) == sorted(baggage.split(","))
+    assert rendered_baggage == baggage
 
 
 @pytest.mark.parametrize(
@@ -985,7 +1032,6 @@ def test_transaction_name(
         auto_enabling_integrations=False,  # Make sure that httpx integration is not added, because it adds tracing information to the starlette test clients request.
         integrations=[StarletteIntegration(transaction_style=transaction_style)],
         traces_sample_rate=1.0,
-        debug=True,
     )
 
     envelopes = capture_envelopes()
@@ -1046,7 +1092,6 @@ def dummy_traces_sampler(sampling_context):
         integrations=[StarletteIntegration(transaction_style=transaction_style)],
         traces_sampler=dummy_traces_sampler,
         traces_sample_rate=1.0,
-        debug=True,
     )
 
     app = starlette_app_factory()
@@ -1054,6 +1099,7 @@ def dummy_traces_sampler(sampling_context):
     client.get(request_url)
 
 
+@pytest.mark.parametrize("middleware_spans", [False, True])
 @pytest.mark.parametrize(
     "request_url,transaction_style,expected_transaction_name,expected_transaction_source",
     [
@@ -1073,6 +1119,7 @@ def dummy_traces_sampler(sampling_context):
 )
 def test_transaction_name_in_middleware(
     sentry_init,
+    middleware_spans,
     request_url,
     transaction_style,
     expected_transaction_name,
@@ -1085,10 +1132,11 @@ def test_transaction_name_in_middleware(
     sentry_init(
         auto_enabling_integrations=False,  # Make sure that httpx integration is not added, because it adds tracing information to the starlette test clients request.
         integrations=[
-            StarletteIntegration(transaction_style=transaction_style),
+            StarletteIntegration(
+                transaction_style=transaction_style, middleware_spans=middleware_spans
+            ),
         ],
         traces_sample_rate=1.0,
-        debug=True,
     )
 
     envelopes = capture_envelopes()
@@ -1112,3 +1160,226 @@ def test_transaction_name_in_middleware(
     assert (
         transaction_event["transaction_info"]["source"] == expected_transaction_source
     )
+
+
+def test_span_origin(sentry_init, capture_events):
+    sentry_init(
+        integrations=[StarletteIntegration()],
+        traces_sample_rate=1.0,
+    )
+    starlette_app = starlette_app_factory(
+        middleware=[Middleware(AuthenticationMiddleware, backend=BasicAuthBackend())]
+    )
+    events = capture_events()
+
+    client = TestClient(starlette_app, raise_server_exceptions=False)
+    try:
+        client.get("/message", auth=("Gabriela", "hello123"))
+    except Exception:
+        pass
+
+    (_, event) = events
+
+    assert event["contexts"]["trace"]["origin"] == "auto.http.starlette"
+    for span in event["spans"]:
+        assert span["origin"] == "auto.http.starlette"
+
+
+class NonIterableContainer:
+    """Wraps any container and makes it non-iterable.
+
+    Used to test backwards compatibility with our old way of defining failed_request_status_codes, which allowed
+    passing in a list of (possibly non-iterable) containers. The Python standard library does not provide any built-in
+    non-iterable containers, so we have to define our own.
+    """
+
+    def __init__(self, inner):
+        self.inner = inner
+
+    def __contains__(self, item):
+        return item in self.inner
+
+
+parametrize_test_configurable_status_codes_deprecated = pytest.mark.parametrize(
+    "failed_request_status_codes,status_code,expected_error",
+    [
+        (None, 500, True),
+        (None, 400, False),
+        ([500, 501], 500, True),
+        ([500, 501], 401, False),
+        ([range(400, 499)], 401, True),
+        ([range(400, 499)], 500, False),
+        ([range(400, 499), range(500, 599)], 300, False),
+        ([range(400, 499), range(500, 599)], 403, True),
+        ([range(400, 499), range(500, 599)], 503, True),
+        ([range(400, 403), 500, 501], 401, True),
+        ([range(400, 403), 500, 501], 405, False),
+        ([range(400, 403), 500, 501], 501, True),
+        ([range(400, 403), 500, 501], 503, False),
+        ([], 500, False),
+        ([NonIterableContainer(range(500, 600))], 500, True),
+        ([NonIterableContainer(range(500, 600))], 404, False),
+    ],
+)
+"""Test cases for configurable status codes (deprecated API).
+Also used by the FastAPI tests.
+"""
+
+
+@parametrize_test_configurable_status_codes_deprecated
+def test_configurable_status_codes_deprecated(
+    sentry_init,
+    capture_events,
+    failed_request_status_codes,
+    status_code,
+    expected_error,
+):
+    with pytest.warns(DeprecationWarning):
+        starlette_integration = StarletteIntegration(
+            failed_request_status_codes=failed_request_status_codes
+        )
+
+    sentry_init(integrations=[starlette_integration])
+
+    events = capture_events()
+
+    async def _error(request):
+        raise HTTPException(status_code)
+
+    app = starlette.applications.Starlette(
+        routes=[
+            starlette.routing.Route("/error", _error, methods=["GET"]),
+        ],
+    )
+
+    client = TestClient(app)
+    client.get("/error")
+
+    if expected_error:
+        assert len(events) == 1
+    else:
+        assert not events
+
+
+@pytest.mark.skipif(
+    STARLETTE_VERSION < (0, 21),
+    reason="Requires Starlette >= 0.21, because earlier versions do not support HTTP 'HEAD' requests",
+)
+def test_transaction_http_method_default(sentry_init, capture_events):
+    """
+    By default OPTIONS and HEAD requests do not create a transaction.
+    """
+    sentry_init(
+        traces_sample_rate=1.0,
+        integrations=[
+            StarletteIntegration(),
+        ],
+    )
+    events = capture_events()
+
+    starlette_app = starlette_app_factory()
+
+    client = TestClient(starlette_app)
+    client.get("/nomessage")
+    client.options("/nomessage")
+    client.head("/nomessage")
+
+    assert len(events) == 1
+
+    (event,) = events
+
+    assert event["request"]["method"] == "GET"
+
+
+@pytest.mark.skipif(
+    STARLETTE_VERSION < (0, 21),
+    reason="Requires Starlette >= 0.21, because earlier versions do not support HTTP 'HEAD' requests",
+)
+def test_transaction_http_method_custom(sentry_init, capture_events):
+    sentry_init(
+        traces_sample_rate=1.0,
+        integrations=[
+            StarletteIntegration(
+                http_methods_to_capture=(
+                    "OPTIONS",
+                    "head",
+                ),  # capitalization does not matter
+            ),
+        ],
+        debug=True,
+    )
+    events = capture_events()
+
+    starlette_app = starlette_app_factory()
+
+    client = TestClient(starlette_app)
+    client.get("/nomessage")
+    client.options("/nomessage")
+    client.head("/nomessage")
+
+    assert len(events) == 2
+
+    (event1, event2) = events
+
+    assert event1["request"]["method"] == "OPTIONS"
+    assert event2["request"]["method"] == "HEAD"
+
+
+@parametrize_test_configurable_status_codes
+def test_configurable_status_codes(
+    sentry_init,
+    capture_events,
+    failed_request_status_codes,
+    status_code,
+    expected_error,
+):
+    integration_kwargs = {}
+    if failed_request_status_codes is not None:
+        integration_kwargs["failed_request_status_codes"] = failed_request_status_codes
+
+    with warnings.catch_warnings():
+        warnings.simplefilter("error", DeprecationWarning)
+        starlette_integration = StarletteIntegration(**integration_kwargs)
+
+    sentry_init(integrations=[starlette_integration])
+
+    events = capture_events()
+
+    async def _error(_):
+        raise HTTPException(status_code)
+
+    app = starlette.applications.Starlette(
+        routes=[
+            starlette.routing.Route("/error", _error, methods=["GET"]),
+        ],
+    )
+
+    client = TestClient(app)
+    client.get("/error")
+
+    assert len(events) == int(expected_error)
+
+
+@pytest.mark.asyncio
+async def test_starletterequestextractor_malformed_json_error_handling(sentry_init):
+    scope = SCOPE.copy()
+    scope["headers"] = [
+        [b"content-type", b"application/json"],
+    ]
+    starlette_request = starlette.requests.Request(scope)
+
+    malformed_json = "{invalid json"
+    malformed_messages = [
+        {"type": "http.request", "body": malformed_json.encode("utf-8")},
+        {"type": "http.disconnect"},
+    ]
+
+    side_effect = [_mock_receive(msg) for msg in malformed_messages]
+    starlette_request._receive = mock.Mock(side_effect=side_effect)
+
+    extractor = StarletteRequestExtractor(starlette_request)
+
+    assert extractor.is_json()
+
+    result = await extractor.json()
+    assert result is None
diff --git a/tests/integrations/starlite/test_starlite.py b/tests/integrations/starlite/test_starlite.py
index 4fbcf65c03..2c3aa704f5 100644
--- a/tests/integrations/starlite/test_starlite.py
+++ b/tests/integrations/starlite/test_starlite.py
@@ -1,64 +1,19 @@
+from __future__ import annotations
 import functools
 
 import pytest
 
-from sentry_sdk import capture_exception, capture_message, last_event_id
+from sentry_sdk import capture_message
 from sentry_sdk.integrations.starlite import StarliteIntegration
 
 from typing import Any, Dict
 
-import starlite
 from starlite import AbstractMiddleware, LoggingConfig, Starlite, get, Controller
 from starlite.middleware import LoggingMiddlewareConfig, RateLimitConfig
 from starlite.middleware.session.memory_backend import MemoryBackendConfig
-from starlite.status_codes import HTTP_500_INTERNAL_SERVER_ERROR
 from starlite.testing import TestClient
 
 
-class SampleMiddleware(AbstractMiddleware):
-    async def __call__(self, scope, receive, send) -> None:
-        async def do_stuff(message):
-            if message["type"] == "http.response.start":
-                # do something here.
-                pass
-            await send(message)
-
-        await self.app(scope, receive, do_stuff)
-
-
-class SampleReceiveSendMiddleware(AbstractMiddleware):
-    async def __call__(self, scope, receive, send):
-        message = await receive()
-        assert message
-        assert message["type"] == "http.request"
-
-        send_output = await send({"type": "something-unimportant"})
-        assert send_output is None
-
-        await self.app(scope, receive, send)
-
-
-class SamplePartialReceiveSendMiddleware(AbstractMiddleware):
-    async def __call__(self, scope, receive, send):
-        message = await receive()
-        assert message
-        assert message["type"] == "http.request"
-
-        send_output = await send({"type": "something-unimportant"})
-        assert send_output is None
-
-        async def my_receive(*args, **kwargs):
-            pass
-
-        async def my_send(*args, **kwargs):
-            pass
-
-        partial_receive = functools.partial(my_receive)
-        partial_send = functools.partial(my_send)
-
-        await self.app(scope, partial_receive, partial_send)
-
-
 def starlite_app_factory(middleware=None, debug=True, exception_handlers=None):
     class MyController(Controller):
         path = "/controller"
@@ -68,7 +23,7 @@ async def controller_error(self) -> None:
             raise Exception("Whoa")
 
     @get("/some_url")
-    async def homepage_handler() -> Dict[str, Any]:
+    async def homepage_handler() -> "Dict[str, Any]":
         1 / 0
         return {"status": "ok"}
 
@@ -77,12 +32,12 @@ async def custom_error() -> Any:
         raise Exception("Too Hot")
 
     @get("/message")
-    async def message() -> Dict[str, Any]:
+    async def message() -> "Dict[str, Any]":
         capture_message("hi")
         return {"status": "ok"}
 
     @get("/message/{message_id:str}")
-    async def message_with_id() -> Dict[str, Any]:
+    async def message_with_id() -> "Dict[str, Any]":
         capture_message("hi")
         return {"status": "ok"}
 
@@ -153,8 +108,8 @@ def test_catch_exceptions(
     assert str(exc) == expected_message
 
     (event,) = events
-    assert event["exception"]["values"][0]["mechanism"]["type"] == "starlite"
     assert event["transaction"] == expected_tx_name
+    assert event["exception"]["values"][0]["mechanism"]["type"] == "starlite"
 
 
 def test_middleware_spans(sentry_init, capture_events):
@@ -179,40 +134,50 @@ def test_middleware_spans(sentry_init, capture_events):
     client = TestClient(
         starlite_app, raise_server_exceptions=False, base_url="http://testserver.local"
     )
-    try:
-        client.get("/message")
-    except Exception:
-        pass
+    client.get("/message")
 
     (_, transaction_event) = events
 
-    expected = ["SessionMiddleware", "LoggingMiddleware", "RateLimitMiddleware"]
+    expected = {"SessionMiddleware", "LoggingMiddleware", "RateLimitMiddleware"}
+    found = set()
+
+    starlite_spans = (
+        span
+        for span in transaction_event["spans"]
+        if span["op"] == "middleware.starlite"
+    )
 
-    idx = 0
-    for span in transaction_event["spans"]:
-        if span["op"] == "middleware.starlite":
-            assert span["description"] == expected[idx]
-            assert span["tags"]["starlite.middleware_name"] == expected[idx]
-            idx += 1
+    for span in starlite_spans:
+        assert span["description"] in expected
+        assert span["description"] not in found
+        found.add(span["description"])
+        assert span["description"] == span["tags"]["starlite.middleware_name"]
 
 
 def test_middleware_callback_spans(sentry_init, capture_events):
+    class SampleMiddleware(AbstractMiddleware):
+        async def __call__(self, scope, receive, send) -> None:
+            async def do_stuff(message):
+                if message["type"] == "http.response.start":
+                    # do something here.
+                    pass
+                await send(message)
+
+            await self.app(scope, receive, do_stuff)
+
     sentry_init(
         traces_sample_rate=1.0,
         integrations=[StarliteIntegration()],
     )
-    starlette_app = starlite_app_factory(middleware=[SampleMiddleware])
+    starlite_app = starlite_app_factory(middleware=[SampleMiddleware])
     events = capture_events()
 
-    client = TestClient(starlette_app, raise_server_exceptions=False)
-    try:
-        client.get("/message")
-    except Exception:
-        pass
+    client = TestClient(starlite_app, raise_server_exceptions=False)
+    client.get("/message")
 
-    (_, transaction_event) = events
+    (_, transaction_events) = events
 
-    expected = [
+    expected_starlite_spans = [
         {
             "op": "middleware.starlite",
             "description": "SampleMiddleware",
@@ -229,50 +194,86 @@ def test_middleware_callback_spans(sentry_init, capture_events):
             "tags": {"starlite.middleware_name": "SampleMiddleware"},
         },
     ]
-    print(transaction_event["spans"])
-    idx = 0
-    for span in transaction_event["spans"]:
-        assert span["op"] == expected[idx]["op"]
-        assert span["description"] == expected[idx]["description"]
-        assert span["tags"] == expected[idx]["tags"]
-        idx += 1
+
+    def is_matching_span(expected_span, actual_span):
+        return (
+            expected_span["op"] == actual_span["op"]
+            and expected_span["description"] == actual_span["description"]
+            and expected_span["tags"] == actual_span["tags"]
+        )
+
+    actual_starlite_spans = list(
+        span
+        for span in transaction_events["spans"]
+        if "middleware.starlite" in span["op"]
+    )
+    assert len(actual_starlite_spans) == 3
+
+    for expected_span in expected_starlite_spans:
+        assert any(
+            is_matching_span(expected_span, actual_span)
+            for actual_span in actual_starlite_spans
+        )
 
 
 def test_middleware_receive_send(sentry_init, capture_events):
+    class SampleReceiveSendMiddleware(AbstractMiddleware):
+        async def __call__(self, scope, receive, send):
+            message = await receive()
+            assert message
+            assert message["type"] == "http.request"
+
+            send_output = await send({"type": "something-unimportant"})
+            assert send_output is None
+
+            await self.app(scope, receive, send)
+
     sentry_init(
         traces_sample_rate=1.0,
         integrations=[StarliteIntegration()],
     )
-    starlette_app = starlite_app_factory(middleware=[SampleReceiveSendMiddleware])
+    starlite_app = starlite_app_factory(middleware=[SampleReceiveSendMiddleware])
 
-    client = TestClient(starlette_app, raise_server_exceptions=False)
-    try:
-        # NOTE: the assert statements checking
-        # for correct behaviour are in `SampleReceiveSendMiddleware`!
-        client.get("/message")
-    except Exception:
-        pass
+    client = TestClient(starlite_app, raise_server_exceptions=False)
+    # See SampleReceiveSendMiddleware.__call__ above for assertions of correct behavior
+    client.get("/message")
 
 
 def test_middleware_partial_receive_send(sentry_init, capture_events):
+    class SamplePartialReceiveSendMiddleware(AbstractMiddleware):
+        async def __call__(self, scope, receive, send):
+            message = await receive()
+            assert message
+            assert message["type"] == "http.request"
+
+            send_output = await send({"type": "something-unimportant"})
+            assert send_output is None
+
+            async def my_receive(*args, **kwargs):
+                pass
+
+            async def my_send(*args, **kwargs):
+                pass
+
+            partial_receive = functools.partial(my_receive)
+            partial_send = functools.partial(my_send)
+
+            await self.app(scope, partial_receive, partial_send)
+
     sentry_init(
         traces_sample_rate=1.0,
         integrations=[StarliteIntegration()],
     )
-    starlette_app = starlite_app_factory(
-        middleware=[SamplePartialReceiveSendMiddleware]
-    )
+    starlite_app = starlite_app_factory(middleware=[SamplePartialReceiveSendMiddleware])
     events = capture_events()
 
-    client = TestClient(starlette_app, raise_server_exceptions=False)
-    try:
-        client.get("/message")
-    except Exception:
-        pass
+    client = TestClient(starlite_app, raise_server_exceptions=False)
+    # See SamplePartialReceiveSendMiddleware.__call__ above for assertions of correct behavior
+    client.get("/message")
 
-    (_, transaction_event) = events
+    (_, transaction_events) = events
 
-    expected = [
+    expected_starlite_spans = [
         {
             "op": "middleware.starlite",
             "description": "SamplePartialReceiveSendMiddleware",
@@ -290,34 +291,105 @@ def test_middleware_partial_receive_send(sentry_init, capture_events):
         },
     ]
 
-    idx = 0
-    for span in transaction_event["spans"]:
-        assert span["op"] == expected[idx]["op"]
-        assert span["description"].startswith(expected[idx]["description"])
-        assert span["tags"] == expected[idx]["tags"]
-        idx += 1
+    def is_matching_span(expected_span, actual_span):
+        return (
+            expected_span["op"] == actual_span["op"]
+            and actual_span["description"].startswith(expected_span["description"])
+            and expected_span["tags"] == actual_span["tags"]
+        )
+
+    actual_starlite_spans = list(
+        span
+        for span in transaction_events["spans"]
+        if "middleware.starlite" in span["op"]
+    )
+    assert len(actual_starlite_spans) == 3
+
+    for expected_span in expected_starlite_spans:
+        assert any(
+            is_matching_span(expected_span, actual_span)
+            for actual_span in actual_starlite_spans
+        )
 
 
-def test_last_event_id(sentry_init, capture_events):
+def test_span_origin(sentry_init, capture_events):
     sentry_init(
         integrations=[StarliteIntegration()],
+        traces_sample_rate=1.0,
+    )
+
+    logging_config = LoggingMiddlewareConfig()
+    session_config = MemoryBackendConfig()
+    rate_limit_config = RateLimitConfig(rate_limit=("hour", 5))
+
+    starlite_app = starlite_app_factory(
+        middleware=[
+            session_config.middleware,
+            logging_config.middleware,
+            rate_limit_config.middleware,
+        ]
     )
     events = capture_events()
 
-    def handler(request, exc):
-        capture_exception(exc)
-        return starlite.response.Response(last_event_id(), status_code=500)
+    client = TestClient(
+        starlite_app, raise_server_exceptions=False, base_url="http://testserver.local"
+    )
+    client.get("/message")
+
+    (_, event) = events
+
+    assert event["contexts"]["trace"]["origin"] == "auto.http.starlite"
+    for span in event["spans"]:
+        assert span["origin"] == "auto.http.starlite"
+
+
+@pytest.mark.parametrize(
+    "is_send_default_pii",
+    [
+        True,
+        False,
+    ],
+    ids=[
+        "send_default_pii=True",
+        "send_default_pii=False",
+    ],
+)
+def test_starlite_scope_user_on_exception_event(
+    sentry_init, capture_exceptions, capture_events, is_send_default_pii
+):
+    class TestUserMiddleware(AbstractMiddleware):
+        async def __call__(self, scope, receive, send):
+            scope["user"] = {
+                "email": "lennon@thebeatles.com",
+                "username": "john",
+                "id": "1",
+            }
+            await self.app(scope, receive, send)
 
-    app = starlite_app_factory(
-        debug=False, exception_handlers={HTTP_500_INTERNAL_SERVER_ERROR: handler}
+    sentry_init(
+        integrations=[StarliteIntegration()], send_default_pii=is_send_default_pii
     )
+    starlite_app = starlite_app_factory(middleware=[TestUserMiddleware])
+    exceptions = capture_exceptions()
+    events = capture_events()
+
+    # This request intentionally raises an exception
+    client = TestClient(starlite_app)
+    try:
+        client.get("/some_url")
+    except Exception:
+        pass
+
+    assert len(exceptions) == 1
+    assert len(events) == 1
+    (event,) = events
 
-    client = TestClient(app, raise_server_exceptions=False)
-    response = client.get("/custom_error")
-    assert response.status_code == 500
-    print(events)
-    event = events[-1]
-    assert response.content.strip().decode("ascii").strip('"') == event["event_id"]
-    (exception,) = event["exception"]["values"]
-    assert exception["type"] == "Exception"
-    assert exception["value"] == "Too Hot"
+    if is_send_default_pii:
+        assert "user" in event
+        assert event["user"] == {
+            "email": "lennon@thebeatles.com",
+            "username": "john",
+            "id": "1",
+        }
+    else:
+        assert "user" not in event
diff --git a/tests/integrations/statsig/__init__.py b/tests/integrations/statsig/__init__.py
new file mode 100644
index 0000000000..6abc08235b
--- /dev/null
+++ b/tests/integrations/statsig/__init__.py
@@ -0,0 +1,3 @@
+import pytest
+
+pytest.importorskip("statsig")
diff --git a/tests/integrations/statsig/test_statsig.py b/tests/integrations/statsig/test_statsig.py
new file mode 100644
index 0000000000..5eb2cf39f3
--- /dev/null
+++ b/tests/integrations/statsig/test_statsig.py
@@ -0,0 +1,203 @@
+import concurrent.futures as cf
+import sys
+from contextlib import contextmanager
+from statsig import statsig
+from statsig.statsig_user import StatsigUser
+from random import random
+from unittest.mock import Mock
+from sentry_sdk import start_span, start_transaction
+from tests.conftest import ApproxDict
+
+import pytest
+
+import sentry_sdk
+from sentry_sdk.integrations.statsig import StatsigIntegration
+
+
+@contextmanager
+def mock_statsig(gate_dict):
+    old_check_gate = statsig.check_gate
+
+    def mock_check_gate(user, gate, *args, **kwargs):
+        return gate_dict.get(gate, False)
+
+    statsig.check_gate = Mock(side_effect=mock_check_gate)
+
+    yield
+
+    statsig.check_gate = old_check_gate
+
+
+def test_check_gate(sentry_init, capture_events, uninstall_integration):
+    uninstall_integration(StatsigIntegration.identifier)
+
+    with mock_statsig({"hello": True, "world": False}):
+        sentry_init(integrations=[StatsigIntegration()])
+        events = capture_events()
+        user = StatsigUser(user_id="user-id")
+
+        statsig.check_gate(user, "hello")
+        statsig.check_gate(user, "world")
+        statsig.check_gate(user, "other")  # unknown gates default to False.
+
+        sentry_sdk.capture_exception(Exception("something wrong!"))
+
+        assert len(events) == 1
+        assert events[0]["contexts"]["flags"] == {
+            "values": [
+                {"flag": "hello", "result": True},
+                {"flag": "world", "result": False},
+                {"flag": "other", "result": False},
+            ]
+        }
+
+
+def test_check_gate_threaded(sentry_init, capture_events, uninstall_integration):
+    uninstall_integration(StatsigIntegration.identifier)
+
+    with mock_statsig({"hello": True, "world": False}):
+        sentry_init(integrations=[StatsigIntegration()])
+        events = capture_events()
+        user = StatsigUser(user_id="user-id")
+
+        # Capture an eval before we split isolation scopes.
+        statsig.check_gate(user, "hello")
+
+        def task(flag_key):
+            # Creates a new isolation scope for the thread.
+            # This means the evaluations in each task are captured separately.
+            with sentry_sdk.isolation_scope():
+                statsig.check_gate(user, flag_key)
+                # use a tag to identify to identify events later on
+                sentry_sdk.set_tag("task_id", flag_key)
+                sentry_sdk.capture_exception(Exception("something wrong!"))
+
+        with cf.ThreadPoolExecutor(max_workers=2) as pool:
+            pool.map(task, ["world", "other"])
+
+        # Capture error in original scope
+        sentry_sdk.set_tag("task_id", "0")
+        sentry_sdk.capture_exception(Exception("something wrong!"))
+
+        assert len(events) == 3
+        events.sort(key=lambda e: e["tags"]["task_id"])
+
+        assert events[0]["contexts"]["flags"] == {
+            "values": [
+                {"flag": "hello", "result": True},
+            ]
+        }
+        assert events[1]["contexts"]["flags"] == {
+            "values": [
+                {"flag": "hello", "result": True},
+                {"flag": "other", "result": False},
+            ]
+        }
+        assert events[2]["contexts"]["flags"] == {
+            "values": [
+                {"flag": "hello", "result": True},
+                {"flag": "world", "result": False},
+            ]
+        }
+
+
+@pytest.mark.skipif(sys.version_info < (3, 7), reason="requires python3.7 or higher")
+def test_check_gate_asyncio(sentry_init, capture_events, uninstall_integration):
+    asyncio = pytest.importorskip("asyncio")
+    uninstall_integration(StatsigIntegration.identifier)
+
+    with mock_statsig({"hello": True, "world": False}):
+        sentry_init(integrations=[StatsigIntegration()])
+        events = capture_events()
+        user = StatsigUser(user_id="user-id")
+
+        # Capture an eval before we split isolation scopes.
+        statsig.check_gate(user, "hello")
+
+        async def task(flag_key):
+            with sentry_sdk.isolation_scope():
+                statsig.check_gate(user, flag_key)
+                # use a tag to identify to identify events later on
+                sentry_sdk.set_tag("task_id", flag_key)
+                sentry_sdk.capture_exception(Exception("something wrong!"))
+
+        async def runner():
+            return asyncio.gather(task("world"), task("other"))
+
+        asyncio.run(runner())
+
+        # Capture error in original scope
+        sentry_sdk.set_tag("task_id", "0")
+        sentry_sdk.capture_exception(Exception("something wrong!"))
+
+        assert len(events) == 3
+        events.sort(key=lambda e: e["tags"]["task_id"])
+
+        assert events[0]["contexts"]["flags"] == {
+            "values": [
+                {"flag": "hello", "result": True},
+            ]
+        }
+        assert events[1]["contexts"]["flags"] == {
+            "values": [
+                {"flag": "hello", "result": True},
+                {"flag": "other", "result": False},
+            ]
+        }
+        assert events[2]["contexts"]["flags"] == {
+            "values": [
+                {"flag": "hello", "result": True},
+                {"flag": "world", "result": False},
+            ]
+        }
+
+
+def test_wraps_original(sentry_init, uninstall_integration):
+    uninstall_integration(StatsigIntegration.identifier)
+    flag_value = random() < 0.5
+
+    with mock_statsig(
+        {"test-flag": flag_value}
+    ):  # patches check_gate with a Mock object.
+        mock_check_gate = statsig.check_gate
+        sentry_init(integrations=[StatsigIntegration()])  # wraps check_gate.
+        user = StatsigUser(user_id="user-id")
+
+        res = statsig.check_gate(user, "test-flag", "extra-arg", kwarg=1)  # type: ignore[arg-type]
+
+        assert res == flag_value
+        assert mock_check_gate.call_args == (  # type: ignore[attr-defined]
+            (user, "test-flag", "extra-arg"),
+            {"kwarg": 1},
+        )
+
+
+def test_wrapper_attributes(sentry_init, uninstall_integration):
+    uninstall_integration(StatsigIntegration.identifier)
+    original_check_gate = statsig.check_gate
+    sentry_init(integrations=[StatsigIntegration()])
+
+    # Methods have not lost their qualified names after decoration.
+    assert statsig.check_gate.__name__ == "check_gate"
+    assert statsig.check_gate.__qualname__ == original_check_gate.__qualname__
+
+    # Clean up
+    statsig.check_gate = original_check_gate
+
+
+def test_statsig_span_integration(sentry_init, capture_events, uninstall_integration):
+    uninstall_integration(StatsigIntegration.identifier)
+
+    with mock_statsig({"hello": True}):
+        sentry_init(traces_sample_rate=1.0, integrations=[StatsigIntegration()])
+        events = capture_events()
+        user = StatsigUser(user_id="user-id")
+        with start_transaction(name="hi"):
+            with start_span(op="foo", name="bar"):
+                statsig.check_gate(user, "hello")
+                statsig.check_gate(user, "world")
+
+    (event,) = events
+    assert event["spans"][0]["data"] == ApproxDict(
+        {"flag.evaluation.hello": True, "flag.evaluation.world": False}
+    )
diff --git a/tests/integrations/stdlib/__init__.py b/tests/integrations/stdlib/__init__.py
new file mode 100644
index 0000000000..472e0151b2
--- /dev/null
+++ b/tests/integrations/stdlib/__init__.py
@@ -0,0 +1,6 @@
+import os
+import sys
+
+# Load `httplib_helpers` into the module search path to test request source path names relative to module. See
+# `test_request_source_with_module_in_search_path`
+sys.path.insert(0, os.path.join(os.path.dirname(__file__)))
diff --git a/tests/integrations/stdlib/httplib_helpers/__init__.py b/tests/integrations/stdlib/httplib_helpers/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/integrations/stdlib/httplib_helpers/helpers.py b/tests/integrations/stdlib/httplib_helpers/helpers.py
new file mode 100644
index 0000000000..875052e7b5
--- /dev/null
+++ b/tests/integrations/stdlib/httplib_helpers/helpers.py
@@ -0,0 +1,3 @@
+def get_request_with_connection(connection, url):
+    connection.request("GET", url)
+    connection.getresponse()
diff --git a/tests/integrations/stdlib/test_httplib.py b/tests/integrations/stdlib/test_httplib.py
index d50bf42e21..588c3b34f4 100644
--- a/tests/integrations/stdlib/test_httplib.py
+++ b/tests/integrations/stdlib/test_httplib.py
@@ -1,33 +1,18 @@
-import random
+import os
+import datetime
+from http.client import HTTPConnection, HTTPSConnection
+from socket import SocketIO
+from urllib.error import HTTPError
+from urllib.request import urlopen
+from unittest import mock
 
 import pytest
 
-try:
-    # py3
-    from urllib.request import urlopen
-except ImportError:
-    # py2
-    from urllib import urlopen
-
-try:
-    # py2
-    from httplib import HTTPConnection, HTTPSConnection
-except ImportError:
-    # py3
-    from http.client import HTTPConnection, HTTPSConnection
-
-try:
-    from unittest import mock  # python 3.3 and above
-except ImportError:
-    import mock  # python < 3.3
-
-
-from sentry_sdk import capture_message, start_transaction
+from sentry_sdk import capture_message, start_transaction, continue_trace
 from sentry_sdk.consts import MATCH_ALL, SPANDATA
-from sentry_sdk.tracing import Transaction
 from sentry_sdk.integrations.stdlib import StdlibIntegration
 
-from tests.conftest import create_mock_http_server
+from tests.conftest import ApproxDict, create_mock_http_server
 
 PORT = create_mock_http_server()
 
@@ -46,14 +31,60 @@ def test_crumb_capture(sentry_init, capture_events):
 
     assert crumb["type"] == "http"
     assert crumb["category"] == "httplib"
-    assert crumb["data"] == {
-        "url": url,
-        SPANDATA.HTTP_METHOD: "GET",
-        SPANDATA.HTTP_STATUS_CODE: 200,
-        "reason": "OK",
-        SPANDATA.HTTP_FRAGMENT: "",
-        SPANDATA.HTTP_QUERY: "",
-    }
+    assert crumb["data"] == ApproxDict(
+        {
+            "url": url,
+            SPANDATA.HTTP_METHOD: "GET",
+            SPANDATA.HTTP_STATUS_CODE: 200,
+            "reason": "OK",
+            SPANDATA.HTTP_FRAGMENT: "",
+            SPANDATA.HTTP_QUERY: "",
+        }
+    )
+
+
+@pytest.mark.parametrize(
+    "status_code,level",
+    [
+        (200, None),
+        (301, None),
+        (403, "warning"),
+        (405, "warning"),
+        (500, "error"),
+    ],
+)
+def test_crumb_capture_client_error(sentry_init, capture_events, status_code, level):
+    sentry_init(integrations=[StdlibIntegration()])
+    events = capture_events()
+
+    url = f"http://localhost:{PORT}/status/{status_code}"  # noqa:E231
+    try:
+        urlopen(url)
+    except HTTPError:
+        pass
+
+    capture_message("Testing!")
+
+    (event,) = events
+    (crumb,) = event["breadcrumbs"]["values"]
+
+    assert crumb["type"] == "http"
+    assert crumb["category"] == "httplib"
+
+    if level is None:
+        assert "level" not in crumb
+    else:
+        assert crumb["level"] == level
+
+    assert crumb["data"] == ApproxDict(
+        {
+            "url": url,
+            SPANDATA.HTTP_METHOD: "GET",
+            SPANDATA.HTTP_STATUS_CODE: status_code,
+            SPANDATA.HTTP_FRAGMENT: "",
+            SPANDATA.HTTP_QUERY: "",
+        }
+    )
 
 
 def test_crumb_capture_hint(sentry_init, capture_events):
@@ -73,15 +104,17 @@ def before_breadcrumb(crumb, hint):
     (crumb,) = event["breadcrumbs"]["values"]
     assert crumb["type"] == "http"
     assert crumb["category"] == "httplib"
-    assert crumb["data"] == {
-        "url": url,
-        SPANDATA.HTTP_METHOD: "GET",
-        SPANDATA.HTTP_STATUS_CODE: 200,
-        "reason": "OK",
-        "extra": "foo",
-        SPANDATA.HTTP_FRAGMENT: "",
-        SPANDATA.HTTP_QUERY: "",
-    }
+    assert crumb["data"] == ApproxDict(
+        {
+            "url": url,
+            SPANDATA.HTTP_METHOD: "GET",
+            SPANDATA.HTTP_STATUS_CODE: 200,
+            "reason": "OK",
+            "extra": "foo",
+            SPANDATA.HTTP_FRAGMENT: "",
+            SPANDATA.HTTP_QUERY: "",
+        }
+    )
 
 
 def test_empty_realurl(sentry_init):
@@ -91,7 +124,7 @@ def test_empty_realurl(sentry_init):
     """
 
     sentry_init(dsn="")
-    HTTPConnection("example.com", port=443).putrequest("POST", None)
+    HTTPConnection("localhost", port=PORT).putrequest("POST", None)
 
 
 def test_httplib_misuse(sentry_init, capture_events, request):
@@ -131,14 +164,16 @@ def test_httplib_misuse(sentry_init, capture_events, request):
 
     assert crumb["type"] == "http"
     assert crumb["category"] == "httplib"
-    assert crumb["data"] == {
-        "url": "http://localhost:{}/200".format(PORT),
-        SPANDATA.HTTP_METHOD: "GET",
-        SPANDATA.HTTP_STATUS_CODE: 200,
-        "reason": "OK",
-        SPANDATA.HTTP_FRAGMENT: "",
-        SPANDATA.HTTP_QUERY: "",
-    }
+    assert crumb["data"] == ApproxDict(
+        {
+            "url": "http://localhost:{}/200".format(PORT),
+            SPANDATA.HTTP_METHOD: "GET",
+            SPANDATA.HTTP_STATUS_CODE: 200,
+            "reason": "OK",
+            SPANDATA.HTTP_FRAGMENT: "",
+            SPANDATA.HTTP_QUERY: "",
+        }
+    )
 
 
 def test_outgoing_trace_headers(sentry_init, monkeypatch):
@@ -150,14 +185,16 @@ def test_outgoing_trace_headers(sentry_init, monkeypatch):
 
     sentry_init(traces_sample_rate=1.0)
 
-    headers = {}
-    headers["baggage"] = (
-        "other-vendor-value-1=foo;bar;baz, sentry-trace_id=771a43a4192642f0b136d5159a501700, "
-        "sentry-public_key=49d0f7386ad645858ae85020e393bef3, sentry-sample_rate=0.01337, "
-        "sentry-user_id=Am%C3%A9lie, other-vendor-value-2=foo;bar;"
-    )
+    headers = {
+        "sentry-trace": "771a43a4192642f0b136d5159a501700-1234567890abcdef-1",
+        "baggage": (
+            "other-vendor-value-1=foo;bar;baz, sentry-trace_id=771a43a4192642f0b136d5159a501700, "
+            "sentry-public_key=49d0f7386ad645858ae85020e393bef3, sentry-sample_rate=0.01337, "
+            "sentry-user_id=Am%C3%A9lie, sentry-sample_rand=0.132521102938283, other-vendor-value-2=foo;bar;"
+        ),
+    }
 
-    transaction = Transaction.continue_from_headers(headers)
+    transaction = continue_trace(headers)
 
     with start_transaction(
         transaction=transaction,
@@ -182,17 +219,16 @@ def test_outgoing_trace_headers(sentry_init, monkeypatch):
         )
         assert request_headers["sentry-trace"] == expected_sentry_trace
 
-        expected_outgoing_baggage_items = [
-            "sentry-trace_id=771a43a4192642f0b136d5159a501700",
-            "sentry-public_key=49d0f7386ad645858ae85020e393bef3",
-            "sentry-sample_rate=0.01337",
-            "sentry-user_id=Am%C3%A9lie",
-        ]
-
-        assert sorted(request_headers["baggage"].split(",")) == sorted(
-            expected_outgoing_baggage_items
+        expected_outgoing_baggage = (
+            "sentry-trace_id=771a43a4192642f0b136d5159a501700,"
+            "sentry-public_key=49d0f7386ad645858ae85020e393bef3,"
+            "sentry-sample_rate=1.0,"
+            "sentry-user_id=Am%C3%A9lie,"
+            "sentry-sample_rand=0.132521102938283"
         )
 
+        assert request_headers["baggage"] == expected_outgoing_baggage
+
 
 def test_outgoing_trace_headers_head_sdk(sentry_init, monkeypatch):
     # HTTPSConnection.send is passed a string containing (among other things)
@@ -201,11 +237,9 @@ def test_outgoing_trace_headers_head_sdk(sentry_init, monkeypatch):
     mock_send = mock.Mock()
     monkeypatch.setattr(HTTPSConnection, "send", mock_send)
 
-    # make sure transaction is always sampled
-    monkeypatch.setattr(random, "random", lambda: 0.1)
-
     sentry_init(traces_sample_rate=0.5, release="foo")
-    transaction = Transaction.continue_from_headers({})
+    with mock.patch("sentry_sdk.tracing_utils.Random.randrange", return_value=250000):
+        transaction = continue_trace({})
 
     with start_transaction(transaction=transaction, name="Head SDK tx") as transaction:
         HTTPSConnection("www.squirrelchasers.com").request("GET", "/top-chasers")
@@ -225,17 +259,16 @@ def test_outgoing_trace_headers_head_sdk(sentry_init, monkeypatch):
         )
         assert request_headers["sentry-trace"] == expected_sentry_trace
 
-        expected_outgoing_baggage_items = [
-            "sentry-trace_id=%s" % transaction.trace_id,
-            "sentry-sample_rate=0.5",
-            "sentry-sampled=%s" % "true" if transaction.sampled else "false",
-            "sentry-release=foo",
-            "sentry-environment=production",
-        ]
+        expected_outgoing_baggage = (
+            "sentry-trace_id=%s,"
+            "sentry-sample_rand=0.250000,"
+            "sentry-environment=production,"
+            "sentry-release=foo,"
+            "sentry-sample_rate=0.5,"
+            "sentry-sampled=%s"
+        ) % (transaction.trace_id, "true" if transaction.sampled else "false")
 
-        assert sorted(request_headers["baggage"].split(",")) == sorted(
-            expected_outgoing_baggage_items
-        )
+        assert request_headers["baggage"] == expected_outgoing_baggage
 
 
 @pytest.mark.parametrize(
@@ -318,7 +351,7 @@ def test_option_trace_propagation_targets(
         )
     }
 
-    transaction = Transaction.continue_from_headers(headers)
+    transaction = continue_trace(headers)
 
     with start_transaction(
         transaction=transaction,
@@ -341,3 +374,271 @@ def test_option_trace_propagation_targets(
         else:
             assert "sentry-trace" not in request_headers
             assert "baggage" not in request_headers
+
+
+def test_request_source_disabled(sentry_init, capture_events):
+    sentry_options = {
+        "traces_sample_rate": 1.0,
+        "enable_http_request_source": False,
+        "http_request_source_threshold_ms": 0,
+    }
+
+    sentry_init(**sentry_options)
+
+    events = capture_events()
+
+    with start_transaction(name="foo"):
+        conn = HTTPConnection("localhost", port=PORT)
+        conn.request("GET", "/foo")
+        conn.getresponse()
+
+    (event,) = events
+
+    span = event["spans"][-1]
+    assert span["description"].startswith("GET")
+
+    data = span.get("data", {})
+
+    assert SPANDATA.CODE_LINENO not in data
+    assert SPANDATA.CODE_NAMESPACE not in data
+    assert SPANDATA.CODE_FILEPATH not in data
+    assert SPANDATA.CODE_FUNCTION not in data
+
+
+@pytest.mark.parametrize("enable_http_request_source", [None, True])
+def test_request_source_enabled(
+    sentry_init, capture_events, enable_http_request_source
+):
+    sentry_options = {
+        "traces_sample_rate": 1.0,
+        "http_request_source_threshold_ms": 0,
+    }
+    if enable_http_request_source is not None:
+        sentry_options["enable_http_request_source"] = enable_http_request_source
+
+    sentry_init(**sentry_options)
+
+    events = capture_events()
+
+    with start_transaction(name="foo"):
+        conn = HTTPConnection("localhost", port=PORT)
+        conn.request("GET", "/foo")
+        conn.getresponse()
+
+    (event,) = events
+
+    span = event["spans"][-1]
+    assert span["description"].startswith("GET")
+
+    data = span.get("data", {})
+
+    assert SPANDATA.CODE_LINENO in data
+    assert SPANDATA.CODE_NAMESPACE in data
+    assert SPANDATA.CODE_FILEPATH in data
+    assert SPANDATA.CODE_FUNCTION in data
+
+
+def test_request_source(sentry_init, capture_events):
+    sentry_init(
+        traces_sample_rate=1.0,
+        enable_http_request_source=True,
+        http_request_source_threshold_ms=0,
+    )
+
+    events = capture_events()
+
+    with start_transaction(name="foo"):
+        conn = HTTPConnection("localhost", port=PORT)
+        conn.request("GET", "/foo")
+        conn.getresponse()
+
+    (event,) = events
+
+    span = event["spans"][-1]
+    assert span["description"].startswith("GET")
+
+    data = span.get("data", {})
+
+    assert SPANDATA.CODE_LINENO in data
+    assert SPANDATA.CODE_NAMESPACE in data
+    assert SPANDATA.CODE_FILEPATH in data
+    assert SPANDATA.CODE_FUNCTION in data
+
+    assert type(data.get(SPANDATA.CODE_LINENO)) == int
+    assert data.get(SPANDATA.CODE_LINENO) > 0
+    assert data.get(SPANDATA.CODE_NAMESPACE) == "tests.integrations.stdlib.test_httplib"
+    assert data.get(SPANDATA.CODE_FILEPATH).endswith(
+        "tests/integrations/stdlib/test_httplib.py"
+    )
+
+    is_relative_path = data.get(SPANDATA.CODE_FILEPATH)[0] != os.sep
+    assert is_relative_path
+
+    assert data.get(SPANDATA.CODE_FUNCTION) == "test_request_source"
+
+
+def test_request_source_with_module_in_search_path(sentry_init, capture_events):
+    """
+    Test that request source is relative to the path of the module it ran in
+    """
+    sentry_init(
+        traces_sample_rate=1.0,
+        enable_http_request_source=True,
+        http_request_source_threshold_ms=0,
+    )
+
+    events = capture_events()
+
+    with start_transaction(name="foo"):
+        from httplib_helpers.helpers import get_request_with_connection
+
+        conn = HTTPConnection("localhost", port=PORT)
+        get_request_with_connection(conn, "/foo")
+
+    (event,) = events
+
+    span = event["spans"][-1]
+    assert span["description"].startswith("GET")
+
+    data = span.get("data", {})
+
+    assert SPANDATA.CODE_LINENO in data
+    assert SPANDATA.CODE_NAMESPACE in data
+    assert SPANDATA.CODE_FILEPATH in data
+    assert SPANDATA.CODE_FUNCTION in data
+
+    assert type(data.get(SPANDATA.CODE_LINENO)) == int
+    assert data.get(SPANDATA.CODE_LINENO) > 0
+    assert data.get(SPANDATA.CODE_NAMESPACE) == "httplib_helpers.helpers"
+    assert data.get(SPANDATA.CODE_FILEPATH) == "httplib_helpers/helpers.py"
+
+    is_relative_path = data.get(SPANDATA.CODE_FILEPATH)[0] != os.sep
+    assert is_relative_path
+
+    assert data.get(SPANDATA.CODE_FUNCTION) == "get_request_with_connection"
+
+
+def test_no_request_source_if_duration_too_short(sentry_init, capture_events):
+    sentry_init(
+        traces_sample_rate=1.0,
+        enable_http_request_source=True,
+        http_request_source_threshold_ms=100,
+    )
+
+    already_patched_putrequest = HTTPConnection.putrequest
+
+    class HttpConnectionWithPatchedSpan(HTTPConnection):
+        def putrequest(self, *args, **kwargs) -> None:
+            already_patched_putrequest(self, *args, **kwargs)
+            span = self._sentrysdk_span  # type: ignore
+            span.start_timestamp = datetime.datetime(2024, 1, 1, microsecond=0)
+            span.timestamp = datetime.datetime(2024, 1, 1, microsecond=99999)
+
+    events = capture_events()
+
+    with start_transaction(name="foo"):
+        conn = HttpConnectionWithPatchedSpan("localhost", port=PORT)
+        conn.request("GET", "/foo")
+        conn.getresponse()
+
+    (event,) = events
+
+    span = event["spans"][-1]
+    assert span["description"].startswith("GET")
+
+    data = span.get("data", {})
+
+    assert SPANDATA.CODE_LINENO not in data
+    assert SPANDATA.CODE_NAMESPACE not in data
+    assert SPANDATA.CODE_FILEPATH not in data
+    assert SPANDATA.CODE_FUNCTION not in data
+
+
+def test_request_source_if_duration_over_threshold(sentry_init, capture_events):
+    sentry_init(
+        traces_sample_rate=1.0,
+        enable_http_request_source=True,
+        http_request_source_threshold_ms=100,
+    )
+
+    already_patched_putrequest = HTTPConnection.putrequest
+
+    class HttpConnectionWithPatchedSpan(HTTPConnection):
+        def putrequest(self, *args, **kwargs) -> None:
+            already_patched_putrequest(self, *args, **kwargs)
+            span = self._sentrysdk_span  # type: ignore
+            span.start_timestamp = datetime.datetime(2024, 1, 1, microsecond=0)
+            span.timestamp = datetime.datetime(2024, 1, 1, microsecond=100001)
+
+    events = capture_events()
+
+    with start_transaction(name="foo"):
+        conn = HttpConnectionWithPatchedSpan("localhost", port=PORT)
+        conn.request("GET", "/foo")
+        conn.getresponse()
+
+    (event,) = events
+
+    span = event["spans"][-1]
+    assert span["description"].startswith("GET")
+
+    data = span.get("data", {})
+
+    assert SPANDATA.CODE_LINENO in data
+    assert SPANDATA.CODE_NAMESPACE in data
+    assert SPANDATA.CODE_FILEPATH in data
+    assert SPANDATA.CODE_FUNCTION in data
+
+    assert type(data.get(SPANDATA.CODE_LINENO)) == int
+    assert data.get(SPANDATA.CODE_LINENO) > 0
+    assert data.get(SPANDATA.CODE_NAMESPACE) == "tests.integrations.stdlib.test_httplib"
+    assert data.get(SPANDATA.CODE_FILEPATH).endswith(
+        "tests/integrations/stdlib/test_httplib.py"
+    )
+
+    is_relative_path = data.get(SPANDATA.CODE_FILEPATH)[0] != os.sep
+    assert is_relative_path
+
+    assert (
+        data.get(SPANDATA.CODE_FUNCTION)
+        == "test_request_source_if_duration_over_threshold"
+    )
+
+
+def test_span_origin(sentry_init, capture_events):
+    sentry_init(traces_sample_rate=1.0, debug=True)
+    events = capture_events()
+
+    with start_transaction(name="foo"):
+        conn = HTTPConnection("localhost", port=PORT)
+        conn.request("GET", "/foo")
+        conn.getresponse()
+
+    (event,) = events
+    assert event["contexts"]["trace"]["origin"] == "manual"
+
+    assert event["spans"][0]["op"] == "http.client"
+    assert event["spans"][0]["origin"] == "auto.http.stdlib.httplib"
+
+
+def test_http_timeout(monkeypatch, sentry_init, capture_envelopes):
+    mock_readinto = mock.Mock(side_effect=TimeoutError)
+    monkeypatch.setattr(SocketIO, "readinto", mock_readinto)
+
+    sentry_init(traces_sample_rate=1.0)
+
+    envelopes = capture_envelopes()
+
+    with pytest.raises(TimeoutError):
+        with start_transaction(op="op", name="name"):
+            conn = HTTPConnection("localhost", port=PORT)
+            conn.request("GET", "/bla")
+            conn.getresponse()
+
+    (transaction_envelope,) = envelopes
+    transaction = transaction_envelope.get_transaction_event()
+    assert len(transaction["spans"]) == 1
+
+    span = transaction["spans"][0]
+    assert span["op"] == "http.client"
+    assert span["description"] == f"GET http://localhost:{PORT}/bla"  # noqa: E231
diff --git a/tests/integrations/stdlib/test_subprocess.py b/tests/integrations/stdlib/test_subprocess.py
index 31da043ac3..593ef8a0dc 100644
--- a/tests/integrations/stdlib/test_subprocess.py
+++ b/tests/integrations/stdlib/test_subprocess.py
@@ -2,18 +2,13 @@
 import platform
 import subprocess
 import sys
+from collections.abc import Mapping
 
 import pytest
 
 from sentry_sdk import capture_message, start_transaction
-from sentry_sdk._compat import PY2
 from sentry_sdk.integrations.stdlib import StdlibIntegration
-
-
-if PY2:
-    from collections import Mapping
-else:
-    from collections.abc import Mapping
+from tests.conftest import ApproxDict
 
 
 class ImmutableDict(Mapping):
@@ -125,7 +120,7 @@ def test_subprocess_basic(
 
     assert message_event["message"] == "hi"
 
-    data = {"subprocess.cwd": os.getcwd()} if with_cwd else {}
+    data = ApproxDict({"subprocess.cwd": os.getcwd()} if with_cwd else {})
 
     (crumb,) = message_event["breadcrumbs"]["values"]
     assert crumb == {
@@ -179,6 +174,19 @@ def test_subprocess_basic(
         assert sys.executable + " -c" in subprocess_init_span["description"]
 
 
+def test_subprocess_empty_env(sentry_init, monkeypatch):
+    monkeypatch.setenv("TEST_MARKER", "should_not_be_seen")
+    sentry_init(integrations=[StdlibIntegration()], traces_sample_rate=1.0)
+    with start_transaction(name="foo"):
+        args = [
+            sys.executable,
+            "-c",
+            "import os; print(os.environ.get('TEST_MARKER', None))",
+        ]
+        output = subprocess.check_output(args, env={}, universal_newlines=True)
+    assert "should_not_be_seen" not in output
+
+
 def test_subprocess_invalid_args(sentry_init):
     sentry_init(integrations=[StdlibIntegration()])
 
@@ -186,3 +194,33 @@ def test_subprocess_invalid_args(sentry_init):
         subprocess.Popen(1)
 
     assert "'int' object is not iterable" in str(excinfo.value)
+
+
+def test_subprocess_span_origin(sentry_init, capture_events):
+    sentry_init(integrations=[StdlibIntegration()], traces_sample_rate=1.0)
+    events = capture_events()
+
+    with start_transaction(name="foo"):
+        args = [
+            sys.executable,
+            "-c",
+            "print('hello world')",
+        ]
+        kw = {"args": args, "stdout": subprocess.PIPE}
+
+        popen = subprocess.Popen(**kw)
+        popen.communicate()
+        popen.poll()
+
+    (event,) = events
+
+    assert event["contexts"]["trace"]["origin"] == "manual"
+
+    assert event["spans"][0]["op"] == "subprocess"
+    assert event["spans"][0]["origin"] == "auto.subprocess.stdlib.subprocess"
+
+    assert event["spans"][1]["op"] == "subprocess.communicate"
+    assert event["spans"][1]["origin"] == "auto.subprocess.stdlib.subprocess"
+
+    assert event["spans"][2]["op"] == "subprocess.wait"
+    assert event["spans"][2]["origin"] == "auto.subprocess.stdlib.subprocess"
diff --git a/tests/integrations/strawberry/test_strawberry_py3.py b/tests/integrations/strawberry/test_strawberry.py
similarity index 70%
rename from tests/integrations/strawberry/test_strawberry_py3.py
rename to tests/integrations/strawberry/test_strawberry.py
index b357779461..ba645da257 100644
--- a/tests/integrations/strawberry/test_strawberry_py3.py
+++ b/tests/integrations/strawberry/test_strawberry.py
@@ -1,4 +1,5 @@
 import pytest
+from typing import AsyncGenerator, Optional
 
 strawberry = pytest.importorskip("strawberry")
 pytest.importorskip("fastapi")
@@ -9,10 +10,6 @@
 from fastapi import FastAPI
 from fastapi.testclient import TestClient
 from flask import Flask
-from strawberry.extensions.tracing import (
-    SentryTracingExtension,
-    SentryTracingExtensionSync,
-)
 from strawberry.fastapi import GraphQLRouter
 from strawberry.flask.views import GraphQLView
 
@@ -25,7 +22,16 @@
     SentryAsyncExtension,
     SentrySyncExtension,
 )
+from tests.conftest import ApproxDict
 
+try:
+    from strawberry.extensions.tracing import (
+        SentryTracingExtension,
+        SentryTracingExtensionSync,
+    )
+except ImportError:
+    SentryTracingExtension = None
+    SentryTracingExtensionSync = None
 
 parameterize_strawberry_test = pytest.mark.parametrize(
     "client_factory,async_execution,framework_integrations",
@@ -58,6 +64,19 @@ def change(self, attribute: str) -> str:
         return attribute
 
 
+@strawberry.type
+class Message:
+    content: str
+
+
+@strawberry.type
+class Subscription:
+    @strawberry.subscription
+    async def message_added(self) -> Optional[AsyncGenerator[Message, None]]:
+        message = Message(content="Hello, world!")
+        yield message
+
+
 @pytest.fixture
 def async_app_client_factory():
     def create_app(schema):
@@ -84,51 +103,30 @@ def create_app(schema):
 def test_async_execution_uses_async_extension(sentry_init):
     sentry_init(integrations=[StrawberryIntegration(async_execution=True)])
 
-    with mock.patch(
-        "sentry_sdk.integrations.strawberry._get_installed_modules",
-        return_value={"flask": "2.3.3"},
-    ):
-        # actual installed modules should not matter, the explicit option takes
-        # precedence
-        schema = strawberry.Schema(Query)
-        assert SentryAsyncExtension in schema.extensions
+    schema = strawberry.Schema(Query)
+    assert SentryAsyncExtension in schema.extensions
+    assert SentrySyncExtension not in schema.extensions
 
 
 def test_sync_execution_uses_sync_extension(sentry_init):
     sentry_init(integrations=[StrawberryIntegration(async_execution=False)])
 
-    with mock.patch(
-        "sentry_sdk.integrations.strawberry._get_installed_modules",
-        return_value={"fastapi": "0.103.1", "starlette": "0.27.0"},
-    ):
-        # actual installed modules should not matter, the explicit option takes
-        # precedence
-        schema = strawberry.Schema(Query)
-        assert SentrySyncExtension in schema.extensions
-
-
-def test_infer_execution_type_from_installed_packages_async(sentry_init):
-    sentry_init(integrations=[StrawberryIntegration()])
-
-    with mock.patch(
-        "sentry_sdk.integrations.strawberry._get_installed_modules",
-        return_value={"fastapi": "0.103.1", "starlette": "0.27.0"},
-    ):
-        schema = strawberry.Schema(Query)
-        assert SentryAsyncExtension in schema.extensions
+    schema = strawberry.Schema(Query)
+    assert SentrySyncExtension in schema.extensions
+    assert SentryAsyncExtension not in schema.extensions
 
 
-def test_infer_execution_type_from_installed_packages_sync(sentry_init):
+def test_use_sync_extension_if_not_specified(sentry_init):
     sentry_init(integrations=[StrawberryIntegration()])
-
-    with mock.patch(
-        "sentry_sdk.integrations.strawberry._get_installed_modules",
-        return_value={"flask": "2.3.3"},
-    ):
-        schema = strawberry.Schema(Query)
-        assert SentrySyncExtension in schema.extensions
+    schema = strawberry.Schema(Query)
+    assert SentrySyncExtension in schema.extensions
+    assert SentryAsyncExtension not in schema.extensions
 
 
+@pytest.mark.skipif(
+    SentryTracingExtension is None,
+    reason="SentryTracingExtension no longer available in this Strawberry version",
+)
 def test_replace_existing_sentry_async_extension(sentry_init):
     sentry_init(integrations=[StrawberryIntegration()])
 
@@ -138,6 +136,10 @@ def test_replace_existing_sentry_async_extension(sentry_init):
     assert SentryAsyncExtension in schema.extensions
 
 
+@pytest.mark.skipif(
+    SentryTracingExtensionSync is None,
+    reason="SentryTracingExtensionSync no longer available in this Strawberry version",
+)
 def test_replace_existing_sentry_sync_extension(sentry_init):
     sentry_init(integrations=[StrawberryIntegration()])
 
@@ -310,11 +312,8 @@ def test_capture_transaction_on_error(
     assert len(events) == 2
     (_, transaction_event) = events
 
-    if async_execution:
-        assert transaction_event["transaction"] == "/graphql"
-    else:
-        assert transaction_event["transaction"] == "graphql_view"
-
+    assert transaction_event["transaction"] == "ErrorQuery"
+    assert transaction_event["contexts"]["trace"]["op"] == OP.GRAPHQL_QUERY
     assert transaction_event["spans"]
 
     query_spans = [
@@ -351,12 +350,14 @@ def test_capture_transaction_on_error(
     resolve_span = resolve_spans[0]
     assert resolve_span["parent_span_id"] == query_span["span_id"]
     assert resolve_span["description"] == "resolving Query.error"
-    assert resolve_span["data"] == {
-        "graphql.field_name": "error",
-        "graphql.parent_type": "Query",
-        "graphql.field_path": "Query.error",
-        "graphql.path": "error",
-    }
+    assert resolve_span["data"] == ApproxDict(
+        {
+            "graphql.field_name": "error",
+            "graphql.parent_type": "Query",
+            "graphql.field_path": "Query.error",
+            "graphql.path": "error",
+        }
+    )
 
 
 @parameterize_strawberry_test
@@ -388,11 +389,8 @@ def test_capture_transaction_on_success(
     assert len(events) == 1
     (transaction_event,) = events
 
-    if async_execution:
-        assert transaction_event["transaction"] == "/graphql"
-    else:
-        assert transaction_event["transaction"] == "graphql_view"
-
+    assert transaction_event["transaction"] == "GreetingQuery"
+    assert transaction_event["contexts"]["trace"]["op"] == OP.GRAPHQL_QUERY
     assert transaction_event["spans"]
 
     query_spans = [
@@ -429,12 +427,14 @@ def test_capture_transaction_on_success(
     resolve_span = resolve_spans[0]
     assert resolve_span["parent_span_id"] == query_span["span_id"]
     assert resolve_span["description"] == "resolving Query.hello"
-    assert resolve_span["data"] == {
-        "graphql.field_name": "hello",
-        "graphql.parent_type": "Query",
-        "graphql.field_path": "Query.hello",
-        "graphql.path": "hello",
-    }
+    assert resolve_span["data"] == ApproxDict(
+        {
+            "graphql.field_name": "hello",
+            "graphql.parent_type": "Query",
+            "graphql.field_path": "Query.hello",
+            "graphql.path": "hello",
+        }
+    )
 
 
 @parameterize_strawberry_test
@@ -507,12 +507,14 @@ def test_transaction_no_operation_name(
     resolve_span = resolve_spans[0]
     assert resolve_span["parent_span_id"] == query_span["span_id"]
     assert resolve_span["description"] == "resolving Query.hello"
-    assert resolve_span["data"] == {
-        "graphql.field_name": "hello",
-        "graphql.parent_type": "Query",
-        "graphql.field_path": "Query.hello",
-        "graphql.path": "hello",
-    }
+    assert resolve_span["data"] == ApproxDict(
+        {
+            "graphql.field_name": "hello",
+            "graphql.parent_type": "Query",
+            "graphql.field_path": "Query.hello",
+            "graphql.path": "hello",
+        }
+    )
 
 
 @parameterize_strawberry_test
@@ -544,11 +546,8 @@ def test_transaction_mutation(
     assert len(events) == 1
     (transaction_event,) = events
 
-    if async_execution:
-        assert transaction_event["transaction"] == "/graphql"
-    else:
-        assert transaction_event["transaction"] == "graphql_view"
-
+    assert transaction_event["transaction"] == "Change"
+    assert transaction_event["contexts"]["trace"]["op"] == OP.GRAPHQL_MUTATION
     assert transaction_event["spans"]
 
     query_spans = [
@@ -585,9 +584,164 @@ def test_transaction_mutation(
     resolve_span = resolve_spans[0]
     assert resolve_span["parent_span_id"] == query_span["span_id"]
     assert resolve_span["description"] == "resolving Mutation.change"
-    assert resolve_span["data"] == {
-        "graphql.field_name": "change",
-        "graphql.parent_type": "Mutation",
-        "graphql.field_path": "Mutation.change",
-        "graphql.path": "change",
-    }
+    assert resolve_span["data"] == ApproxDict(
+        {
+            "graphql.field_name": "change",
+            "graphql.parent_type": "Mutation",
+            "graphql.field_path": "Mutation.change",
+            "graphql.path": "change",
+        }
+    )
+
+
+@parameterize_strawberry_test
+def test_handle_none_query_gracefully(
+    request,
+    sentry_init,
+    capture_events,
+    client_factory,
+    async_execution,
+    framework_integrations,
+):
+    sentry_init(
+        integrations=[
+            StrawberryIntegration(async_execution=async_execution),
+        ]
+        + framework_integrations,
+    )
+    events = capture_events()
+
+    schema = strawberry.Schema(Query)
+
+    client_factory = request.getfixturevalue(client_factory)
+    client = client_factory(schema)
+
+    client.post("/graphql", json={})
+
+    assert len(events) == 0, "expected no events to be sent to Sentry"
+
+
+@parameterize_strawberry_test
+def test_span_origin(
+    request,
+    sentry_init,
+    capture_events,
+    client_factory,
+    async_execution,
+    framework_integrations,
+):
+    """
+    Tests for OP.GRAPHQL_MUTATION, OP.GRAPHQL_PARSE, OP.GRAPHQL_VALIDATE, OP.GRAPHQL_RESOLVE,
+    """
+    sentry_init(
+        integrations=[
+            StrawberryIntegration(async_execution=async_execution),
+        ]
+        + framework_integrations,
+        traces_sample_rate=1,
+    )
+    events = capture_events()
+
+    schema = strawberry.Schema(Query, mutation=Mutation)
+
+    client_factory = request.getfixturevalue(client_factory)
+    client = client_factory(schema)
+
+    query = 'mutation Change { change(attribute: "something") }'
+    client.post("/graphql", json={"query": query})
+
+    (event,) = events
+
+    is_flask = "Flask" in str(framework_integrations[0])
+    if is_flask:
+        assert event["contexts"]["trace"]["origin"] == "auto.http.flask"
+    else:
+        assert event["contexts"]["trace"]["origin"] == "auto.http.starlette"
+
+    for span in event["spans"]:
+        if span["op"].startswith("graphql."):
+            assert span["origin"] == "auto.graphql.strawberry"
+
+
+@parameterize_strawberry_test
+def test_span_origin2(
+    request,
+    sentry_init,
+    capture_events,
+    client_factory,
+    async_execution,
+    framework_integrations,
+):
+    """
+    Tests for OP.GRAPHQL_QUERY
+    """
+    sentry_init(
+        integrations=[
+            StrawberryIntegration(async_execution=async_execution),
+        ]
+        + framework_integrations,
+        traces_sample_rate=1,
+    )
+    events = capture_events()
+
+    schema = strawberry.Schema(Query, mutation=Mutation)
+
+    client_factory = request.getfixturevalue(client_factory)
+    client = client_factory(schema)
+
+    query = "query GreetingQuery { hello }"
+    client.post("/graphql", json={"query": query, "operationName": "GreetingQuery"})
+
+    (event,) = events
+
+    is_flask = "Flask" in str(framework_integrations[0])
+    if is_flask:
+        assert event["contexts"]["trace"]["origin"] == "auto.http.flask"
+    else:
+        assert event["contexts"]["trace"]["origin"] == "auto.http.starlette"
+
+    for span in event["spans"]:
+        if span["op"].startswith("graphql."):
+            assert span["origin"] == "auto.graphql.strawberry"
+
+
+@parameterize_strawberry_test
+def test_span_origin3(
+    request,
+    sentry_init,
+    capture_events,
+    client_factory,
+    async_execution,
+    framework_integrations,
+):
+    """
+    Tests for OP.GRAPHQL_SUBSCRIPTION
+    """
+    sentry_init(
+        integrations=[
+            StrawberryIntegration(async_execution=async_execution),
+        ]
+        + framework_integrations,
+        traces_sample_rate=1,
+    )
+    events = capture_events()
+
+    schema = strawberry.Schema(Query, subscription=Subscription)
+
+    client_factory = request.getfixturevalue(client_factory)
+    client = client_factory(schema)
+
+    query = "subscription { messageAdded { content } }"
+    client.post("/graphql", json={"query": query})
+
+    (event,) = events
+
+    is_flask = "Flask" in str(framework_integrations[0])
+    if is_flask:
+        assert event["contexts"]["trace"]["origin"] == "auto.http.flask"
+    else:
+        assert event["contexts"]["trace"]["origin"] == "auto.http.starlette"
+
+    for span in event["spans"]:
+        if span["op"].startswith("graphql."):
+            assert span["origin"] == "auto.graphql.strawberry"
diff --git a/tests/integrations/sys_exit/test_sys_exit.py b/tests/integrations/sys_exit/test_sys_exit.py
new file mode 100644
index 0000000000..a9909ae3c2
--- /dev/null
+++ b/tests/integrations/sys_exit/test_sys_exit.py
@@ -0,0 +1,71 @@
+import sys
+
+import pytest
+
+from sentry_sdk.integrations.sys_exit import SysExitIntegration
+
+
+@pytest.mark.parametrize(
+    ("integration_params", "exit_status", "should_capture"),
+    (
+        ({}, 0, False),
+        ({}, 1, True),
+        ({}, None, False),
+        ({}, "unsuccessful exit", True),
+        ({"capture_successful_exits": False}, 0, False),
+        ({"capture_successful_exits": False}, 1, True),
+        ({"capture_successful_exits": False}, None, False),
+        ({"capture_successful_exits": False}, "unsuccessful exit", True),
+        ({"capture_successful_exits": True}, 0, True),
+        ({"capture_successful_exits": True}, 1, True),
+        ({"capture_successful_exits": True}, None, True),
+        ({"capture_successful_exits": True}, "unsuccessful exit", True),
+    ),
+)
+def test_sys_exit(
+    sentry_init, capture_events, integration_params, exit_status, should_capture
+):
+    sentry_init(integrations=[SysExitIntegration(**integration_params)])
+
+    events = capture_events()
+
+    # Manually catch the sys.exit rather than using pytest.raises because IDE does not recognize that pytest.raises
+    # will catch SystemExit.
+    try:
+        sys.exit(exit_status)
+    except SystemExit:
+        ...
+    else:
+        pytest.fail("Patched sys.exit did not raise SystemExit")
+
+    if should_capture:
+        (event,) = events
+        (exception_value,) = event["exception"]["values"]
+
+        assert exception_value["type"] == "SystemExit"
+        assert exception_value["value"] == (
+            str(exit_status) if exit_status is not None else ""
+        )
+    else:
+        assert len(events) == 0
+
+
+def test_sys_exit_integration_not_auto_enabled(sentry_init, capture_events):
+    sentry_init()  # No SysExitIntegration
+
+    events = capture_events()
+
+    # Manually catch the sys.exit rather than using pytest.raises because IDE does not recognize that pytest.raises
+    # will catch SystemExit.
+    try:
+        sys.exit(1)
+    except SystemExit:
+        ...
+    else:
+        pytest.fail(
+            "sys.exit should not be patched, but it must have been because it did not raise SystemExit"
+        )
+
+    assert len(events) == 0, (
+        "No events should have been captured because sys.exit should not have been patched"
+    )
diff --git a/tests/integrations/test_gnu_backtrace.py b/tests/integrations/test_gnu_backtrace.py
index b91359dfa8..be7346a2c3 100644
--- a/tests/integrations/test_gnu_backtrace.py
+++ b/tests/integrations/test_gnu_backtrace.py
@@ -4,78 +4,65 @@
 from sentry_sdk.integrations.gnu_backtrace import GnuBacktraceIntegration
 
 LINES = r"""
-0. clickhouse-server(StackTrace::StackTrace()+0x16) [0x99d31a6]
-1. clickhouse-server(DB::Exception::Exception(std::__cxx11::basic_string, std::allocator > const&, int)+0x22) [0x372c822]
-10. clickhouse-server(DB::ActionsVisitor::visit(std::shared_ptr const&)+0x1a12) [0x6ae45d2]
-10. clickhouse-server(DB::InterpreterSelectQuery::executeImpl(DB::InterpreterSelectQuery::Pipeline&, std::shared_ptr const&, bool)+0x11af) [0x75c68ff]
-10. clickhouse-server(ThreadPoolImpl::worker(std::_List_iterator)+0x1ab) [0x6f90c1b]
-11. clickhouse-server() [0xae06ddf]
-11. clickhouse-server(DB::ExpressionAnalyzer::getRootActions(std::shared_ptr const&, bool, std::shared_ptr&, bool)+0xdb) [0x6a0a63b]
-11. clickhouse-server(DB::InterpreterSelectQuery::InterpreterSelectQuery(std::shared_ptr const&, DB::Context const&, std::shared_ptr const&, std::shared_ptr const&, std::vector, std::allocator >, std::allocator, std::allocator > > > const&, DB::QueryProcessingStage::Enum, unsigned long, bool)+0x5e6) [0x75c7516]
-12. /lib/x86_64-linux-gnu/libpthread.so.0(+0x8184) [0x7f3bbc568184]
-12. clickhouse-server(DB::ExpressionAnalyzer::getConstActions()+0xc9) [0x6a0b059]
-12. clickhouse-server(DB::InterpreterSelectQuery::InterpreterSelectQuery(std::shared_ptr const&, DB::Context const&, std::vector, std::allocator >, std::allocator, std::allocator > > > const&, DB::QueryProcessingStage::Enum, unsigned long, bool)+0x56) [0x75c8276]
-13. /lib/x86_64-linux-gnu/libc.so.6(clone+0x6d) [0x7f3bbbb8303d]
-13. clickhouse-server(DB::InterpreterSelectWithUnionQuery::InterpreterSelectWithUnionQuery(std::shared_ptr const&, DB::Context const&, std::vector, std::allocator >, std::allocator, std::allocator > > > const&, DB::QueryProcessingStage::Enum, unsigned long, bool)+0x7e7) [0x75d4067]
-13. clickhouse-server(DB::evaluateConstantExpression(std::shared_ptr const&, DB::Context const&)+0x3ed) [0x656bfdd]
-14. clickhouse-server(DB::InterpreterFactory::get(std::shared_ptr&, DB::Context&, DB::QueryProcessingStage::Enum)+0x3a8) [0x75b0298]
-14. clickhouse-server(DB::makeExplicitSet(DB::ASTFunction const*, DB::Block const&, bool, DB::Context const&, DB::SizeLimits const&, std::unordered_map, DB::PreparedSetKey::Hash, std::equal_to, std::allocator > > >&)+0x382) [0x6adf692]
-15. clickhouse-server() [0x7664c79]
-15. clickhouse-server(DB::ActionsVisitor::makeSet(DB::ASTFunction const*, DB::Block const&)+0x2a7) [0x6ae2227]
-16. clickhouse-server(DB::ActionsVisitor::visit(std::shared_ptr const&)+0x1973) [0x6ae4533]
-16. clickhouse-server(DB::executeQuery(std::__cxx11::basic_string, std::allocator > const&, DB::Context&, bool, DB::QueryProcessingStage::Enum)+0x8a) [0x76669fa]
-17. clickhouse-server(DB::ActionsVisitor::visit(std::shared_ptr const&)+0x1324) [0x6ae3ee4]
-17. clickhouse-server(DB::TCPHandler::runImpl()+0x4b9) [0x30973c9]
-18. clickhouse-server(DB::ExpressionAnalyzer::getRootActions(std::shared_ptr const&, bool, std::shared_ptr&, bool)+0xdb) [0x6a0a63b]
-18. clickhouse-server(DB::TCPHandler::run()+0x2b) [0x30985ab]
-19. clickhouse-server(DB::ExpressionAnalyzer::appendGroupBy(DB::ExpressionActionsChain&, bool)+0x100) [0x6a0b4f0]
-19. clickhouse-server(Poco::Net::TCPServerConnection::start()+0xf) [0x9b53e4f]
-2. clickhouse-server(DB::FunctionTuple::getReturnTypeImpl(std::vector, std::allocator > > const&) const+0x122) [0x3a2a0f2]
-2. clickhouse-server(DB::readException(DB::Exception&, DB::ReadBuffer&, std::__cxx11::basic_string, std::allocator > const&)+0x21f) [0x6fb253f]
-2. clickhouse-server(void DB::readDateTimeTextFallback(long&, DB::ReadBuffer&, DateLUTImpl const&)+0x318) [0x99ffed8]
-20. clickhouse-server(DB::InterpreterSelectQuery::analyzeExpressions(DB::QueryProcessingStage::Enum, bool)+0x364) [0x6437fa4]
-20. clickhouse-server(Poco::Net::TCPServerDispatcher::run()+0x16a) [0x9b5422a]
-21. clickhouse-server(DB::InterpreterSelectQuery::executeImpl(DB::InterpreterSelectQuery::Pipeline&, std::shared_ptr const&, bool)+0x36d) [0x643c28d]
-21. clickhouse-server(Poco::PooledThread::run()+0x77) [0x9c70f37]
-22. clickhouse-server(DB::InterpreterSelectQuery::executeWithMultipleStreams()+0x50) [0x643ecd0]
-22. clickhouse-server(Poco::ThreadImpl::runnableEntry(void*)+0x38) [0x9c6caa8]
-23. clickhouse-server() [0xa3c68cf]
-23. clickhouse-server(DB::InterpreterSelectWithUnionQuery::executeWithMultipleStreams()+0x6c) [0x644805c]
-24. /lib/x86_64-linux-gnu/libpthread.so.0(+0x8184) [0x7fe839d2d184]
-24. clickhouse-server(DB::InterpreterSelectWithUnionQuery::execute()+0x38) [0x6448658]
-25. /lib/x86_64-linux-gnu/libc.so.6(clone+0x6d) [0x7fe83934803d]
-25. clickhouse-server() [0x65744ef]
-26. clickhouse-server(DB::executeQuery(std::__cxx11::basic_string, std::allocator > const&, DB::Context&, bool, DB::QueryProcessingStage::Enum, bool)+0x81) [0x6576141]
-27. clickhouse-server(DB::TCPHandler::runImpl()+0x752) [0x3739f82]
-28. clickhouse-server(DB::TCPHandler::run()+0x2b) [0x373a5cb]
-29. clickhouse-server(Poco::Net::TCPServerConnection::start()+0xf) [0x708e63f]
-3. clickhouse-server(DB::Connection::receiveException()+0x81) [0x67d3ad1]
-3. clickhouse-server(DB::DefaultFunctionBuilder::getReturnTypeImpl(std::vector > const&) const+0x223) [0x38ac3b3]
-3. clickhouse-server(DB::FunctionComparison::executeDateOrDateTimeOrEnumOrUUIDWithConstString(DB::Block&, unsigned long, DB::IColumn const*, DB::IColumn const*, std::shared_ptr const&, std::shared_ptr const&, bool, unsigned long)+0xbb3) [0x411dee3]
-30. clickhouse-server(Poco::Net::TCPServerDispatcher::run()+0xe9) [0x708ed79]
-31. clickhouse-server(Poco::PooledThread::run()+0x81) [0x7142011]
-4. clickhouse-server(DB::Connection::receivePacket()+0x767) [0x67d9cd7]
-4. clickhouse-server(DB::FunctionBuilderImpl::getReturnTypeWithoutLowCardinality(std::vector > const&) const+0x75) [0x6869635]
-4. clickhouse-server(DB::FunctionComparison::executeImpl(DB::Block&, std::vector > const&, unsigned long, unsigned long)+0x576) [0x41ab006]
-5. clickhouse-server(DB::FunctionBuilderImpl::getReturnType(std::vector > const&) const+0x350) [0x6869f10]
-5. clickhouse-server(DB::MultiplexedConnections::receivePacket()+0x7e) [0x67e7ede]
-5. clickhouse-server(DB::PreparedFunctionImpl::execute(DB::Block&, std::vector > const&, unsigned long, unsigned long)+0x3e2) [0x7933492]
-6. clickhouse-server(DB::ExpressionAction::execute(DB::Block&, std::unordered_map, std::allocator >, unsigned long, std::hash, std::allocator > >, std::equal_to, std::allocator > >, std::allocator, std::allocator > const, unsigned long> > >&) const+0x61a) [0x7ae093a]
-6. clickhouse-server(DB::FunctionBuilderImpl::build(std::vector > const&) const+0x3c) [0x38accfc]
-6. clickhouse-server(DB::RemoteBlockInputStream::readImpl()+0x87) [0x631da97]
-7. clickhouse-server(DB::ExpressionActions::addImpl(DB::ExpressionAction, std::vector, std::allocator >, std::allocator, std::allocator > > >&)+0x552) [0x6a00052]
-7. clickhouse-server(DB::ExpressionActions::execute(DB::Block&) const+0xe6) [0x7ae1e06]
-7. clickhouse-server(DB::IBlockInputStream::read()+0x178) [0x63075e8]
-8. clickhouse-server(DB::ExpressionActions::add(DB::ExpressionAction const&, std::vector, std::allocator >, std::allocator, std::allocator > > >&)+0x42) [0x6a00422]
-8. clickhouse-server(DB::FilterBlockInputStream::FilterBlockInputStream(std::shared_ptr const&, std::shared_ptr const&, std::__cxx11::basic_string, std::allocator > const&, bool)+0x711) [0x79970d1]
-8. clickhouse-server(DB::ParallelInputsProcessor::thread(std::shared_ptr, unsigned long)+0x2f1) [0x64467c1]
-9. clickhouse-server() [0x75bd5a3]
-9. clickhouse-server(DB::ScopeStack::addAction(DB::ExpressionAction const&)+0xd2) [0x6ae04d2]
-9. clickhouse-server(ThreadFromGlobalPool::ThreadFromGlobalPool::process()::{lambda()#1}>(DB::ParallelInputsProcessor::process()::{lambda()#1}&&)::{lambda()#1}::operator()() const+0x6d) [0x644722d]
+0. DB::Exception::Exception(DB::Exception::MessageMasked&&, int, bool) @ 0x000000000bfc38a4 in /usr/bin/clickhouse
+1. DB::Exception::Exception(int, FormatStringHelperImpl::type, std::type_identity::type>, String&&, String&&) @ 0x00000000075d242c in /usr/bin/clickhouse
+2. DB::ActionsMatcher::visit(DB::ASTIdentifier const&, std::shared_ptr const&, DB::ActionsMatcher::Data&) @ 0x0000000010b1c648 in /usr/bin/clickhouse
+3. DB::ActionsMatcher::visit(DB::ASTFunction const&, std::shared_ptr const&, DB::ActionsMatcher::Data&) @ 0x0000000010b1f58c in /usr/bin/clickhouse
+4. DB::ActionsMatcher::visit(DB::ASTFunction const&, std::shared_ptr const&, DB::ActionsMatcher::Data&) @ 0x0000000010b1f58c in /usr/bin/clickhouse
+5. DB::ActionsMatcher::visit(std::shared_ptr const&, DB::ActionsMatcher::Data&) @ 0x0000000010b1c394 in /usr/bin/clickhouse
+6. DB::InDepthNodeVisitor const>::doVisit(std::shared_ptr const&) @ 0x0000000010b154a0 in /usr/bin/clickhouse
+7. DB::ExpressionAnalyzer::getRootActions(std::shared_ptr const&, bool, std::shared_ptr&, bool) @ 0x0000000010af83b4 in /usr/bin/clickhouse
+8. DB::SelectQueryExpressionAnalyzer::appendSelect(DB::ExpressionActionsChain&, bool) @ 0x0000000010aff168 in /usr/bin/clickhouse
+9. DB::ExpressionAnalysisResult::ExpressionAnalysisResult(DB::SelectQueryExpressionAnalyzer&, std::shared_ptr const&, bool, bool, bool, std::shared_ptr const&, std::shared_ptr const&, DB::Block const&) @ 0x0000000010b05b74 in /usr/bin/clickhouse
+10. DB::InterpreterSelectQuery::getSampleBlockImpl() @ 0x00000000111559fc in /usr/bin/clickhouse
+11. DB::InterpreterSelectQuery::InterpreterSelectQuery(std::shared_ptr const&, std::shared_ptr const&, std::optional, std::shared_ptr const&, DB::SelectQueryOptions const&, std::vector> const&, std::shared_ptr const&, std::shared_ptr)::$_0::operator()(bool) const @ 0x0000000011148254 in /usr/bin/clickhouse
+12. DB::InterpreterSelectQuery::InterpreterSelectQuery(std::shared_ptr const&, std::shared_ptr const&, std::optional, std::shared_ptr const&, DB::SelectQueryOptions const&, std::vector> const&, std::shared_ptr const&, std::shared_ptr) @ 0x00000000111413e8 in /usr/bin/clickhouse
+13. DB::InterpreterSelectWithUnionQuery::InterpreterSelectWithUnionQuery(std::shared_ptr const&, std::shared_ptr, DB::SelectQueryOptions const&, std::vector> const&) @ 0x00000000111d3708 in /usr/bin/clickhouse
+14. DB::InterpreterFactory::get(std::shared_ptr&, std::shared_ptr, DB::SelectQueryOptions const&) @ 0x0000000011100b64 in /usr/bin/clickhouse
+15. DB::executeQueryImpl(char const*, char const*, std::shared_ptr, bool, DB::QueryProcessingStage::Enum, DB::ReadBuffer*) @ 0x00000000114c3f3c in /usr/bin/clickhouse
+16. DB::executeQuery(String const&, std::shared_ptr, bool, DB::QueryProcessingStage::Enum) @ 0x00000000114c0ec8 in /usr/bin/clickhouse
+17. DB::TCPHandler::runImpl() @ 0x00000000121bb5d8 in /usr/bin/clickhouse
+18. DB::TCPHandler::run() @ 0x00000000121cb728 in /usr/bin/clickhouse
+19. Poco::Net::TCPServerConnection::start() @ 0x00000000146d9404 in /usr/bin/clickhouse
+20. Poco::Net::TCPServerDispatcher::run() @ 0x00000000146da900 in /usr/bin/clickhouse
+21. Poco::PooledThread::run() @ 0x000000001484da7c in /usr/bin/clickhouse
+22. Poco::ThreadImpl::runnableEntry(void*) @ 0x000000001484bc24 in /usr/bin/clickhouse
+23. start_thread @ 0x0000000000007624 in /usr/lib/aarch64-linux-gnu/libpthread-2.31.so
+24. ? @ 0x00000000000d162c in /usr/lib/aarch64-linux-gnu/libc-2.31.so
+"""
+
+LINES_NO_PATH = r"""
+0. DB::Exception::Exception(DB::Exception::MessageMasked&&, int, bool) @ 0x000000000bfc38a4
+1. DB::Exception::Exception(int, FormatStringHelperImpl::type, std::type_identity::type>, String&&, String&&) @ 0x00000000075d242c
+2. DB::ActionsMatcher::visit(DB::ASTIdentifier const&, std::shared_ptr const&, DB::ActionsMatcher::Data&) @ 0x0000000010b1c648
+3. DB::ActionsMatcher::visit(DB::ASTFunction const&, std::shared_ptr const&, DB::ActionsMatcher::Data&) @ 0x0000000010b1f58c
+4. DB::ActionsMatcher::visit(DB::ASTFunction const&, std::shared_ptr const&, DB::ActionsMatcher::Data&) @ 0x0000000010b1f58c
+5. DB::ActionsMatcher::visit(std::shared_ptr const&, DB::ActionsMatcher::Data&) @ 0x0000000010b1c394
+6. DB::InDepthNodeVisitor const>::doVisit(std::shared_ptr const&) @ 0x0000000010b154a0
+7. DB::ExpressionAnalyzer::getRootActions(std::shared_ptr const&, bool, std::shared_ptr&, bool) @ 0x0000000010af83b4
+8. DB::SelectQueryExpressionAnalyzer::appendSelect(DB::ExpressionActionsChain&, bool) @ 0x0000000010aff168
+9. DB::ExpressionAnalysisResult::ExpressionAnalysisResult(DB::SelectQueryExpressionAnalyzer&, std::shared_ptr const&, bool, bool, bool, std::shared_ptr const&, std::shared_ptr const&, DB::Block const&) @ 0x0000000010b05b74
+10. DB::InterpreterSelectQuery::getSampleBlockImpl() @ 0x00000000111559fc
+11. DB::InterpreterSelectQuery::InterpreterSelectQuery(std::shared_ptr const&, std::shared_ptr const&, std::optional, std::shared_ptr const&, DB::SelectQueryOptions const&, std::vector> const&, std::shared_ptr const&, std::shared_ptr)::$_0::operator()(bool) const @ 0x0000000011148254
+12. DB::InterpreterSelectQuery::InterpreterSelectQuery(std::shared_ptr const&, std::shared_ptr const&, std::optional, std::shared_ptr const&, DB::SelectQueryOptions const&, std::vector> const&, std::shared_ptr const&, std::shared_ptr) @ 0x00000000111413e8
+13. DB::InterpreterSelectWithUnionQuery::InterpreterSelectWithUnionQuery(std::shared_ptr const&, std::shared_ptr, DB::SelectQueryOptions const&, std::vector> const&) @ 0x00000000111d3708
+14. DB::InterpreterFactory::get(std::shared_ptr&, std::shared_ptr, DB::SelectQueryOptions const&) @ 0x0000000011100b64
+15. DB::executeQueryImpl(char const*, char const*, std::shared_ptr, bool, DB::QueryProcessingStage::Enum, DB::ReadBuffer*) @ 0x00000000114c3f3c
+16. DB::executeQuery(String const&, std::shared_ptr, bool, DB::QueryProcessingStage::Enum) @ 0x00000000114c0ec8
+17. DB::TCPHandler::runImpl() @ 0x00000000121bb5d8
+18. DB::TCPHandler::run() @ 0x00000000121cb728
+19. Poco::Net::TCPServerConnection::start() @ 0x00000000146d9404
+20. Poco::Net::TCPServerDispatcher::run() @ 0x00000000146da900
+21. Poco::PooledThread::run() @ 0x000000001484da7c
+22. Poco::ThreadImpl::runnableEntry(void*) @ 0x000000001484bc24
+23. start_thread @ 0x0000000000007624
+24. ? @ 0x00000000000d162c
 """
 
 
-@pytest.mark.parametrize("input", LINES.strip().splitlines())
+@pytest.mark.parametrize(
+    "input", LINES.strip().splitlines() + LINES_NO_PATH.strip().splitlines()
+)
 def test_basic(sentry_init, capture_events, input):
     sentry_init(integrations=[GnuBacktraceIntegration()])
     events = capture_events()
@@ -94,8 +81,4 @@ def test_basic(sentry_init, capture_events, input):
     )
     (frame,) = exception["stacktrace"]["frames"][1:]
 
-    if frame.get("function") is None:
-        assert "clickhouse-server()" in input or "pthread" in input
-    else:
-        assert ")" not in frame["function"] and "(" not in frame["function"]
-        assert frame["function"] in input
+    assert frame["function"]
diff --git a/tests/integrations/threading/test_threading.py b/tests/integrations/threading/test_threading.py
index 555694133e..788fb24fdf 100644
--- a/tests/integrations/threading/test_threading.py
+++ b/tests/integrations/threading/test_threading.py
@@ -1,23 +1,20 @@
 import gc
 import sys
+from concurrent import futures
+from textwrap import dedent
 from threading import Thread
 
-try:
-    from concurrent import futures
-except ImportError:
-    futures = None
-
 import pytest
 
 import sentry_sdk
-from sentry_sdk import configure_scope, capture_message
+from sentry_sdk import capture_message
 from sentry_sdk.integrations.threading import ThreadingIntegration
 
 original_start = Thread.start
 original_run = Thread.run
 
 
-@pytest.mark.forked
+@pytest.mark.filterwarnings("ignore:.*:pytest.PytestUnhandledThreadExceptionWarning")
 @pytest.mark.parametrize("integrations", [[ThreadingIntegration()], []])
 def test_handles_exceptions(sentry_init, capture_events, integrations):
     sentry_init(default_integrations=False, integrations=integrations)
@@ -41,7 +38,7 @@ def crash():
         assert not events
 
 
-@pytest.mark.forked
+@pytest.mark.filterwarnings("ignore:.*:pytest.PytestUnhandledThreadExceptionWarning")
 @pytest.mark.parametrize("propagate_hub", (True, False))
 def test_propagates_hub(sentry_init, capture_events, propagate_hub):
     sentry_init(
@@ -51,8 +48,7 @@ def test_propagates_hub(sentry_init, capture_events, propagate_hub):
     events = capture_events()
 
     def stage1():
-        with configure_scope() as scope:
-            scope.set_tag("stage1", "true")
+        sentry_sdk.get_isolation_scope().set_tag("stage1", "true")
 
         t = Thread(target=stage2)
         t.start()
@@ -73,16 +69,13 @@ def stage2():
     assert exception["mechanism"]["type"] == "threading"
     assert not exception["mechanism"]["handled"]
 
-    if propagate_hub:
+    # Free-threaded builds set thread_inherit_context to True, otherwise thread_inherit_context is False
+    if propagate_hub or getattr(sys.flags, "thread_inherit_context", None):
         assert event["tags"]["stage1"] == "true"
     else:
         assert "stage1" not in event.get("tags", {})
 
 
-@pytest.mark.skipif(
-    futures is None,
-    reason="ThreadPool was added in 3.2",
-)
 @pytest.mark.parametrize("propagate_hub", (True, False))
 def test_propagates_threadpool_hub(sentry_init, capture_events, propagate_hub):
     sentry_init(
@@ -92,7 +85,7 @@ def test_propagates_threadpool_hub(sentry_init, capture_events, propagate_hub):
     events = capture_events()
 
     def double(number):
-        with sentry_sdk.start_span(op="task", description=str(number)):
+        with sentry_sdk.start_span(op="task", name=str(number)):
             return number * 2
 
     with sentry_sdk.start_transaction(name="test_handles_threadpool"):
@@ -103,7 +96,8 @@ def double(number):
 
     sentry_sdk.flush()
 
-    if propagate_hub:
+    # Free-threaded builds set thread_inherit_context to True, otherwise thread_inherit_context is False
+    if propagate_hub or getattr(sys.flags, "thread_inherit_context", None):
         assert len(events) == 1
         (event,) = events
         assert event["spans"][0]["trace_id"] == event["spans"][1]["trace_id"]
@@ -115,6 +109,7 @@ def double(number):
         assert len(event["spans"]) == 0
 
 
+@pytest.mark.skip(reason="Temporarily disable to release SDK 2.0a1.")
 def test_circular_references(sentry_init, request):
     sentry_init(default_integrations=False, integrations=[ThreadingIntegration()])
 
@@ -131,10 +126,11 @@ def run(self):
     t.join()
     del t
 
-    assert not gc.collect()
+    unreachable_objects = gc.collect()
+    assert unreachable_objects == 0
 
 
-@pytest.mark.forked
+@pytest.mark.filterwarnings("ignore:.*:pytest.PytestUnhandledThreadExceptionWarning")
 def test_double_patching(sentry_init, capture_events):
     sentry_init(default_integrations=False, integrations=[ThreadingIntegration()])
     events = capture_events()
@@ -163,7 +159,6 @@ def run(self):
         assert exception["type"] == "ZeroDivisionError"
 
 
-@pytest.mark.skipif(sys.version_info < (3, 2), reason="no __qualname__ in older python")
 def test_wrapper_attributes(sentry_init):
     sentry_init(default_integrations=False, integrations=[ThreadingIntegration()])
 
@@ -186,22 +181,165 @@ def target():
     assert t.run.__qualname__ == original_run.__qualname__
 
 
-@pytest.mark.skipif(
-    sys.version_info > (2, 7),
-    reason="simpler test for py2.7 without py3 only __qualname__",
+@pytest.mark.parametrize(
+    "propagate_scope",
+    (True, False),
+    ids=["propagate_scope=True", "propagate_scope=False"],
 )
-def test_wrapper_attributes_no_qualname(sentry_init):
-    sentry_init(default_integrations=False, integrations=[ThreadingIntegration()])
+def test_scope_data_not_leaked_in_threads(sentry_init, propagate_scope):
+    sentry_init(
+        integrations=[ThreadingIntegration(propagate_scope=propagate_scope)],
+    )
 
-    def target():
-        assert t.run.__name__ == "run"
+    sentry_sdk.set_tag("initial_tag", "initial_value")
+    initial_iso_scope = sentry_sdk.get_isolation_scope()
 
-    t = Thread(target=target)
+    def do_some_work():
+        # check if we have the initial scope data propagated into the thread
+        if propagate_scope:
+            assert sentry_sdk.get_isolation_scope()._tags == {
+                "initial_tag": "initial_value"
+            }
+        else:
+            assert sentry_sdk.get_isolation_scope()._tags == {}
+
+        # change data in isolation scope in thread
+        sentry_sdk.set_tag("thread_tag", "thread_value")
+
+    t = Thread(target=do_some_work)
     t.start()
     t.join()
 
-    assert Thread.start.__name__ == "start"
-    assert t.start.__name__ == "start"
+    # check if the initial scope data is not modified by the started thread
+    assert initial_iso_scope._tags == {"initial_tag": "initial_value"}, (
+        "The isolation scope in the main thread should not be modified by the started thread."
+    )
 
-    assert Thread.run.__name__ == "run"
-    assert t.run.__name__ == "run"
+
+@pytest.mark.parametrize(
+    "propagate_scope",
+    (True, False),
+    ids=["propagate_scope=True", "propagate_scope=False"],
+)
+def test_spans_from_multiple_threads(
+    sentry_init, capture_events, render_span_tree, propagate_scope
+):
+    sentry_init(
+        traces_sample_rate=1.0,
+        integrations=[ThreadingIntegration(propagate_scope=propagate_scope)],
+    )
+    events = capture_events()
+
+    def do_some_work(number):
+        with sentry_sdk.start_span(
+            op=f"inner-run-{number}", name=f"Thread: child-{number}"
+        ):
+            pass
+
+    threads = []
+
+    with sentry_sdk.start_transaction(op="outer-trx"):
+        for number in range(5):
+            with sentry_sdk.start_span(
+                op=f"outer-submit-{number}", name="Thread: main"
+            ):
+                t = Thread(target=do_some_work, args=(number,))
+                t.start()
+                threads.append(t)
+
+        for t in threads:
+            t.join()
+
+    (event,) = events
+
+    # Free-threaded builds set thread_inherit_context to True, otherwise thread_inherit_context is False
+    if propagate_scope or getattr(sys.flags, "thread_inherit_context", None):
+        assert render_span_tree(event) == dedent(
+            """\
+            - op="outer-trx": description=null
+              - op="outer-submit-0": description="Thread: main"
+                - op="inner-run-0": description="Thread: child-0"
+              - op="outer-submit-1": description="Thread: main"
+                - op="inner-run-1": description="Thread: child-1"
+              - op="outer-submit-2": description="Thread: main"
+                - op="inner-run-2": description="Thread: child-2"
+              - op="outer-submit-3": description="Thread: main"
+                - op="inner-run-3": description="Thread: child-3"
+              - op="outer-submit-4": description="Thread: main"
+                - op="inner-run-4": description="Thread: child-4"\
+"""
+        )
+
+    elif not propagate_scope:
+        assert render_span_tree(event) == dedent(
+            """\
+            - op="outer-trx": description=null
+              - op="outer-submit-0": description="Thread: main"
+              - op="outer-submit-1": description="Thread: main"
+              - op="outer-submit-2": description="Thread: main"
+              - op="outer-submit-3": description="Thread: main"
+              - op="outer-submit-4": description="Thread: main"\
+"""
+        )
+
+
+@pytest.mark.parametrize(
+    "propagate_scope",
+    (True, False),
+    ids=["propagate_scope=True", "propagate_scope=False"],
+)
+def test_spans_from_threadpool(
+    sentry_init, capture_events, render_span_tree, propagate_scope
+):
+    sentry_init(
+        traces_sample_rate=1.0,
+        integrations=[ThreadingIntegration(propagate_scope=propagate_scope)],
+    )
+    events = capture_events()
+
+    def do_some_work(number):
+        with sentry_sdk.start_span(
+            op=f"inner-run-{number}", name=f"Thread: child-{number}"
+        ):
+            pass
+
+    with sentry_sdk.start_transaction(op="outer-trx"):
+        with futures.ThreadPoolExecutor(max_workers=1) as executor:
+            for number in range(5):
+                with sentry_sdk.start_span(
+                    op=f"outer-submit-{number}", name="Thread: main"
+                ):
+                    future = executor.submit(do_some_work, number)
+                    future.result()
+
+    (event,) = events
+
+    # Free-threaded builds set thread_inherit_context to True, otherwise thread_inherit_context is False
+    if propagate_scope or getattr(sys.flags, "thread_inherit_context", None):
+        assert render_span_tree(event) == dedent(
+            """\
+            - op="outer-trx": description=null
+              - op="outer-submit-0": description="Thread: main"
+                - op="inner-run-0": description="Thread: child-0"
+              - op="outer-submit-1": description="Thread: main"
+                - op="inner-run-1": description="Thread: child-1"
+              - op="outer-submit-2": description="Thread: main"
+                - op="inner-run-2": description="Thread: child-2"
+              - op="outer-submit-3": description="Thread: main"
+                - op="inner-run-3": description="Thread: child-3"
+              - op="outer-submit-4": description="Thread: main"
+                - op="inner-run-4": description="Thread: child-4"\
+"""
+        )
+
+    elif not propagate_scope:
+        assert render_span_tree(event) == dedent(
+            """\
+            - op="outer-trx": description=null
+              - op="outer-submit-0": description="Thread: main"
+              - op="outer-submit-1": description="Thread: main"
+              - op="outer-submit-2": description="Thread: main"
+              - op="outer-submit-3": description="Thread: main"
+              - op="outer-submit-4": description="Thread: main"\
+"""
+        )
diff --git a/tests/integrations/tornado/test_tornado.py b/tests/integrations/tornado/test_tornado.py
index 2160154933..294f605f6a 100644
--- a/tests/integrations/tornado/test_tornado.py
+++ b/tests/integrations/tornado/test_tornado.py
@@ -2,7 +2,8 @@
 
 import pytest
 
-from sentry_sdk import configure_scope, start_transaction, capture_message
+import sentry_sdk
+from sentry_sdk import start_transaction, capture_message
 from sentry_sdk.integrations.tornado import TornadoIntegration
 
 from tornado.web import RequestHandler, Application, HTTPError
@@ -36,13 +37,11 @@ def bogustest(self):
 
 class CrashingHandler(RequestHandler):
     def get(self):
-        with configure_scope() as scope:
-            scope.set_tag("foo", "42")
+        sentry_sdk.get_isolation_scope().set_tag("foo", "42")
         1 / 0
 
     def post(self):
-        with configure_scope() as scope:
-            scope.set_tag("foo", "43")
+        sentry_sdk.get_isolation_scope().set_tag("foo", "43")
         1 / 0
 
 
@@ -54,14 +53,12 @@ def get(self):
 
 class HelloHandler(RequestHandler):
     async def get(self):
-        with configure_scope() as scope:
-            scope.set_tag("foo", "42")
+        sentry_sdk.get_isolation_scope().set_tag("foo", "42")
 
         return b"hello"
 
     async def post(self):
-        with configure_scope() as scope:
-            scope.set_tag("foo", "43")
+        sentry_sdk.get_isolation_scope().set_tag("foo", "43")
 
         return b"hello"
 
@@ -104,8 +101,7 @@ def test_basic(tornado_testcase, sentry_init, capture_events):
     )
     assert event["transaction_info"] == {"source": "component"}
 
-    with configure_scope() as scope:
-        assert not scope._tags
+    assert not sentry_sdk.get_isolation_scope()._tags
 
 
 @pytest.mark.parametrize(
@@ -116,7 +112,7 @@ def test_basic(tornado_testcase, sentry_init, capture_events):
     ],
 )
 def test_transactions(tornado_testcase, sentry_init, capture_events, handler, code):
-    sentry_init(integrations=[TornadoIntegration()], traces_sample_rate=1.0, debug=True)
+    sentry_init(integrations=[TornadoIntegration()], traces_sample_rate=1.0)
     events = capture_events()
     client = tornado_testcase(Application([(r"/hi", handler)]))
 
@@ -440,3 +436,17 @@ def test_error_has_existing_trace_context_performance_disabled(
         == error_event["contexts"]["trace"]["trace_id"]
         == "471a43a4192642f0b136d5159a501701"
     )
+
+
+def test_span_origin(tornado_testcase, sentry_init, capture_events):
+    sentry_init(integrations=[TornadoIntegration()], traces_sample_rate=1.0)
+    events = capture_events()
+    client = tornado_testcase(Application([(r"/hi", CrashingHandler)]))
+
+    client.fetch(
+        "/hi?foo=bar", headers={"Cookie": "name=value; name2=value2; name3=value3"}
+    )
+
+    (_, event) = events
+
+    assert event["contexts"]["trace"]["origin"] == "auto.http.tornado"
diff --git a/tests/integrations/trytond/test_trytond.py b/tests/integrations/trytond/test_trytond.py
index c4593c3060..33a138b50a 100644
--- a/tests/integrations/trytond/test_trytond.py
+++ b/tests/integrations/trytond/test_trytond.py
@@ -11,8 +11,9 @@
 from trytond.wsgi import app as trytond_app
 
 from werkzeug.test import Client
-from sentry_sdk import last_event_id
+
 from sentry_sdk.integrations.trytond import TrytondWSGIIntegration
+from tests.conftest import unpack_werkzeug_response
 
 
 @pytest.fixture(scope="function")
@@ -79,13 +80,12 @@ def _(request):
 @pytest.mark.skipif(
     trytond.__version__.split(".") < ["5", "4"], reason="At least Trytond-5.4 required"
 )
-def test_rpc_error_page(sentry_init, app, capture_events, get_client):
+def test_rpc_error_page(sentry_init, app, get_client):
     """Test that, after initializing the Trytond-SentrySDK integration
     a custom error handler can be registered to the Trytond WSGI app so as to
     inform the event identifiers to the Tryton RPC client"""
 
     sentry_init(integrations=[TrytondWSGIIntegration()])
-    events = capture_events()
 
     @app.route("/rpcerror", methods=["POST"])
     def _(request):
@@ -96,8 +96,7 @@ def _(app, request, e):
         if isinstance(e, TrytondBaseException):
             return
         else:
-            event_id = last_event_id()
-            data = TrytondUserError(str(event_id), str(e))
+            data = TrytondUserError("Sentry error.", str(e))
             return app.make_response(request, data)
 
     client = get_client()
@@ -121,9 +120,27 @@ def _(app, request, e):
         "/rpcerror", content_type="application/json", data=json.dumps(_data)
     )
 
-    (event,) = events
-    (content, status, headers) = response
-    data = json.loads(next(content))
+    (content, status, headers) = unpack_werkzeug_response(response)
+    data = json.loads(content)
     assert status == "200 OK"
     assert headers.get("Content-Type") == "application/json"
-    assert data == dict(id=42, error=["UserError", [event["event_id"], "foo", None]])
+    assert data == dict(id=42, error=["UserError", ["Sentry error.", "foo", None]])
+
+
+def test_span_origin(sentry_init, app, capture_events, get_client):
+    sentry_init(
+        integrations=[TrytondWSGIIntegration()],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    @app.route("/something")
+    def _(request):
+        return "ok"
+
+    client = get_client()
+    client.get("/something")
+
+    (event,) = events
+
+    assert event["contexts"]["trace"]["origin"] == "auto.http.trytond_wsgi"
diff --git a/tests/integrations/typer/__init__.py b/tests/integrations/typer/__init__.py
new file mode 100644
index 0000000000..3b7c8011ea
--- /dev/null
+++ b/tests/integrations/typer/__init__.py
@@ -0,0 +1,3 @@
+import pytest
+
+pytest.importorskip("typer")
diff --git a/tests/integrations/typer/test_typer.py b/tests/integrations/typer/test_typer.py
new file mode 100644
index 0000000000..34ac0a7c8c
--- /dev/null
+++ b/tests/integrations/typer/test_typer.py
@@ -0,0 +1,52 @@
+import subprocess
+import sys
+from textwrap import dedent
+import pytest
+
+from typer.testing import CliRunner
+
+runner = CliRunner()
+
+
+def test_catch_exceptions(tmpdir):
+    app = tmpdir.join("app.py")
+
+    app.write(
+        dedent(
+            """
+    import typer
+    from unittest import mock
+
+    from sentry_sdk import init, transport
+    from sentry_sdk.integrations.typer import TyperIntegration
+
+    def capture_envelope(self, envelope):
+        print("capture_envelope was called")
+        event = envelope.get_event()
+        if event is not None:
+            print(event)
+
+    transport.HttpTransport.capture_envelope = capture_envelope
+
+    init("http://foobar@localhost/123", integrations=[TyperIntegration()])
+
+    app = typer.Typer()
+
+    @app.command()
+    def test():
+        print("test called")
+        raise Exception("pollo")
+
+    app()
+    """
+        )
+    )
+
+    with pytest.raises(subprocess.CalledProcessError) as excinfo:
+        subprocess.check_output([sys.executable, str(app)], stderr=subprocess.STDOUT)
+
+    output = excinfo.value.output
+
+    assert b"capture_envelope was called" in output
+    assert b"test called" in output
+    assert b"pollo" in output
diff --git a/tests/integrations/unleash/__init__.py b/tests/integrations/unleash/__init__.py
new file mode 100644
index 0000000000..33cff3e65a
--- /dev/null
+++ b/tests/integrations/unleash/__init__.py
@@ -0,0 +1,3 @@
+import pytest
+
+pytest.importorskip("UnleashClient")
diff --git a/tests/integrations/unleash/test_unleash.py b/tests/integrations/unleash/test_unleash.py
new file mode 100644
index 0000000000..98a6188181
--- /dev/null
+++ b/tests/integrations/unleash/test_unleash.py
@@ -0,0 +1,186 @@
+import concurrent.futures as cf
+import sys
+from random import random
+from unittest import mock
+from UnleashClient import UnleashClient
+
+import pytest
+
+import sentry_sdk
+from sentry_sdk.integrations.unleash import UnleashIntegration
+from sentry_sdk import start_span, start_transaction
+from tests.integrations.unleash.testutils import mock_unleash_client
+from tests.conftest import ApproxDict
+
+
+def test_is_enabled(sentry_init, capture_events, uninstall_integration):
+    uninstall_integration(UnleashIntegration.identifier)
+
+    with mock_unleash_client():
+        client = UnleashClient()  # type: ignore[arg-type]
+        sentry_init(integrations=[UnleashIntegration()])
+        client.is_enabled("hello")
+        client.is_enabled("world")
+        client.is_enabled("other")
+
+    events = capture_events()
+    sentry_sdk.capture_exception(Exception("something wrong!"))
+
+    assert len(events) == 1
+    assert events[0]["contexts"]["flags"] == {
+        "values": [
+            {"flag": "hello", "result": True},
+            {"flag": "world", "result": False},
+            {"flag": "other", "result": False},
+        ]
+    }
+
+
+def test_is_enabled_threaded(sentry_init, capture_events, uninstall_integration):
+    uninstall_integration(UnleashIntegration.identifier)
+
+    with mock_unleash_client():
+        client = UnleashClient()  # type: ignore[arg-type]
+        sentry_init(integrations=[UnleashIntegration()])
+        events = capture_events()
+
+        def task(flag_key):
+            # Creates a new isolation scope for the thread.
+            # This means the evaluations in each task are captured separately.
+            with sentry_sdk.isolation_scope():
+                client.is_enabled(flag_key)
+                # use a tag to identify to identify events later on
+                sentry_sdk.set_tag("task_id", flag_key)
+                sentry_sdk.capture_exception(Exception("something wrong!"))
+
+        # Capture an eval before we split isolation scopes.
+        client.is_enabled("hello")
+
+        with cf.ThreadPoolExecutor(max_workers=2) as pool:
+            pool.map(task, ["world", "other"])
+
+    # Capture error in original scope
+    sentry_sdk.set_tag("task_id", "0")
+    sentry_sdk.capture_exception(Exception("something wrong!"))
+
+    assert len(events) == 3
+    events.sort(key=lambda e: e["tags"]["task_id"])
+
+    assert events[0]["contexts"]["flags"] == {
+        "values": [
+            {"flag": "hello", "result": True},
+        ]
+    }
+    assert events[1]["contexts"]["flags"] == {
+        "values": [
+            {"flag": "hello", "result": True},
+            {"flag": "other", "result": False},
+        ]
+    }
+    assert events[2]["contexts"]["flags"] == {
+        "values": [
+            {"flag": "hello", "result": True},
+            {"flag": "world", "result": False},
+        ]
+    }
+
+
+@pytest.mark.skipif(sys.version_info < (3, 7), reason="requires python3.7 or higher")
+def test_is_enabled_asyncio(sentry_init, capture_events, uninstall_integration):
+    asyncio = pytest.importorskip("asyncio")
+    uninstall_integration(UnleashIntegration.identifier)
+
+    with mock_unleash_client():
+        client = UnleashClient()  # type: ignore[arg-type]
+        sentry_init(integrations=[UnleashIntegration()])
+        events = capture_events()
+
+        async def task(flag_key):
+            with sentry_sdk.isolation_scope():
+                client.is_enabled(flag_key)
+                # use a tag to identify to identify events later on
+                sentry_sdk.set_tag("task_id", flag_key)
+                sentry_sdk.capture_exception(Exception("something wrong!"))
+
+        async def runner():
+            return asyncio.gather(task("world"), task("other"))
+
+        # Capture an eval before we split isolation scopes.
+        client.is_enabled("hello")
+
+        asyncio.run(runner())
+
+    # Capture error in original scope
+    sentry_sdk.set_tag("task_id", "0")
+    sentry_sdk.capture_exception(Exception("something wrong!"))
+
+    assert len(events) == 3
+    events.sort(key=lambda e: e["tags"]["task_id"])
+
+    assert events[0]["contexts"]["flags"] == {
+        "values": [
+            {"flag": "hello", "result": True},
+        ]
+    }
+    assert events[1]["contexts"]["flags"] == {
+        "values": [
+            {"flag": "hello", "result": True},
+            {"flag": "other", "result": False},
+        ]
+    }
+    assert events[2]["contexts"]["flags"] == {
+        "values": [
+            {"flag": "hello", "result": True},
+            {"flag": "world", "result": False},
+        ]
+    }
+
+
+def test_wraps_original(sentry_init, uninstall_integration):
+    with mock_unleash_client():
+        client = UnleashClient()  # type: ignore[arg-type]
+
+        mock_is_enabled = mock.Mock(return_value=random() < 0.5)
+        client.is_enabled = mock_is_enabled
+
+        uninstall_integration(UnleashIntegration.identifier)
+        sentry_init(integrations=[UnleashIntegration()])  # type: ignore
+
+    res = client.is_enabled("test-flag", "arg", kwarg=1)
+    assert res == mock_is_enabled.return_value
+    assert mock_is_enabled.call_args == (
+        ("test-flag", "arg"),
+        {"kwarg": 1},
+    )
+
+
+def test_wrapper_attributes(sentry_init, uninstall_integration):
+    with mock_unleash_client():
+        client = UnleashClient()  # type: ignore[arg-type]
+
+        original_is_enabled = client.is_enabled
+
+        uninstall_integration(UnleashIntegration.identifier)
+        sentry_init(integrations=[UnleashIntegration()])  # type: ignore
+
+        # Mock clients methods have not lost their qualified names after decoration.
+        assert client.is_enabled.__name__ == "is_enabled"
+        assert client.is_enabled.__qualname__ == original_is_enabled.__qualname__
+
+
+def test_unleash_span_integration(sentry_init, capture_events, uninstall_integration):
+    uninstall_integration(UnleashIntegration.identifier)
+
+    with mock_unleash_client():
+        sentry_init(traces_sample_rate=1.0, integrations=[UnleashIntegration()])
+        events = capture_events()
+        client = UnleashClient()  # type: ignore[arg-type]
+        with start_transaction(name="hi"):
+            with start_span(op="foo", name="bar"):
+                client.is_enabled("hello")
+                client.is_enabled("other")
+
+    (event,) = events
+    assert event["spans"][0]["data"] == ApproxDict(
+        {"flag.evaluation.hello": True, "flag.evaluation.other": False}
+    )
diff --git a/tests/integrations/unleash/testutils.py b/tests/integrations/unleash/testutils.py
new file mode 100644
index 0000000000..4e91b6190b
--- /dev/null
+++ b/tests/integrations/unleash/testutils.py
@@ -0,0 +1,44 @@
+from contextlib import contextmanager
+from UnleashClient import UnleashClient
+
+
+@contextmanager
+def mock_unleash_client():
+    """
+    Temporarily replaces UnleashClient's methods with mock implementations
+    for testing.
+
+    This context manager swaps out UnleashClient's __init__ and is_enabled,
+    methods with mock versions from MockUnleashClient.
+    Original methods are restored when exiting the context.
+
+    After mocking the client class the integration can be initialized.
+    The methods on the mock client class are overridden by the
+    integration and flag tracking proceeds as expected.
+
+    Example:
+        with mock_unleash_client():
+            client = UnleashClient()  # Uses mock implementation
+            sentry_init(integrations=[UnleashIntegration()])
+    """
+    old_init = UnleashClient.__init__
+    old_is_enabled = UnleashClient.is_enabled
+
+    UnleashClient.__init__ = MockUnleashClient.__init__
+    UnleashClient.is_enabled = MockUnleashClient.is_enabled
+
+    yield
+
+    UnleashClient.__init__ = old_init
+    UnleashClient.is_enabled = old_is_enabled
+
+
+class MockUnleashClient:
+    def __init__(self, *a, **kw):
+        self.features = {
+            "hello": True,
+            "world": False,
+        }
+
+    def is_enabled(self, feature, *a, **kw):
+        return self.features.get(feature, False)
diff --git a/tests/integrations/unraisablehook/test_unraisablehook.py b/tests/integrations/unraisablehook/test_unraisablehook.py
new file mode 100644
index 0000000000..dbe8164cf5
--- /dev/null
+++ b/tests/integrations/unraisablehook/test_unraisablehook.py
@@ -0,0 +1,54 @@
+import pytest
+import sys
+import subprocess
+
+from textwrap import dedent
+
+
+TEST_PARAMETERS = [
+    ("", "HttpTransport"),
+    ('_experiments={"transport_http2": True}', "Http2Transport"),
+]
+
+minimum_python_38 = pytest.mark.skipif(
+    sys.version_info < (3, 8),
+    reason="The unraisable exception hook is only available in Python 3.8 and above.",
+)
+
+
+@minimum_python_38
+@pytest.mark.parametrize("options, transport", TEST_PARAMETERS)
+def test_unraisablehook(tmpdir, options, transport):
+    app = tmpdir.join("app.py")
+    app.write(
+        dedent(
+            """
+    from sentry_sdk import init, transport
+    from sentry_sdk.integrations.unraisablehook import UnraisablehookIntegration
+
+    class Undeletable:
+        def __del__(self):
+            1 / 0
+
+    def capture_envelope(self, envelope):
+        print("capture_envelope was called")
+        event = envelope.get_event()
+        if event is not None:
+            print(event)
+
+    transport.{transport}.capture_envelope = capture_envelope
+
+    init("http://foobar@localhost/123", integrations=[UnraisablehookIntegration()], {options})
+
+    undeletable = Undeletable()
+    del undeletable
+    """.format(transport=transport, options=options)
+        )
+    )
+
+    output = subprocess.check_output(
+        [sys.executable, str(app)], stderr=subprocess.STDOUT
+    )
+
+    assert b"ZeroDivisionError" in output
+    assert b"capture_envelope was called" in output
diff --git a/tests/integrations/wsgi/test_wsgi.py b/tests/integrations/wsgi/test_wsgi.py
index 0b76bf6887..a741d1c57b 100644
--- a/tests/integrations/wsgi/test_wsgi.py
+++ b/tests/integrations/wsgi/test_wsgi.py
@@ -1,18 +1,12 @@
-import sys
-
-from werkzeug.test import Client
+from collections import Counter
+from unittest import mock
 
 import pytest
+from werkzeug.test import Client
 
 import sentry_sdk
 from sentry_sdk import capture_message
 from sentry_sdk.integrations.wsgi import SentryWsgiMiddleware
-from collections import Counter
-
-try:
-    from unittest import mock  # python 3.3 and above
-except ImportError:
-    import mock  # python < 3.3
 
 
 @pytest.fixture
@@ -23,7 +17,7 @@ def app(environ, start_response):
     return app
 
 
-class IterableApp(object):
+class IterableApp:
     def __init__(self, iterable):
         self.iterable = iterable
 
@@ -31,7 +25,7 @@ def __call__(self, environ, start_response):
         return self.iterable
 
 
-class ExitingIterable(object):
+class ExitingIterable:
     def __init__(self, exc_func):
         self._exc_func = exc_func
 
@@ -67,6 +61,25 @@ def test_basic(sentry_init, crashing_app, capture_events):
     }
 
 
+@pytest.mark.parametrize("path_info", ("bark/", "/bark/"))
+@pytest.mark.parametrize("script_name", ("woof/woof", "woof/woof/"))
+def test_script_name_is_respected(
+    sentry_init, crashing_app, capture_events, script_name, path_info
+):
+    sentry_init(send_default_pii=True)
+    app = SentryWsgiMiddleware(crashing_app)
+    client = Client(app)
+    events = capture_events()
+
+    with pytest.raises(ZeroDivisionError):
+        # setting url with PATH_INFO: bark/, HTTP_HOST: dogs.are.great and SCRIPT_NAME: woof/woof/
+        client.get(path_info, f"https://dogs.are.great/{script_name}")  # noqa: E231
+
+    (event,) = events
+
+    assert event["request"]["url"] == "https://dogs.are.great/woof/woof/bark/"
+
+
 @pytest.fixture(params=[0, None])
 def test_systemexit_zero_is_ignored(sentry_init, capture_events, request):
     zero_code = request.param
@@ -123,7 +136,10 @@ def test_keyboard_interrupt_is_captured(sentry_init, capture_events):
 
 
 def test_transaction_with_error(
-    sentry_init, crashing_app, capture_events, DictionaryContaining  # noqa:N803
+    sentry_init,
+    crashing_app,
+    capture_events,
+    DictionaryContaining,  # noqa:N803
 ):
     def dogpark(environ, start_response):
         raise ValueError("Fetch aborted. The ball was not returned.")
@@ -160,7 +176,9 @@ def dogpark(environ, start_response):
 
 
 def test_transaction_no_error(
-    sentry_init, capture_events, DictionaryContaining  # noqa:N803
+    sentry_init,
+    capture_events,
+    DictionaryContaining,  # noqa:N803
 ):
     def dogpark(environ, start_response):
         start_response("200 OK", [])
@@ -418,10 +436,7 @@ def sample_app(environ, start_response):
     assert len(session_aggregates) == 1
 
 
-@pytest.mark.skipif(
-    sys.version_info < (3, 3), reason="Profiling is only supported in Python >= 3.3"
-)
-@mock.patch("sentry_sdk.profiler.PROFILE_MINIMUM_SAMPLES", 0)
+@mock.patch("sentry_sdk.profiler.transaction_profiler.PROFILE_MINIMUM_SAMPLES", 0)
 def test_profile_sent(
     sentry_init,
     capture_envelopes,
@@ -446,3 +461,42 @@ def test_app(environ, start_response):
 
     profiles = [item for item in envelopes[0].items if item.type == "profile"]
     assert len(profiles) == 1
+
+
+def test_span_origin_manual(sentry_init, capture_events):
+    def dogpark(environ, start_response):
+        start_response("200 OK", [])
+        return ["Go get the ball! Good dog!"]
+
+    sentry_init(send_default_pii=True, traces_sample_rate=1.0)
+    app = SentryWsgiMiddleware(dogpark)
+
+    events = capture_events()
+
+    client = Client(app)
+    client.get("/dogs/are/great/")
+
+    (event,) = events
+
+    assert event["contexts"]["trace"]["origin"] == "manual"
+
+
+def test_span_origin_custom(sentry_init, capture_events):
+    def dogpark(environ, start_response):
+        start_response("200 OK", [])
+        return ["Go get the ball! Good dog!"]
+
+    sentry_init(send_default_pii=True, traces_sample_rate=1.0)
+    app = SentryWsgiMiddleware(
+        dogpark,
+        span_origin="auto.dogpark.deluxe",
+    )
+
+    events = capture_events()
+
+    client = Client(app)
+    client.get("/dogs/are/great/")
+
+    (event,) = events
+
+    assert event["contexts"]["trace"]["origin"] == "auto.dogpark.deluxe"
diff --git a/tests/new_scopes_compat/__init__.py b/tests/new_scopes_compat/__init__.py
new file mode 100644
index 0000000000..45391bd9ad
--- /dev/null
+++ b/tests/new_scopes_compat/__init__.py
@@ -0,0 +1,7 @@
+"""
+Separate module for tests that check backwards compatibility of the Hub API with 1.x.
+These tests should be removed once we remove the Hub API, likely in the next major.
+
+All tests in this module are run with hub isolation, provided by `isolate_hub` autouse
+fixture, defined in `conftest.py`.
+"""
diff --git a/tests/new_scopes_compat/conftest.py b/tests/new_scopes_compat/conftest.py
new file mode 100644
index 0000000000..9f16898dea
--- /dev/null
+++ b/tests/new_scopes_compat/conftest.py
@@ -0,0 +1,8 @@
+import pytest
+import sentry_sdk
+
+
+@pytest.fixture(autouse=True)
+def isolate_hub(suppress_deprecation_warnings):
+    with sentry_sdk.Hub(None):
+        yield
diff --git a/tests/new_scopes_compat/test_new_scopes_compat.py b/tests/new_scopes_compat/test_new_scopes_compat.py
new file mode 100644
index 0000000000..21e2ac27d3
--- /dev/null
+++ b/tests/new_scopes_compat/test_new_scopes_compat.py
@@ -0,0 +1,275 @@
+import sentry_sdk
+from sentry_sdk.hub import Hub
+
+"""
+Those tests are meant to check the compatibility of the new scopes in SDK 2.0 with the old Hub/Scope system in SDK 1.x.
+
+Those tests have been run with the latest SDK 1.x versiona and the data used in the `assert` statements represents
+the behvaior of the SDK 1.x.
+
+This makes sure that we are backwards compatible. (on a best effort basis, there will probably be some edge cases that are not covered here)
+"""
+
+
+def test_configure_scope_sdk1(sentry_init, capture_events):
+    """
+    Mutate data in a `with configure_scope` block.
+
+    Checks the results of SDK 2.x against the results the same code returned in SDK 1.x.
+    """
+    sentry_init()
+
+    events = capture_events()
+
+    sentry_sdk.set_tag("A", 1)
+    sentry_sdk.capture_message("Event A")
+
+    with sentry_sdk.configure_scope() as scope:  # configure scope
+        sentry_sdk.set_tag("B1", 1)
+        scope.set_tag("B2", 1)
+        sentry_sdk.capture_message("Event B")
+
+    sentry_sdk.set_tag("Z", 1)
+    sentry_sdk.capture_message("Event Z")
+
+    (event_a, event_b, event_z) = events
+
+    # Check against the results the same code returned in SDK 1.x
+    assert event_a["tags"] == {"A": 1}
+    assert event_b["tags"] == {"A": 1, "B1": 1, "B2": 1}
+    assert event_z["tags"] == {"A": 1, "B1": 1, "B2": 1, "Z": 1}
+
+
+def test_push_scope_sdk1(sentry_init, capture_events):
+    """
+    Mutate data in a `with push_scope` block
+
+    Checks the results of SDK 2.x against the results the same code returned in SDK 1.x.
+    """
+    sentry_init()
+
+    events = capture_events()
+
+    sentry_sdk.set_tag("A", 1)
+    sentry_sdk.capture_message("Event A")
+
+    with sentry_sdk.push_scope() as scope:  # push scope
+        sentry_sdk.set_tag("B1", 1)
+        scope.set_tag("B2", 1)
+        sentry_sdk.capture_message("Event B")
+
+    sentry_sdk.set_tag("Z", 1)
+    sentry_sdk.capture_message("Event Z")
+
+    (event_a, event_b, event_z) = events
+
+    # Check against the results the same code returned in SDK 1.x
+    assert event_a["tags"] == {"A": 1}
+    assert event_b["tags"] == {"A": 1, "B1": 1, "B2": 1}
+    assert event_z["tags"] == {"A": 1, "Z": 1}
+
+
+def test_with_hub_sdk1(sentry_init, capture_events):
+    """
+    Mutate data in a `with Hub:` block
+
+    Checks the results of SDK 2.x against the results the same code returned in SDK 1.x.
+    """
+    sentry_init()
+
+    events = capture_events()
+
+    sentry_sdk.set_tag("A", 1)
+    sentry_sdk.capture_message("Event A")
+
+    with Hub.current as hub:  # with hub
+        sentry_sdk.set_tag("B1", 1)
+        hub.scope.set_tag("B2", 1)
+        sentry_sdk.capture_message("Event B")
+
+    sentry_sdk.set_tag("Z", 1)
+    sentry_sdk.capture_message("Event Z")
+
+    (event_a, event_b, event_z) = events
+
+    # Check against the results the same code returned in SDK 1.x
+    assert event_a["tags"] == {"A": 1}
+    assert event_b["tags"] == {"A": 1, "B1": 1, "B2": 1}
+    assert event_z["tags"] == {"A": 1, "B1": 1, "B2": 1, "Z": 1}
+
+
+def test_with_hub_configure_scope_sdk1(sentry_init, capture_events):
+    """
+    Mutate data in a `with Hub:` containing a `with configure_scope` block
+
+    Checks the results of SDK 2.x against the results the same code returned in SDK 1.x.
+    """
+    sentry_init()
+
+    events = capture_events()
+
+    sentry_sdk.set_tag("A", 1)
+    sentry_sdk.capture_message("Event A")
+
+    with Hub.current as hub:  # with hub
+        sentry_sdk.set_tag("B1", 1)
+        with hub.configure_scope() as scope:  # configure scope
+            sentry_sdk.set_tag("B2", 1)
+            hub.scope.set_tag("B3", 1)
+            scope.set_tag("B4", 1)
+            sentry_sdk.capture_message("Event B")
+        sentry_sdk.set_tag("B5", 1)
+        sentry_sdk.capture_message("Event C")
+
+    sentry_sdk.set_tag("Z", 1)
+    sentry_sdk.capture_message("Event Z")
+
+    (event_a, event_b, event_c, event_z) = events
+
+    # Check against the results the same code returned in SDK 1.x
+    assert event_a["tags"] == {"A": 1}
+    assert event_b["tags"] == {"A": 1, "B1": 1, "B2": 1, "B3": 1, "B4": 1}
+    assert event_c["tags"] == {"A": 1, "B1": 1, "B2": 1, "B3": 1, "B4": 1, "B5": 1}
+    assert event_z["tags"] == {
+        "A": 1,
+        "B1": 1,
+        "B2": 1,
+        "B3": 1,
+        "B4": 1,
+        "B5": 1,
+        "Z": 1,
+    }
+
+
+def test_with_hub_push_scope_sdk1(sentry_init, capture_events):
+    """
+    Mutate data in a `with Hub:` containing a `with push_scope` block
+
+    Checks the results of SDK 2.x against the results the same code returned in SDK 1.x.
+    """
+    sentry_init()
+
+    events = capture_events()
+
+    sentry_sdk.set_tag("A", 1)
+    sentry_sdk.capture_message("Event A")
+
+    with Hub.current as hub:  # with hub
+        sentry_sdk.set_tag("B1", 1)
+        with hub.push_scope() as scope:  # push scope
+            sentry_sdk.set_tag("B2", 1)
+            hub.scope.set_tag("B3", 1)
+            scope.set_tag("B4", 1)
+            sentry_sdk.capture_message("Event B")
+        sentry_sdk.set_tag("B5", 1)
+        sentry_sdk.capture_message("Event C")
+
+    sentry_sdk.set_tag("Z", 1)
+    sentry_sdk.capture_message("Event Z")
+
+    (event_a, event_b, event_c, event_z) = events
+
+    # Check against the results the same code returned in SDK 1.x
+    assert event_a["tags"] == {"A": 1}
+    assert event_b["tags"] == {"A": 1, "B1": 1, "B2": 1, "B3": 1, "B4": 1}
+    assert event_c["tags"] == {"A": 1, "B1": 1, "B5": 1}
+    assert event_z["tags"] == {"A": 1, "B1": 1, "B5": 1, "Z": 1}
+
+
+def test_with_cloned_hub_sdk1(sentry_init, capture_events):
+    """
+    Mutate data in a `with cloned Hub:` block
+
+    Checks the results of SDK 2.x against the results the same code returned in SDK 1.x.
+    """
+    sentry_init()
+
+    events = capture_events()
+
+    sentry_sdk.set_tag("A", 1)
+    sentry_sdk.capture_message("Event A")
+
+    with Hub(Hub.current) as hub:  # clone hub
+        sentry_sdk.set_tag("B1", 1)
+        hub.scope.set_tag("B2", 1)
+        sentry_sdk.capture_message("Event B")
+
+    sentry_sdk.set_tag("Z", 1)
+    sentry_sdk.capture_message("Event Z")
+
+    (event_a, event_b, event_z) = events
+
+    # Check against the results the same code returned in SDK 1.x
+    assert event_a["tags"] == {"A": 1}
+    assert event_b["tags"] == {"A": 1, "B1": 1, "B2": 1}
+    assert event_z["tags"] == {"A": 1, "Z": 1}
+
+
+def test_with_cloned_hub_configure_scope_sdk1(sentry_init, capture_events):
+    """
+    Mutate data in a `with cloned Hub:` containing a `with configure_scope` block
+
+    Checks the results of SDK 2.x against the results the same code returned in SDK 1.x.
+    """
+    sentry_init()
+
+    events = capture_events()
+
+    sentry_sdk.set_tag("A", 1)
+    sentry_sdk.capture_message("Event A")
+
+    with Hub(Hub.current) as hub:  # clone hub
+        sentry_sdk.set_tag("B1", 1)
+        with hub.configure_scope() as scope:  # configure scope
+            sentry_sdk.set_tag("B2", 1)
+            hub.scope.set_tag("B3", 1)
+            scope.set_tag("B4", 1)
+            sentry_sdk.capture_message("Event B")
+        sentry_sdk.set_tag("B5", 1)
+        sentry_sdk.capture_message("Event C")
+
+    sentry_sdk.set_tag("Z", 1)
+    sentry_sdk.capture_message("Event Z")
+
+    (event_a, event_b, event_c, event_z) = events
+
+    # Check against the results the same code returned in SDK 1.x
+    assert event_a["tags"] == {"A": 1}
+    assert event_b["tags"] == {"A": 1, "B1": 1, "B2": 1, "B3": 1, "B4": 1}
+    assert event_c["tags"] == {"A": 1, "B1": 1, "B2": 1, "B3": 1, "B4": 1, "B5": 1}
+    assert event_z["tags"] == {"A": 1, "Z": 1}
+
+
+def test_with_cloned_hub_push_scope_sdk1(sentry_init, capture_events):
+    """
+    Mutate data in a `with cloned Hub:` containing a `with push_scope` block
+
+    Checks the results of SDK 2.x against the results the same code returned in SDK 1.x.
+    """
+    sentry_init()
+
+    events = capture_events()
+
+    sentry_sdk.set_tag("A", 1)
+    sentry_sdk.capture_message("Event A")
+
+    with Hub(Hub.current) as hub:  # clone hub
+        sentry_sdk.set_tag("B1", 1)
+        with hub.push_scope() as scope:  # push scope
+            sentry_sdk.set_tag("B2", 1)
+            hub.scope.set_tag("B3", 1)
+            scope.set_tag("B4", 1)
+            sentry_sdk.capture_message("Event B")
+        sentry_sdk.set_tag("B5", 1)
+        sentry_sdk.capture_message("Event C")
+
+    sentry_sdk.set_tag("Z", 1)
+    sentry_sdk.capture_message("Event Z")
+
+    (event_a, event_b, event_c, event_z) = events
+
+    # Check against the results the same code returned in SDK 1.x
+    assert event_a["tags"] == {"A": 1}
+    assert event_b["tags"] == {"A": 1, "B1": 1, "B2": 1, "B3": 1, "B4": 1}
+    assert event_c["tags"] == {"A": 1, "B1": 1, "B5": 1}
+    assert event_z["tags"] == {"A": 1, "Z": 1}
diff --git a/tests/new_scopes_compat/test_new_scopes_compat_event.py b/tests/new_scopes_compat/test_new_scopes_compat_event.py
new file mode 100644
index 0000000000..db1e5fec4b
--- /dev/null
+++ b/tests/new_scopes_compat/test_new_scopes_compat_event.py
@@ -0,0 +1,503 @@
+import pytest
+
+from unittest import mock
+
+import sentry_sdk
+from sentry_sdk.hub import Hub
+from sentry_sdk.integrations import iter_default_integrations
+from sentry_sdk.scrubber import EventScrubber, DEFAULT_DENYLIST
+
+
+"""
+Those tests are meant to check the compatibility of the new scopes in SDK 2.0 with the old Hub/Scope system in SDK 1.x.
+
+Those tests have been run with the latest SDK 1.x version and the data used in the `assert` statements represents
+the behvaior of the SDK 1.x.
+
+This makes sure that we are backwards compatible. (on a best effort basis, there will probably be some edge cases that are not covered here)
+"""
+
+
+@pytest.fixture
+def integrations():
+    return [
+        integration.identifier
+        for integration in iter_default_integrations(
+            with_auto_enabling_integrations=False
+        )
+    ]
+
+
+@pytest.fixture
+def expected_error(integrations):
+    def create_expected_error_event(trx, span):
+        return {
+            "level": "warning-X",
+            "exception": {
+                "values": [
+                    {
+                        "mechanism": {"type": "generic", "handled": True},
+                        "module": None,
+                        "type": "ValueError",
+                        "value": "This is a test exception",
+                        "stacktrace": {
+                            "frames": [
+                                {
+                                    "filename": "tests/new_scopes_compat/test_new_scopes_compat_event.py",
+                                    "abs_path": mock.ANY,
+                                    "function": "_faulty_function",
+                                    "module": "tests.new_scopes_compat.test_new_scopes_compat_event",
+                                    "lineno": mock.ANY,
+                                    "pre_context": [
+                                        "    return create_expected_transaction_event",
+                                        "",
+                                        "",
+                                        "def _faulty_function():",
+                                        "    try:",
+                                    ],
+                                    "context_line": '        raise ValueError("This is a test exception")',
+                                    "post_context": [
+                                        "    except ValueError as ex:",
+                                        "        sentry_sdk.capture_exception(ex)",
+                                        "",
+                                        "",
+                                        "def _test_before_send(event, hint):",
+                                    ],
+                                    "vars": {
+                                        "ex": mock.ANY,
+                                    },
+                                    "in_app": True,
+                                }
+                            ]
+                        },
+                    }
+                ]
+            },
+            "event_id": mock.ANY,
+            "timestamp": mock.ANY,
+            "contexts": {
+                "character": {
+                    "name": "Mighty Fighter changed by before_send",
+                    "age": 19,
+                    "attack_type": "melee",
+                },
+                "trace": {
+                    "trace_id": trx.trace_id,
+                    "span_id": span.span_id,
+                    "parent_span_id": span.parent_span_id,
+                    "op": "test_span",
+                    "origin": "manual",
+                    "description": None,
+                    "data": {
+                        "thread.id": mock.ANY,
+                        "thread.name": "MainThread",
+                    },
+                },
+                "runtime": {
+                    "name": "CPython",
+                    "version": mock.ANY,
+                    "build": mock.ANY,
+                },
+            },
+            "user": {
+                "id": "123",
+                "email": "jane.doe@example.com",
+                "ip_address": "[Filtered]",
+            },
+            "transaction": "test_transaction",
+            "transaction_info": {"source": "custom"},
+            "tags": {"tag1": "tag1_value", "tag2": "tag2_value"},
+            "extra": {
+                "extra1": "extra1_value",
+                "extra2": "extra2_value",
+                "should_be_removed_by_event_scrubber": "[Filtered]",
+                "sys.argv": "[Filtered]",
+            },
+            "breadcrumbs": {
+                "values": [
+                    {
+                        "category": "error-level",
+                        "message": "Authenticated user %s",
+                        "level": "error",
+                        "data": {"breadcrumb2": "somedata"},
+                        "timestamp": mock.ANY,
+                        "type": "default",
+                    }
+                ]
+            },
+            "modules": mock.ANY,
+            "release": "0.1.2rc3",
+            "environment": "checking-compatibility-with-sdk1",
+            "server_name": mock.ANY,
+            "sdk": {
+                "name": "sentry.python",
+                "version": mock.ANY,
+                "packages": [{"name": "pypi:sentry-sdk", "version": mock.ANY}],
+                "integrations": integrations,
+            },
+            "platform": "python",
+            "_meta": {
+                "user": {"ip_address": {"": {"rem": [["!config", "s"]]}}},
+                "extra": {
+                    "should_be_removed_by_event_scrubber": {
+                        "": {"rem": [["!config", "s"]]}
+                    },
+                    "sys.argv": {"": {"rem": [["!config", "s"]]}},
+                },
+            },
+        }
+
+    return create_expected_error_event
+
+
+@pytest.fixture
+def expected_transaction(integrations):
+    def create_expected_transaction_event(trx, span):
+        return {
+            "type": "transaction",
+            "transaction": "test_transaction changed by before_send_transaction",
+            "transaction_info": {"source": "custom"},
+            "contexts": {
+                "trace": {
+                    "trace_id": trx.trace_id,
+                    "span_id": trx.span_id,
+                    "parent_span_id": None,
+                    "op": "test_transaction_op",
+                    "origin": "manual",
+                    "description": None,
+                    "data": {
+                        "thread.id": mock.ANY,
+                        "thread.name": "MainThread",
+                    },
+                },
+                "character": {
+                    "name": "Mighty Fighter changed by before_send_transaction",
+                    "age": 19,
+                    "attack_type": "melee",
+                },
+                "runtime": {
+                    "name": "CPython",
+                    "version": mock.ANY,
+                    "build": mock.ANY,
+                },
+            },
+            "tags": {"tag1": "tag1_value", "tag2": "tag2_value"},
+            "timestamp": mock.ANY,
+            "start_timestamp": mock.ANY,
+            "spans": [
+                {
+                    "data": {
+                        "thread.id": mock.ANY,
+                        "thread.name": "MainThread",
+                    },
+                    "trace_id": trx.trace_id,
+                    "span_id": span.span_id,
+                    "parent_span_id": span.parent_span_id,
+                    "same_process_as_parent": True,
+                    "op": "test_span",
+                    "origin": "manual",
+                    "description": None,
+                    "start_timestamp": mock.ANY,
+                    "timestamp": mock.ANY,
+                }
+            ],
+            "measurements": {"memory_used": {"value": 456, "unit": "byte"}},
+            "event_id": mock.ANY,
+            "level": "warning-X",
+            "user": {
+                "id": "123",
+                "email": "jane.doe@example.com",
+                "ip_address": "[Filtered]",
+            },
+            "extra": {
+                "extra1": "extra1_value",
+                "extra2": "extra2_value",
+                "should_be_removed_by_event_scrubber": "[Filtered]",
+                "sys.argv": "[Filtered]",
+            },
+            "release": "0.1.2rc3",
+            "environment": "checking-compatibility-with-sdk1",
+            "server_name": mock.ANY,
+            "sdk": {
+                "name": "sentry.python",
+                "version": mock.ANY,
+                "packages": [{"name": "pypi:sentry-sdk", "version": mock.ANY}],
+                "integrations": integrations,
+            },
+            "platform": "python",
+            "_meta": {
+                "user": {"ip_address": {"": {"rem": [["!config", "s"]]}}},
+                "extra": {
+                    "should_be_removed_by_event_scrubber": {
+                        "": {"rem": [["!config", "s"]]}
+                    },
+                    "sys.argv": {"": {"rem": [["!config", "s"]]}},
+                },
+            },
+        }
+
+    return create_expected_transaction_event
+
+
+def _faulty_function():
+    try:
+        raise ValueError("This is a test exception")
+    except ValueError as ex:
+        sentry_sdk.capture_exception(ex)
+
+
+def _test_before_send(event, hint):
+    event["contexts"]["character"]["name"] += " changed by before_send"
+    return event
+
+
+def _test_before_send_transaction(event, hint):
+    event["transaction"] += " changed by before_send_transaction"
+    event["contexts"]["character"]["name"] += " changed by before_send_transaction"
+    return event
+
+
+def _test_before_breadcrumb(breadcrumb, hint):
+    if breadcrumb["category"] == "info-level":
+        return None
+    return breadcrumb
+
+
+def _generate_event_data(scope=None):
+    """
+    Generates some data to be used in the events sent by the tests.
+    """
+    sentry_sdk.set_level("warning-X")
+
+    sentry_sdk.add_breadcrumb(
+        category="info-level",
+        message="Authenticated user %s",
+        level="info",
+        data={"breadcrumb1": "somedata"},
+    )
+    sentry_sdk.add_breadcrumb(
+        category="error-level",
+        message="Authenticated user %s",
+        level="error",
+        data={"breadcrumb2": "somedata"},
+    )
+
+    sentry_sdk.set_context(
+        "character",
+        {
+            "name": "Mighty Fighter",
+            "age": 19,
+            "attack_type": "melee",
+        },
+    )
+
+    sentry_sdk.set_extra("extra1", "extra1_value")
+    sentry_sdk.set_extra("extra2", "extra2_value")
+    sentry_sdk.set_extra("should_be_removed_by_event_scrubber", "XXX")
+
+    sentry_sdk.set_tag("tag1", "tag1_value")
+    sentry_sdk.set_tag("tag2", "tag2_value")
+
+    sentry_sdk.set_user(
+        {"id": "123", "email": "jane.doe@example.com", "ip_address": "211.161.1.124"}
+    )
+
+    sentry_sdk.set_measurement("memory_used", 456, "byte")
+
+    if scope is not None:
+        scope.add_attachment(bytes=b"Hello World", filename="hello.txt")
+
+
+def _init_sentry_sdk(sentry_init):
+    sentry_init(
+        environment="checking-compatibility-with-sdk1",
+        release="0.1.2rc3",
+        before_send=_test_before_send,
+        before_send_transaction=_test_before_send_transaction,
+        before_breadcrumb=_test_before_breadcrumb,
+        event_scrubber=EventScrubber(
+            denylist=DEFAULT_DENYLIST
+            + ["should_be_removed_by_event_scrubber", "sys.argv"]
+        ),
+        send_default_pii=False,
+        traces_sample_rate=1.0,
+        auto_enabling_integrations=False,
+    )
+
+
+#
+# The actual Tests start here!
+#
+
+
+def test_event(sentry_init, capture_envelopes, expected_error, expected_transaction):
+    _init_sentry_sdk(sentry_init)
+
+    envelopes = capture_envelopes()
+
+    with sentry_sdk.start_transaction(
+        name="test_transaction", op="test_transaction_op"
+    ) as trx:
+        with sentry_sdk.start_span(op="test_span") as span:
+            with sentry_sdk.configure_scope() as scope:  # configure scope
+                _generate_event_data(scope)
+                _faulty_function()
+
+    (error_envelope, transaction_envelope) = envelopes
+
+    error = error_envelope.get_event()
+    transaction = transaction_envelope.get_transaction_event()
+    attachment = error_envelope.items[-1]
+
+    assert error == expected_error(trx, span)
+    assert transaction == expected_transaction(trx, span)
+    assert attachment.headers == {
+        "filename": "hello.txt",
+        "type": "attachment",
+        "content_type": "text/plain",
+    }
+    assert attachment.payload.bytes == b"Hello World"
+
+
+def test_event2(sentry_init, capture_envelopes, expected_error, expected_transaction):
+    _init_sentry_sdk(sentry_init)
+
+    envelopes = capture_envelopes()
+
+    with Hub(Hub.current):
+        sentry_sdk.set_tag("A", 1)  # will not be added
+
+    with Hub.current:  # with hub
+        with sentry_sdk.push_scope() as scope:
+            scope.set_tag("B", 1)  # will not be added
+
+        with sentry_sdk.start_transaction(
+            name="test_transaction", op="test_transaction_op"
+        ) as trx:
+            with sentry_sdk.start_span(op="test_span") as span:
+                with sentry_sdk.configure_scope() as scope:  # configure scope
+                    _generate_event_data(scope)
+                    _faulty_function()
+
+    (error_envelope, transaction_envelope) = envelopes
+
+    error = error_envelope.get_event()
+    transaction = transaction_envelope.get_transaction_event()
+    attachment = error_envelope.items[-1]
+
+    assert error == expected_error(trx, span)
+    assert transaction == expected_transaction(trx, span)
+    assert attachment.headers == {
+        "filename": "hello.txt",
+        "type": "attachment",
+        "content_type": "text/plain",
+    }
+    assert attachment.payload.bytes == b"Hello World"
+
+
+def test_event3(sentry_init, capture_envelopes, expected_error, expected_transaction):
+    _init_sentry_sdk(sentry_init)
+
+    envelopes = capture_envelopes()
+
+    with Hub(Hub.current):
+        sentry_sdk.set_tag("A", 1)  # will not be added
+
+    with Hub.current:  # with hub
+        with sentry_sdk.push_scope() as scope:
+            scope.set_tag("B", 1)  # will not be added
+
+        with sentry_sdk.push_scope() as scope:  # push scope
+            with sentry_sdk.start_transaction(
+                name="test_transaction", op="test_transaction_op"
+            ) as trx:
+                with sentry_sdk.start_span(op="test_span") as span:
+                    _generate_event_data(scope)
+                    _faulty_function()
+
+    (error_envelope, transaction_envelope) = envelopes
+
+    error = error_envelope.get_event()
+    transaction = transaction_envelope.get_transaction_event()
+    attachment = error_envelope.items[-1]
+
+    assert error == expected_error(trx, span)
+    assert transaction == expected_transaction(trx, span)
+    assert attachment.headers == {
+        "filename": "hello.txt",
+        "type": "attachment",
+        "content_type": "text/plain",
+    }
+    assert attachment.payload.bytes == b"Hello World"
+
+
+def test_event4(sentry_init, capture_envelopes, expected_error, expected_transaction):
+    _init_sentry_sdk(sentry_init)
+
+    envelopes = capture_envelopes()
+
+    with Hub(Hub.current):
+        sentry_sdk.set_tag("A", 1)  # will not be added
+
+    with Hub(Hub.current):  # with hub clone
+        with sentry_sdk.push_scope() as scope:
+            scope.set_tag("B", 1)  # will not be added
+
+        with sentry_sdk.start_transaction(
+            name="test_transaction", op="test_transaction_op"
+        ) as trx:
+            with sentry_sdk.start_span(op="test_span") as span:
+                with sentry_sdk.configure_scope() as scope:  # configure scope
+                    _generate_event_data(scope)
+                    _faulty_function()
+
+    (error_envelope, transaction_envelope) = envelopes
+
+    error = error_envelope.get_event()
+    transaction = transaction_envelope.get_transaction_event()
+    attachment = error_envelope.items[-1]
+
+    assert error == expected_error(trx, span)
+    assert transaction == expected_transaction(trx, span)
+    assert attachment.headers == {
+        "filename": "hello.txt",
+        "type": "attachment",
+        "content_type": "text/plain",
+    }
+    assert attachment.payload.bytes == b"Hello World"
+
+
+def test_event5(sentry_init, capture_envelopes, expected_error, expected_transaction):
+    _init_sentry_sdk(sentry_init)
+
+    envelopes = capture_envelopes()
+
+    with Hub(Hub.current):
+        sentry_sdk.set_tag("A", 1)  # will not be added
+
+    with Hub(Hub.current):  # with hub clone
+        with sentry_sdk.push_scope() as scope:
+            scope.set_tag("B", 1)  # will not be added
+
+        with sentry_sdk.push_scope() as scope:  # push scope
+            with sentry_sdk.start_transaction(
+                name="test_transaction", op="test_transaction_op"
+            ) as trx:
+                with sentry_sdk.start_span(op="test_span") as span:
+                    _generate_event_data(scope)
+                    _faulty_function()
+
+    (error_envelope, transaction_envelope) = envelopes
+
+    error = error_envelope.get_event()
+    transaction = transaction_envelope.get_transaction_event()
+    attachment = error_envelope.items[-1]
+
+    assert error == expected_error(trx, span)
+    assert transaction == expected_transaction(trx, span)
+    assert attachment.headers == {
+        "filename": "hello.txt",
+        "type": "attachment",
+        "content_type": "text/plain",
+    }
+    assert attachment.payload.bytes == b"Hello World"
diff --git a/tests/profiler/__init__.py b/tests/profiler/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/profiler/test_continuous_profiler.py b/tests/profiler/test_continuous_profiler.py
new file mode 100644
index 0000000000..e4f5cb5e25
--- /dev/null
+++ b/tests/profiler/test_continuous_profiler.py
@@ -0,0 +1,623 @@
+import threading
+import time
+from collections import defaultdict
+from unittest import mock
+
+import pytest
+
+import sentry_sdk
+from sentry_sdk.consts import VERSION
+from sentry_sdk.profiler.continuous_profiler import (
+    is_profile_session_sampled,
+    get_profiler_id,
+    setup_continuous_profiler,
+    start_profiler,
+    start_profile_session,
+    stop_profiler,
+    stop_profile_session,
+)
+from tests.conftest import ApproxDict
+
+try:
+    import gevent
+except ImportError:
+    gevent = None
+
+
+requires_gevent = pytest.mark.skipif(gevent is None, reason="gevent not enabled")
+
+
+def get_client_options(use_top_level_profiler_mode):
+    def client_options(
+        mode=None, auto_start=None, profile_session_sample_rate=1.0, lifecycle="manual"
+    ):
+        if use_top_level_profiler_mode:
+            return {
+                "profile_lifecycle": lifecycle,
+                "profiler_mode": mode,
+                "profile_session_sample_rate": profile_session_sample_rate,
+                "_experiments": {
+                    "continuous_profiling_auto_start": auto_start,
+                },
+            }
+        return {
+            "profile_lifecycle": lifecycle,
+            "profile_session_sample_rate": profile_session_sample_rate,
+            "_experiments": {
+                "continuous_profiling_auto_start": auto_start,
+                "continuous_profiling_mode": mode,
+            },
+        }
+
+    return client_options
+
+
+mock_sdk_info = {
+    "name": "sentry.python",
+    "version": VERSION,
+    "packages": [{"name": "pypi:sentry-sdk", "version": VERSION}],
+}
+
+
+@pytest.mark.parametrize("mode", [pytest.param("foo")])
+@pytest.mark.parametrize(
+    "make_options",
+    [
+        pytest.param(get_client_options(True), id="non-experiment"),
+        pytest.param(get_client_options(False), id="experiment"),
+    ],
+)
+def test_continuous_profiler_invalid_mode(mode, make_options, teardown_profiling):
+    with pytest.raises(ValueError):
+        setup_continuous_profiler(
+            make_options(mode=mode),
+            mock_sdk_info,
+            lambda envelope: None,
+        )
+
+
+@pytest.mark.parametrize(
+    "mode",
+    [
+        pytest.param("thread"),
+        pytest.param("gevent", marks=requires_gevent),
+    ],
+)
+@pytest.mark.parametrize(
+    "make_options",
+    [
+        pytest.param(get_client_options(True), id="non-experiment"),
+        pytest.param(get_client_options(False), id="experiment"),
+    ],
+)
+def test_continuous_profiler_valid_mode(mode, make_options, teardown_profiling):
+    options = make_options(mode=mode)
+    setup_continuous_profiler(
+        options,
+        mock_sdk_info,
+        lambda envelope: None,
+    )
+
+
+@pytest.mark.parametrize(
+    "mode",
+    [
+        pytest.param("thread"),
+        pytest.param("gevent", marks=requires_gevent),
+    ],
+)
+@pytest.mark.parametrize(
+    "make_options",
+    [
+        pytest.param(get_client_options(True), id="non-experiment"),
+        pytest.param(get_client_options(False), id="experiment"),
+    ],
+)
+def test_continuous_profiler_setup_twice(mode, make_options, teardown_profiling):
+    assert not is_profile_session_sampled()
+
+    # setting up the first time should return True to indicate success
+    options = make_options(mode=mode, profile_session_sample_rate=1.0)
+    assert setup_continuous_profiler(
+        options,
+        mock_sdk_info,
+        lambda envelope: None,
+    )
+    assert is_profile_session_sampled()
+
+    # setting up the second time should return True to indicate re-init
+    options = make_options(mode=mode, profile_session_sample_rate=0.0)
+    assert setup_continuous_profiler(
+        options,
+        mock_sdk_info,
+        lambda envelope: None,
+    )
+    assert not is_profile_session_sampled()
+
+
+def assert_single_transaction_with_profile_chunks(
+    envelopes, thread, max_chunks=None, transactions=1
+):
+    items = defaultdict(list)
+    for envelope in envelopes:
+        for item in envelope.items:
+            items[item.type].append(item)
+
+    assert len(items["transaction"]) == transactions
+    assert len(items["profile_chunk"]) > 0
+    if max_chunks is not None:
+        assert len(items["profile_chunk"]) <= max_chunks
+
+    for chunk_item in items["profile_chunk"]:
+        chunk = chunk_item.payload.json
+        headers = chunk_item.headers
+        assert chunk["platform"] == headers["platform"]
+
+    transaction = items["transaction"][0].payload.json
+
+    trace_context = transaction["contexts"]["trace"]
+
+    assert trace_context == ApproxDict(
+        {
+            "data": ApproxDict(
+                {
+                    "thread.id": str(thread.ident),
+                    "thread.name": thread.name,
+                }
+            ),
+        }
+    )
+
+    profile_context = transaction["contexts"]["profile"]
+    profiler_id = profile_context["profiler_id"]
+
+    assert profile_context == ApproxDict({"profiler_id": profiler_id})
+
+    spans = transaction["spans"]
+    assert len(spans) > 0
+    for span in spans:
+        assert span["data"] == ApproxDict(
+            {
+                "profiler_id": profiler_id,
+                "thread.id": str(thread.ident),
+                "thread.name": thread.name,
+            }
+        )
+
+    for profile_chunk_item in items["profile_chunk"]:
+        profile_chunk = profile_chunk_item.payload.json
+        del profile_chunk["profile"]  # make the diff easier to read
+        assert profile_chunk == ApproxDict(
+            {
+                "client_sdk": {
+                    "name": mock.ANY,
+                    "version": VERSION,
+                },
+                "platform": "python",
+                "profiler_id": profiler_id,
+                "version": "2",
+            }
+        )
+
+
+def assert_single_transaction_without_profile_chunks(envelopes):
+    items = defaultdict(list)
+    for envelope in envelopes:
+        for item in envelope.items:
+            items[item.type].append(item)
+
+    assert len(items["transaction"]) == 1
+    assert len(items["profile_chunk"]) == 0
+
+    transaction = items["transaction"][0].payload.json
+    assert "profile" not in transaction["contexts"]
+
+
+@pytest.mark.forked
+@pytest.mark.parametrize(
+    "mode",
+    [
+        pytest.param("thread"),
+        pytest.param("gevent", marks=requires_gevent),
+    ],
+)
+@pytest.mark.parametrize(
+    ["start_profiler_func", "stop_profiler_func"],
+    [
+        pytest.param(
+            start_profile_session,
+            stop_profile_session,
+            id="start_profile_session/stop_profile_session (deprecated)",
+        ),
+        pytest.param(
+            start_profiler,
+            stop_profiler,
+            id="start_profiler/stop_profiler",
+        ),
+    ],
+)
+@pytest.mark.parametrize(
+    "make_options",
+    [
+        pytest.param(get_client_options(True), id="non-experiment"),
+        pytest.param(get_client_options(False), id="experiment"),
+    ],
+)
+@mock.patch("sentry_sdk.profiler.continuous_profiler.PROFILE_BUFFER_SECONDS", 0.01)
+def test_continuous_profiler_auto_start_and_manual_stop(
+    sentry_init,
+    capture_envelopes,
+    mode,
+    start_profiler_func,
+    stop_profiler_func,
+    make_options,
+    teardown_profiling,
+):
+    options = make_options(mode=mode, auto_start=True)
+    sentry_init(
+        traces_sample_rate=1.0,
+        **options,
+    )
+
+    envelopes = capture_envelopes()
+
+    thread = threading.current_thread()
+
+    with sentry_sdk.start_transaction(name="profiling"):
+        with sentry_sdk.start_span(op="op"):
+            time.sleep(0.05)
+
+    assert_single_transaction_with_profile_chunks(envelopes, thread)
+
+    for _ in range(3):
+        stop_profiler_func()
+
+        envelopes.clear()
+
+        with sentry_sdk.start_transaction(name="profiling"):
+            with sentry_sdk.start_span(op="op"):
+                time.sleep(0.05)
+
+        assert_single_transaction_without_profile_chunks(envelopes)
+
+        start_profiler_func()
+
+        envelopes.clear()
+
+        with sentry_sdk.start_transaction(name="profiling"):
+            with sentry_sdk.start_span(op="op"):
+                time.sleep(0.05)
+
+        assert_single_transaction_with_profile_chunks(envelopes, thread)
+
+
+@pytest.mark.parametrize(
+    "mode",
+    [
+        pytest.param("thread"),
+        pytest.param("gevent", marks=requires_gevent),
+    ],
+)
+@pytest.mark.parametrize(
+    ["start_profiler_func", "stop_profiler_func"],
+    [
+        pytest.param(
+            start_profile_session,
+            stop_profile_session,
+            id="start_profile_session/stop_profile_session  (deprecated)",
+        ),
+        pytest.param(
+            start_profiler,
+            stop_profiler,
+            id="start_profiler/stop_profiler",
+        ),
+    ],
+)
+@pytest.mark.parametrize(
+    "make_options",
+    [
+        pytest.param(get_client_options(True), id="non-experiment"),
+        pytest.param(get_client_options(False), id="experiment"),
+    ],
+)
+@mock.patch("sentry_sdk.profiler.continuous_profiler.PROFILE_BUFFER_SECONDS", 0.01)
+def test_continuous_profiler_manual_start_and_stop_sampled(
+    sentry_init,
+    capture_envelopes,
+    mode,
+    start_profiler_func,
+    stop_profiler_func,
+    make_options,
+    teardown_profiling,
+):
+    options = make_options(
+        mode=mode, profile_session_sample_rate=1.0, lifecycle="manual"
+    )
+    sentry_init(
+        traces_sample_rate=1.0,
+        **options,
+    )
+
+    envelopes = capture_envelopes()
+
+    thread = threading.current_thread()
+
+    for _ in range(3):
+        start_profiler_func()
+
+        envelopes.clear()
+
+        with sentry_sdk.start_transaction(name="profiling"):
+            assert get_profiler_id() is not None, "profiler should be running"
+            with sentry_sdk.start_span(op="op"):
+                time.sleep(0.1)
+            assert get_profiler_id() is not None, "profiler should be running"
+
+        assert_single_transaction_with_profile_chunks(envelopes, thread)
+
+        assert get_profiler_id() is not None, "profiler should be running"
+
+        stop_profiler_func()
+
+        # the profiler stops immediately in manual mode
+        assert get_profiler_id() is None, "profiler should not be running"
+
+        envelopes.clear()
+
+        with sentry_sdk.start_transaction(name="profiling"):
+            assert get_profiler_id() is None, "profiler should not be running"
+            with sentry_sdk.start_span(op="op"):
+                time.sleep(0.1)
+            assert get_profiler_id() is None, "profiler should not be running"
+
+        assert_single_transaction_without_profile_chunks(envelopes)
+
+
+@pytest.mark.parametrize(
+    "mode",
+    [
+        pytest.param("thread"),
+        pytest.param("gevent", marks=requires_gevent),
+    ],
+)
+@pytest.mark.parametrize(
+    ["start_profiler_func", "stop_profiler_func"],
+    [
+        pytest.param(
+            start_profile_session,
+            stop_profile_session,
+            id="start_profile_session/stop_profile_session (deprecated)",
+        ),
+        pytest.param(
+            start_profiler,
+            stop_profiler,
+            id="start_profiler/stop_profiler",
+        ),
+    ],
+)
+@pytest.mark.parametrize(
+    "make_options",
+    [
+        pytest.param(get_client_options(True), id="non-experiment"),
+        pytest.param(get_client_options(False), id="experiment"),
+    ],
+)
+def test_continuous_profiler_manual_start_and_stop_unsampled(
+    sentry_init,
+    capture_envelopes,
+    mode,
+    start_profiler_func,
+    stop_profiler_func,
+    make_options,
+    teardown_profiling,
+):
+    options = make_options(
+        mode=mode, profile_session_sample_rate=0.0, lifecycle="manual"
+    )
+    sentry_init(
+        traces_sample_rate=1.0,
+        **options,
+    )
+
+    envelopes = capture_envelopes()
+
+    start_profiler_func()
+
+    with sentry_sdk.start_transaction(name="profiling"):
+        with sentry_sdk.start_span(op="op"):
+            time.sleep(0.05)
+
+    assert_single_transaction_without_profile_chunks(envelopes)
+
+    stop_profiler_func()
+
+
+@pytest.mark.parametrize(
+    "mode",
+    [
+        pytest.param("thread"),
+        pytest.param("gevent", marks=requires_gevent),
+    ],
+)
+@pytest.mark.parametrize(
+    "make_options",
+    [
+        pytest.param(get_client_options(True), id="non-experiment"),
+        pytest.param(get_client_options(False), id="experiment"),
+    ],
+)
+@mock.patch("sentry_sdk.profiler.continuous_profiler.DEFAULT_SAMPLING_FREQUENCY", 21)
+def test_continuous_profiler_auto_start_and_stop_sampled(
+    sentry_init,
+    capture_envelopes,
+    mode,
+    make_options,
+    teardown_profiling,
+):
+    options = make_options(
+        mode=mode, profile_session_sample_rate=1.0, lifecycle="trace"
+    )
+    sentry_init(
+        traces_sample_rate=1.0,
+        **options,
+    )
+
+    envelopes = capture_envelopes()
+
+    thread = threading.current_thread()
+
+    all_profiler_ids = set()
+
+    for _ in range(3):
+        envelopes.clear()
+
+        profiler_ids = set()
+
+        with sentry_sdk.start_transaction(name="profiling 1"):
+            profiler_id = get_profiler_id()
+            assert profiler_id is not None, "profiler should be running"
+            profiler_ids.add(profiler_id)
+            with sentry_sdk.start_span(op="op"):
+                time.sleep(0.1)
+            profiler_id = get_profiler_id()
+            assert profiler_id is not None, "profiler should be running"
+            profiler_ids.add(profiler_id)
+
+        time.sleep(0.03)
+
+        # the profiler takes a while to stop in auto mode so if we start
+        # a transaction immediately, it'll be part of the same chunk
+        profiler_id = get_profiler_id()
+        assert profiler_id is not None, "profiler should be running"
+        profiler_ids.add(profiler_id)
+
+        with sentry_sdk.start_transaction(name="profiling 2"):
+            profiler_id = get_profiler_id()
+            assert profiler_id is not None, "profiler should be running"
+            profiler_ids.add(profiler_id)
+            with sentry_sdk.start_span(op="op"):
+                time.sleep(0.1)
+            profiler_id = get_profiler_id()
+            assert profiler_id is not None, "profiler should be running"
+            profiler_ids.add(profiler_id)
+
+        # wait at least 1 cycle for the profiler to stop
+        time.sleep(0.2)
+        assert get_profiler_id() is None, "profiler should not be running"
+
+        assert len(profiler_ids) == 1
+        all_profiler_ids.add(profiler_ids.pop())
+
+        assert_single_transaction_with_profile_chunks(
+            envelopes, thread, max_chunks=1, transactions=2
+        )
+
+    assert len(all_profiler_ids) == 3
+
+
+@pytest.mark.parametrize(
+    "mode",
+    [
+        pytest.param("thread"),
+        pytest.param("gevent", marks=requires_gevent),
+    ],
+)
+@pytest.mark.parametrize(
+    "make_options",
+    [
+        pytest.param(get_client_options(True), id="non-experiment"),
+        pytest.param(get_client_options(False), id="experiment"),
+    ],
+)
+@mock.patch("sentry_sdk.profiler.continuous_profiler.PROFILE_BUFFER_SECONDS", 0.01)
+def test_continuous_profiler_auto_start_and_stop_unsampled(
+    sentry_init,
+    capture_envelopes,
+    mode,
+    make_options,
+    teardown_profiling,
+):
+    options = make_options(
+        mode=mode, profile_session_sample_rate=0.0, lifecycle="trace"
+    )
+    sentry_init(
+        traces_sample_rate=1.0,
+        **options,
+    )
+
+    envelopes = capture_envelopes()
+
+    for _ in range(3):
+        envelopes.clear()
+
+        with sentry_sdk.start_transaction(name="profiling"):
+            assert get_profiler_id() is None, "profiler should not be running"
+            with sentry_sdk.start_span(op="op"):
+                time.sleep(0.05)
+            assert get_profiler_id() is None, "profiler should not be running"
+
+        assert get_profiler_id() is None, "profiler should not be running"
+        assert_single_transaction_without_profile_chunks(envelopes)
+
+
+@pytest.mark.parametrize(
+    ["mode", "class_name"],
+    [
+        pytest.param("thread", "ThreadContinuousScheduler"),
+        pytest.param(
+            "gevent",
+            "GeventContinuousScheduler",
+            marks=requires_gevent,
+        ),
+    ],
+)
+@pytest.mark.parametrize(
+    ["start_profiler_func", "stop_profiler_func"],
+    [
+        pytest.param(
+            start_profile_session,
+            stop_profile_session,
+            id="start_profile_session/stop_profile_session (deprecated)",
+        ),
+        pytest.param(
+            start_profiler,
+            stop_profiler,
+            id="start_profiler/stop_profiler",
+        ),
+    ],
+)
+@pytest.mark.parametrize(
+    "make_options",
+    [
+        pytest.param(get_client_options(True), id="non-experiment"),
+        pytest.param(get_client_options(False), id="experiment"),
+    ],
+)
+def test_continuous_profiler_manual_start_and_stop_noop_when_using_trace_lifecyle(
+    sentry_init,
+    mode,
+    start_profiler_func,
+    stop_profiler_func,
+    class_name,
+    make_options,
+    teardown_profiling,
+):
+    options = make_options(
+        mode=mode, profile_session_sample_rate=0.0, lifecycle="trace"
+    )
+    sentry_init(
+        traces_sample_rate=1.0,
+        **options,
+    )
+
+    with mock.patch(
+        f"sentry_sdk.profiler.continuous_profiler.{class_name}.ensure_running"
+    ) as mock_ensure_running:
+        start_profiler_func()
+        mock_ensure_running.assert_not_called()
+
+    with mock.patch(
+        f"sentry_sdk.profiler.continuous_profiler.{class_name}.teardown"
+    ) as mock_teardown:
+        stop_profiler_func()
+        mock_teardown.assert_not_called()
diff --git a/tests/test_profiler.py b/tests/profiler/test_transaction_profiler.py
similarity index 81%
rename from tests/test_profiler.py
rename to tests/profiler/test_transaction_profiler.py
index 451ebe65a3..2ba11bfcea 100644
--- a/tests/test_profiler.py
+++ b/tests/profiler/test_transaction_profiler.py
@@ -1,33 +1,30 @@
 import inspect
 import os
+import sentry_sdk
 import sys
 import threading
 import time
+import warnings
+from collections import defaultdict
+from unittest import mock
 
 import pytest
 
-from collections import defaultdict
 from sentry_sdk import start_transaction
-from sentry_sdk.profiler import (
+from sentry_sdk.profiler.transaction_profiler import (
     GeventScheduler,
     Profile,
     Scheduler,
     ThreadScheduler,
+    setup_profiler,
+)
+from sentry_sdk.profiler.utils import (
     extract_frame,
     extract_stack,
     frame_id,
-    get_current_thread_id,
     get_frame_name,
-    setup_profiler,
 )
-from sentry_sdk.tracing import Transaction
 from sentry_sdk._lru_cache import LRUCache
-from sentry_sdk._queue import Queue
-
-try:
-    from unittest import mock  # python 3.3 and above
-except ImportError:
-    import mock  # python < 3.3
 
 try:
     import gevent
@@ -35,12 +32,6 @@
     gevent = None
 
 
-def requires_python_version(major, minor, reason=None):
-    if reason is None:
-        reason = "Requires Python {}.{}".format(major, minor)
-    return pytest.mark.skipif(sys.version_info < (major, minor), reason=reason)
-
-
 requires_gevent = pytest.mark.skipif(gevent is None, reason="gevent not enabled")
 
 
@@ -59,16 +50,9 @@ def experimental_options(mode=None, sample_rate=None):
     }
 
 
-@requires_python_version(3, 3)
 @pytest.mark.parametrize(
     "mode",
-    [
-        pytest.param("foo"),
-        pytest.param(
-            "gevent",
-            marks=pytest.mark.skipif(gevent is not None, reason="gevent not enabled"),
-        ),
-    ],
+    [pytest.param("foo")],
 )
 @pytest.mark.parametrize(
     "make_options",
@@ -82,7 +66,6 @@ def test_profiler_invalid_mode(mode, make_options, teardown_profiling):
         setup_profiler(make_options(mode))
 
 
-@requires_python_version(3, 3)
 @pytest.mark.parametrize(
     "mode",
     [
@@ -103,7 +86,6 @@ def test_profiler_valid_mode(mode, make_options, teardown_profiling):
     setup_profiler(make_options(mode))
 
 
-@requires_python_version(3, 3)
 @pytest.mark.parametrize(
     "make_options",
     [
@@ -118,7 +100,6 @@ def test_profiler_setup_twice(make_options, teardown_profiling):
     assert not setup_profiler(make_options())
 
 
-@requires_python_version(3, 3)
 @pytest.mark.parametrize(
     "mode",
     [
@@ -143,11 +124,11 @@ def test_profiler_setup_twice(make_options, teardown_profiling):
         pytest.param(non_experimental_options, id="non experimental"),
     ],
 )
-@mock.patch("sentry_sdk.profiler.PROFILE_MINIMUM_SAMPLES", 0)
+@mock.patch("sentry_sdk.profiler.transaction_profiler.PROFILE_MINIMUM_SAMPLES", 0)
 def test_profiles_sample_rate(
     sentry_init,
     capture_envelopes,
-    capture_client_reports,
+    capture_record_lost_event_calls,
     teardown_profiling,
     profiles_sample_rate,
     profile_count,
@@ -163,9 +144,11 @@ def test_profiles_sample_rate(
     )
 
     envelopes = capture_envelopes()
-    reports = capture_client_reports()
+    record_lost_event_calls = capture_record_lost_event_calls()
 
-    with mock.patch("sentry_sdk.profiler.random.random", return_value=0.5):
+    with mock.patch(
+        "sentry_sdk.profiler.transaction_profiler.random.random", return_value=0.5
+    ):
         with start_transaction(name="profiling"):
             pass
 
@@ -177,14 +160,13 @@ def test_profiles_sample_rate(
     assert len(items["transaction"]) == 1
     assert len(items["profile"]) == profile_count
     if profiles_sample_rate is None or profiles_sample_rate == 0:
-        assert reports == []
+        assert record_lost_event_calls == []
     elif profile_count:
-        assert reports == []
+        assert record_lost_event_calls == []
     else:
-        assert reports == [("sample_rate", "profile")]
+        assert record_lost_event_calls == [("sample_rate", "profile", None, 1)]
 
 
-@requires_python_version(3, 3)
 @pytest.mark.parametrize(
     "mode",
     [
@@ -217,11 +199,11 @@ def test_profiles_sample_rate(
         pytest.param(lambda _: False, 0, id="profiler sampled at False"),
     ],
 )
-@mock.patch("sentry_sdk.profiler.PROFILE_MINIMUM_SAMPLES", 0)
+@mock.patch("sentry_sdk.profiler.transaction_profiler.PROFILE_MINIMUM_SAMPLES", 0)
 def test_profiles_sampler(
     sentry_init,
     capture_envelopes,
-    capture_client_reports,
+    capture_record_lost_event_calls,
     teardown_profiling,
     profiles_sampler,
     profile_count,
@@ -233,9 +215,11 @@ def test_profiles_sampler(
     )
 
     envelopes = capture_envelopes()
-    reports = capture_client_reports()
+    record_lost_event_calls = capture_record_lost_event_calls()
 
-    with mock.patch("sentry_sdk.profiler.random.random", return_value=0.5):
+    with mock.patch(
+        "sentry_sdk.profiler.transaction_profiler.random.random", return_value=0.5
+    ):
         with start_transaction(name="profiling"):
             pass
 
@@ -247,16 +231,15 @@ def test_profiles_sampler(
     assert len(items["transaction"]) == 1
     assert len(items["profile"]) == profile_count
     if profile_count:
-        assert reports == []
+        assert record_lost_event_calls == []
     else:
-        assert reports == [("sample_rate", "profile")]
+        assert record_lost_event_calls == [("sample_rate", "profile", None, 1)]
 
 
-@requires_python_version(3, 3)
 def test_minimum_unique_samples_required(
     sentry_init,
     capture_envelopes,
-    capture_client_reports,
+    capture_record_lost_event_calls,
     teardown_profiling,
 ):
     sentry_init(
@@ -265,7 +248,7 @@ def test_minimum_unique_samples_required(
     )
 
     envelopes = capture_envelopes()
-    reports = capture_client_reports()
+    record_lost_event_calls = capture_record_lost_event_calls()
 
     with start_transaction(name="profiling"):
         pass
@@ -279,10 +262,10 @@ def test_minimum_unique_samples_required(
     # because we dont leave any time for the profiler to
     # take any samples, it should be not be sent
     assert len(items["profile"]) == 0
-    assert reports == [("insufficient_data", "profile")]
+    assert record_lost_event_calls == [("insufficient_data", "profile", None, 1)]
 
 
-@requires_python_version(3, 3)
+@pytest.mark.forked
 def test_profile_captured(
     sentry_init,
     capture_envelopes,
@@ -372,7 +355,6 @@ def static_method():
         return inspect.currentframe()
 
 
-@requires_python_version(3, 3)
 @pytest.mark.parametrize(
     ("frame", "frame_name"),
     [
@@ -393,9 +375,11 @@ def static_method():
         ),
         pytest.param(
             GetFrame().instance_method_wrapped()(),
-            "wrapped"
-            if sys.version_info < (3, 11)
-            else "GetFrame.instance_method_wrapped..wrapped",
+            (
+                "wrapped"
+                if sys.version_info < (3, 11)
+                else "GetFrame.instance_method_wrapped..wrapped"
+            ),
             id="instance_method_wrapped",
         ),
         pytest.param(
@@ -405,9 +389,11 @@ def static_method():
         ),
         pytest.param(
             GetFrame().class_method_wrapped()(),
-            "wrapped"
-            if sys.version_info < (3, 11)
-            else "GetFrame.class_method_wrapped..wrapped",
+            (
+                "wrapped"
+                if sys.version_info < (3, 11)
+                else "GetFrame.class_method_wrapped..wrapped"
+            ),
             id="class_method_wrapped",
         ),
         pytest.param(
@@ -422,9 +408,11 @@ def static_method():
         ),
         pytest.param(
             GetFrame().inherited_instance_method_wrapped()(),
-            "wrapped"
-            if sys.version_info < (3, 11)
-            else "GetFrameBase.inherited_instance_method_wrapped..wrapped",
+            (
+                "wrapped"
+                if sys.version_info < (3, 11)
+                else "GetFrameBase.inherited_instance_method_wrapped..wrapped"
+            ),
             id="instance_method_wrapped",
         ),
         pytest.param(
@@ -434,16 +422,20 @@ def static_method():
         ),
         pytest.param(
             GetFrame().inherited_class_method_wrapped()(),
-            "wrapped"
-            if sys.version_info < (3, 11)
-            else "GetFrameBase.inherited_class_method_wrapped..wrapped",
+            (
+                "wrapped"
+                if sys.version_info < (3, 11)
+                else "GetFrameBase.inherited_class_method_wrapped..wrapped"
+            ),
             id="inherited_class_method_wrapped",
         ),
         pytest.param(
             GetFrame().inherited_static_method(),
-            "inherited_static_method"
-            if sys.version_info < (3, 11)
-            else "GetFrameBase.inherited_static_method",
+            (
+                "inherited_static_method"
+                if sys.version_info < (3, 11)
+                else "GetFrameBase.inherited_static_method"
+            ),
             id="inherited_static_method",
         ),
     ],
@@ -452,7 +444,6 @@ def test_get_frame_name(frame, frame_name):
     assert get_frame_name(frame) == frame_name
 
 
-@requires_python_version(3, 3)
 @pytest.mark.parametrize(
     ("get_frame", "function"),
     [
@@ -480,7 +471,6 @@ def test_extract_frame(get_frame, function):
     assert isinstance(extracted_frame["lineno"], int)
 
 
-@requires_python_version(3, 3)
 @pytest.mark.parametrize(
     ("depth", "max_stack_depth", "actual_depth"),
     [
@@ -522,7 +512,6 @@ def test_extract_stack_with_max_depth(depth, max_stack_depth, actual_depth):
         assert frames[actual_depth]["function"] == "", actual_depth
 
 
-@requires_python_version(3, 3)
 @pytest.mark.parametrize(
     ("frame", "depth"),
     [(get_frame(depth=1), len(inspect.stack()))],
@@ -545,79 +534,10 @@ def test_extract_stack_with_cache(frame, depth):
         assert frame1 is frame2, i
 
 
-@requires_python_version(3, 3)
-def test_get_current_thread_id_explicit_thread():
-    results = Queue(maxsize=1)
-
-    def target1():
-        pass
-
-    def target2():
-        results.put(get_current_thread_id(thread1))
-
-    thread1 = threading.Thread(target=target1)
-    thread1.start()
-
-    thread2 = threading.Thread(target=target2)
-    thread2.start()
-
-    thread2.join()
-    thread1.join()
-
-    assert thread1.ident == results.get(timeout=1)
-
-
-@requires_python_version(3, 3)
-@requires_gevent
-def test_get_current_thread_id_gevent_in_thread():
-    results = Queue(maxsize=1)
-
-    def target():
-        job = gevent.spawn(get_current_thread_id)
-        job.join()
-        results.put(job.value)
-
-    thread = threading.Thread(target=target)
-    thread.start()
-    thread.join()
-    assert thread.ident == results.get(timeout=1)
-
-
-@requires_python_version(3, 3)
-def test_get_current_thread_id_running_thread():
-    results = Queue(maxsize=1)
-
-    def target():
-        results.put(get_current_thread_id())
-
-    thread = threading.Thread(target=target)
-    thread.start()
-    thread.join()
-    assert thread.ident == results.get(timeout=1)
-
-
-@requires_python_version(3, 3)
-def test_get_current_thread_id_main_thread():
-    results = Queue(maxsize=1)
-
-    def target():
-        # mock that somehow the current thread doesn't exist
-        with mock.patch("threading.current_thread", side_effect=[None]):
-            results.put(get_current_thread_id())
-
-    thread_id = threading.main_thread().ident if sys.version_info >= (3, 4) else None
-
-    thread = threading.Thread(target=target)
-    thread.start()
-    thread.join()
-    assert thread_id == results.get(timeout=1)
-
-
 def get_scheduler_threads(scheduler):
     return [thread for thread in threading.enumerate() if thread.name == scheduler.name]
 
 
-@requires_python_version(3, 3)
 @pytest.mark.parametrize(
     ("scheduler_class",),
     [
@@ -661,7 +581,50 @@ def test_thread_scheduler_single_background_thread(scheduler_class):
     assert len(get_scheduler_threads(scheduler)) == 0
 
 
-@requires_python_version(3, 3)
+@pytest.mark.parametrize(
+    ("scheduler_class",),
+    [
+        pytest.param(ThreadScheduler, id="thread scheduler"),
+        pytest.param(
+            GeventScheduler,
+            marks=[
+                requires_gevent,
+                pytest.mark.skip(
+                    reason="cannot find this thread via threading.enumerate()"
+                ),
+            ],
+            id="gevent scheduler",
+        ),
+    ],
+)
+def test_thread_scheduler_no_thread_on_shutdown(scheduler_class):
+    scheduler = scheduler_class(frequency=1000)
+
+    # not yet setup, no scheduler threads yet
+    assert len(get_scheduler_threads(scheduler)) == 0
+
+    scheduler.setup()
+
+    # setup but no profiles started so still no threads
+    assert len(get_scheduler_threads(scheduler)) == 0
+
+    # mock RuntimeError as if the 3.12 intepreter was shutting down
+    with mock.patch(
+        "threading.Thread.start",
+        side_effect=RuntimeError("can't create new thread at interpreter shutdown"),
+    ):
+        scheduler.ensure_running()
+
+    assert scheduler.running is False
+
+    # still no thread
+    assert len(get_scheduler_threads(scheduler)) == 0
+
+    scheduler.teardown()
+
+    assert len(get_scheduler_threads(scheduler)) == 0
+
+
 @pytest.mark.parametrize(
     ("scheduler_class",),
     [
@@ -669,7 +632,7 @@ def test_thread_scheduler_single_background_thread(scheduler_class):
         pytest.param(GeventScheduler, marks=requires_gevent, id="gevent scheduler"),
     ],
 )
-@mock.patch("sentry_sdk.profiler.MAX_PROFILE_DURATION_NS", 1)
+@mock.patch("sentry_sdk.profiler.transaction_profiler.MAX_PROFILE_DURATION_NS", 1)
 def test_max_profile_duration_reached(scheduler_class):
     sample = [
         (
@@ -683,8 +646,7 @@ def test_max_profile_duration_reached(scheduler_class):
     ]
 
     with scheduler_class(frequency=1000) as scheduler:
-        transaction = Transaction(sampled=True)
-        with Profile(transaction, scheduler=scheduler) as profile:
+        with Profile(True, 0, scheduler=scheduler) as profile:
             # profile just started, it's active
             assert profile.active
 
@@ -702,16 +664,13 @@ def test_max_profile_duration_reached(scheduler_class):
 
 
 class NoopScheduler(Scheduler):
-    def setup(self):
-        # type: () -> None
+    def setup(self) -> None:
         pass
 
-    def teardown(self):
-        # type: () -> None
+    def teardown(self) -> None:
         pass
 
-    def ensure_running(self):
-        # type: () -> None
+    def ensure_running(self) -> None:
         pass
 
 
@@ -739,7 +698,6 @@ def ensure_running(self):
 ]
 
 
-@requires_python_version(3, 3)
 @pytest.mark.parametrize(
     ("samples", "expected"),
     [
@@ -832,15 +790,14 @@ def ensure_running(self):
         ),
     ],
 )
-@mock.patch("sentry_sdk.profiler.MAX_PROFILE_DURATION_NS", 5)
+@mock.patch("sentry_sdk.profiler.transaction_profiler.MAX_PROFILE_DURATION_NS", 5)
 def test_profile_processing(
     DictionaryContaining,  # noqa: N803
     samples,
     expected,
 ):
     with NoopScheduler(frequency=1000) as scheduler:
-        transaction = Transaction(sampled=True)
-        with Profile(transaction, scheduler=scheduler) as profile:
+        with Profile(True, 0, scheduler=scheduler) as profile:
             for ts, sample in samples:
                 # force the sample to be written at a time relative to the
                 # start of the profile
@@ -855,3 +812,27 @@ def test_profile_processing(
             assert processed["frames"] == expected["frames"]
             assert processed["stacks"] == expected["stacks"]
             assert processed["samples"] == expected["samples"]
+
+
+def test_hub_backwards_compatibility(suppress_deprecation_warnings):
+    hub = sentry_sdk.Hub()
+
+    with pytest.warns(DeprecationWarning):
+        profile = Profile(True, 0, hub=hub)
+
+    with pytest.warns(DeprecationWarning):
+        assert profile.hub is hub
+
+    new_hub = sentry_sdk.Hub()
+
+    with pytest.warns(DeprecationWarning):
+        profile.hub = new_hub
+
+    with pytest.warns(DeprecationWarning):
+        assert profile.hub is new_hub
+
+
+def test_no_warning_without_hub():
+    with warnings.catch_warnings():
+        warnings.simplefilter("error")
+        Profile(True, 0)
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-----
+MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQCNSgCTO5Pc7o21
+BfvfDv/UDwDydEhInosNG7lgumqelT4dyJcYWoiDYAZ8zf6mlPFaw3oYouq+nQo/
+Z5eRNQD6AxhXw86qANjcfs1HWoP8d7jgR+ZelrshadvBBGYUJhiDkjUWb8jU7b9M
+28z5m4SA5enfSrQYZfVlrX8MFxV70ws5duLye92FYjpqFBWeeGtmsw1iWUO020Nj
+bbngpcRmRiBq41KuPydD8IWWQteoOVAI3U2jwEI2foAkXTHB+kQF//NtUWz5yiZY
+4ugjY20p0t8Asom1oDK9pL2Qy4EQpsCev/6SJ+o7sK6oR1gyrzodn6hcqJbqcXvp
+Y6xgXIO02H8wn7e3NkAJZkfFWJAyIslYrurMcnZwDaLpzL35vyULseOtDfsWQ3yq
+TflXHcA2Zlujuv7rmq6Q+GCaLJxbmj5bPUvv8DAARd97BXf57s6C9srT8kk5Ekbf
+URWRiO8j5XDLPyqsaP1c/pMPee1CGdtY6gf9EDWgmivgAYvH27pqzKh0JJAsmJ8p
+1Zp5xFMtEkzoTlKL2jqeyS6zBO/o+9MHJld5OHcUvlWm767vKKe++aV2IA3h9nBQ
+vmbCQ9i0ufGXZYZtJUYk6T8EMLclvtQz4yLRAYx0PLFOKfi1pAfDAHBFEfwWmuCk
+cYqw8erbbfoj0qpnuDEj45iUtH5gRwIDAQABAoICADqdqfFrNSPiYC3qxpy6x039
+z4HG1joydDPC/bxwek1CU1vd3TmATcRbMTXT7ELF5f+mu1+/Ly5XTmoRmyLl33rZ
+j97RYErNQSrw/E8O8VTrgmqhyaQSWp45Ia9JGORhDaiAHsApLiOQYt4LDlW7vFQR
+jl5RyreYjR9axCuK5CHT44M6nFrHIpb0spFRtcph4QThYbscl2dP0/xLCGN3wixA
+CbDukF2z26FnBrTZFEk5Rcf3r/8wgwfCoXz0oPD91/y5PA9tSY2z3QbhVDdiR2aj
+klritxj/1i0xTGfm1avH0n/J3V5bauTKnxs3RhL4+V5S33FZjArFfAfOjzQHDah6
+nqz43dAOf83QYreMivxyAnQvU3Cs+J4RKYUsIQzsLpRs/2Wb7nK3W/p+bLdRIl04
+Y+xcX+3aKBluKoVMh7CeQDtr8NslSNO+YfGNmGYfD2f05da1Wi+FWqTrXXY2Y/NB
+3VJDLgMuNgT5nsimrCl6ZfNcBtyDhsCUPN9V8sGZooEnjG0eNIX/OO3mlEI5GXfY
+oFoXsjPX53aYZkOPVZLdXq0IteKGCFZCBhDVOmAqgALlVl66WbO+pMlBB+L7aw/h
+H1NlBmrzfOXlYZi8SbmO0DSqC0ckXZCSdbmjix9aOhpDk/NlUZF29xCfQ5Mwk4gk
+FboJIKDa0kKXQB18UV4ZAoIBAQC/LX97kOa1YibZIYdkyo0BD8jgjXZGV3y0Lc5V
+h5mjOUD2mQ2AE9zcKtfjxEBnFYcC5RFe88vWBuYyLpVdDuZeiAfQHP4bXT+QZRBi
+p51PjMuC+5zd5XlGeU5iwnfJ6TBe0yVfSb7M2N88LEeBaVCRcP7rqyiSYnwVkaHN
+9Ow1PwJ4BiX0wIn62fO6o6CDo8x9KxXK6G+ak5z83AFSV8+ZGjHMEYcLaVfOj8a2
+VFbc2eX1V0ebgJOZVx8eAgjLV6fJahJ1/lT+8y9CzHtS7b3RvU/EsD+7WLMFUxHJ
+cPVL6/iHBsV8heKxFfdORSBtBgllQjzv6rzuJ2rZDqQBZF0TAoIBAQC9MhjeEtNw
+J8jrnsfg5fDJMPCg5nvb6Ck3z2FyDPJInK+b/IPvcrDl/+X+1vHhmGf5ReLZuEPR
+0YEeAWbdMiKJbgRyca5xWRWgP7+sIFmJ9Calvf0FfFzaKQHyLAepBuVp5JMCqqTc
+9Rw+5X5MjRgQxvJRppO/EnrvJ3/ZPJEhvYaSqvFQpYR4U0ghoQSlSxoYwCNuKSga
+EmpItqZ1j6bKCxy/TZbYgM2SDoSzsD6h/hlLLIU6ecIsBPrF7C+rwxasbLLomoCD
+RqjCjsLsgiQU9Qmg01ReRWjXa64r0JKGU0gb+E365WJHqPQgyyhmeYhcXhhUCj+B
+Anze8CYU8xp9AoIBAFOpjYh9uPjXoziSO7YYDezRA4+BWKkf0CrpgMpdNRcBDzTb
+ddT+3EBdX20FjUmPWi4iIJ/1ANcA3exIBoVa5+WmkgS5K1q+S/rcv3bs8yLE8qq3
+gcZ5jcERhQQjJljt+4UD0e8JTr5GiirDFefENsXvNR/dHzwwbSzjNnPzIwuKL4Jm
+7mVVfQySJN8gjDYPkIWWPUs2vOBgiOr/PHTUiLzvgatUYEzWJN74fHV+IyUzFjdv
+op6iffU08yEmssKJ8ZtrF/ka/Ac2VRBee/mmoNMQjb/9gWZzQqSp3bbSAAbhlTlB
+9VqxHKtyeW9/QNl1MtdlTVWQ3G08Qr4KcitJyJECggEAL3lrrgXxUnpZO26bXz6z
+vfhu2SEcwWCvPxblr9W50iinFDA39xTDeONOljTfeylgJbe4pcNMGVFF4f6eDjEv
+Y2bc7M7D5CNjftOgSBPSBADk1cAnxoGfVwrlNxx/S5W0aW72yLuDJQLIdKvnllPt
+TwBs+7od5ts/R9WUijFdhabmJtWIOiFebUcQmYeq/8MpqD5GZbUkH+6xBs/2UxeZ
+1acWLpbMnEUt0FGeUOyPutxlAm0IfVTiOWOCfbm3eJU6kkewWRez2b0YScHC/c/m
+N/AI23dL+1/VYADgMpRiwBwTwxj6kFOQ5sRphfUUjSo/4lWmKyhrKPcz2ElQdP9P
+jQKCAQEAqsAD7r443DklL7oPR/QV0lrjv11EtXcZ0Gff7ZF2FI1V/CxkbYolPrB+
+QPSjwcMtyzxy6tXtUnaH19gx/K/8dBO/vnBw1Go/tvloIXidvVE0wemEC+gpTVtP
+fLVplwBhcyxOMMGJcqbIT62pzSUisyXeb8dGn27BOUqz69u+z+MKdHDMM/loKJbj
+TRw8MB8+t51osJ/tA3SwQCzS4onUMmwqE9eVHspANQeWZVqs+qMtpwW0lvs909Wv
+VZ1o9pRPv2G9m7aK4v/bZO56DOx+9/Rp+mv3S2zl2Pkd6RIuD0UR4v03bRz3ACpf
+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_ai_integration_deactivation.py b/tests/test_ai_integration_deactivation.py
new file mode 100644
index 0000000000..dc8aef6be8
--- /dev/null
+++ b/tests/test_ai_integration_deactivation.py
@@ -0,0 +1,252 @@
+import pytest
+
+from sentry_sdk import get_client
+from sentry_sdk.integrations import _INTEGRATION_DEACTIVATES
+
+
+try:
+    from sentry_sdk.integrations.langchain import LangchainIntegration
+
+    has_langchain = True
+except Exception:
+    has_langchain = False
+
+try:
+    from sentry_sdk.integrations.openai import OpenAIIntegration
+
+    has_openai = True
+except Exception:
+    has_openai = False
+
+try:
+    from sentry_sdk.integrations.anthropic import AnthropicIntegration
+
+    has_anthropic = True
+except Exception:
+    has_anthropic = False
+
+
+try:
+    from sentry_sdk.integrations.openai_agents import OpenAIAgentsIntegration
+
+    has_openai_agents = True
+except Exception:
+    has_openai_agents = False
+
+try:
+    from sentry_sdk.integrations.pydantic_ai import PydanticAIIntegration
+
+    has_pydantic_ai = True
+except Exception:
+    has_pydantic_ai = False
+
+
+pytestmark = pytest.mark.skipif(
+    not (
+        has_langchain
+        and has_openai
+        and has_anthropic
+        and has_openai_agents
+        and has_pydantic_ai
+    ),
+    reason="Requires langchain, openai, and anthropic packages to be installed",
+)
+
+
+def test_integration_deactivates_map_exists():
+    assert "langchain" in _INTEGRATION_DEACTIVATES
+    assert "openai" in _INTEGRATION_DEACTIVATES["langchain"]
+    assert "anthropic" in _INTEGRATION_DEACTIVATES["langchain"]
+    assert "openai_agents" in _INTEGRATION_DEACTIVATES
+    assert "openai" in _INTEGRATION_DEACTIVATES["openai_agents"]
+    assert "pydantic_ai" in _INTEGRATION_DEACTIVATES
+    assert "openai" in _INTEGRATION_DEACTIVATES["pydantic_ai"]
+    assert "anthropic" in _INTEGRATION_DEACTIVATES["pydantic_ai"]
+
+
+def test_langchain_auto_deactivates_openai_and_anthropic(
+    sentry_init, reset_integrations
+):
+    sentry_init(
+        default_integrations=False,
+        auto_enabling_integrations=True,
+    )
+
+    client = get_client()
+    integration_types = {
+        type(integration) for integration in client.integrations.values()
+    }
+
+    if LangchainIntegration in integration_types:
+        assert OpenAIIntegration not in integration_types
+        assert AnthropicIntegration not in integration_types
+
+
+def test_user_can_override_with_explicit_openai(sentry_init, reset_integrations):
+    sentry_init(
+        default_integrations=False,
+        auto_enabling_integrations=True,
+        integrations=[OpenAIIntegration()],
+    )
+
+    client = get_client()
+    integration_types = {
+        type(integration) for integration in client.integrations.values()
+    }
+
+    assert OpenAIIntegration in integration_types
+
+
+def test_user_can_override_with_explicit_anthropic(sentry_init, reset_integrations):
+    sentry_init(
+        default_integrations=False,
+        auto_enabling_integrations=True,
+        integrations=[AnthropicIntegration()],
+    )
+
+    client = get_client()
+    integration_types = {
+        type(integration) for integration in client.integrations.values()
+    }
+
+    assert AnthropicIntegration in integration_types
+
+
+def test_user_can_override_with_both_explicit_integrations(
+    sentry_init, reset_integrations
+):
+    sentry_init(
+        default_integrations=False,
+        auto_enabling_integrations=True,
+        integrations=[OpenAIIntegration(), AnthropicIntegration()],
+    )
+
+    client = get_client()
+    integration_types = {
+        type(integration) for integration in client.integrations.values()
+    }
+
+    assert OpenAIIntegration in integration_types
+    assert AnthropicIntegration in integration_types
+
+
+def test_disabling_integrations_allows_openai_and_anthropic(
+    sentry_init, reset_integrations
+):
+    sentry_init(
+        default_integrations=False,
+        auto_enabling_integrations=True,
+        disabled_integrations=[
+            LangchainIntegration,
+            OpenAIAgentsIntegration,
+            PydanticAIIntegration,
+        ],
+    )
+
+    client = get_client()
+    integration_types = {
+        type(integration) for integration in client.integrations.values()
+    }
+
+    assert LangchainIntegration not in integration_types
+
+
+def test_explicit_langchain_still_deactivates_others(sentry_init, reset_integrations):
+    sentry_init(
+        default_integrations=False,
+        auto_enabling_integrations=False,
+        integrations=[LangchainIntegration()],
+    )
+
+    client = get_client()
+    integration_types = {
+        type(integration) for integration in client.integrations.values()
+    }
+
+    if LangchainIntegration in integration_types:
+        assert OpenAIIntegration not in integration_types
+        assert AnthropicIntegration not in integration_types
+
+
+def test_langchain_and_openai_both_explicit_both_active(
+    sentry_init, reset_integrations
+):
+    sentry_init(
+        default_integrations=False,
+        auto_enabling_integrations=False,
+        integrations=[LangchainIntegration(), OpenAIIntegration()],
+    )
+
+    client = get_client()
+    integration_types = {
+        type(integration) for integration in client.integrations.values()
+    }
+
+    assert LangchainIntegration in integration_types
+    assert OpenAIIntegration in integration_types
+
+
+def test_no_langchain_means_openai_and_anthropic_can_auto_enable(
+    sentry_init, reset_integrations, monkeypatch
+):
+    import sys
+    import sentry_sdk.integrations
+
+    old_iter = sentry_sdk.integrations.iter_default_integrations
+
+    def filtered_iter(with_auto_enabling):
+        for cls in old_iter(with_auto_enabling):
+            if cls.identifier != "langchain":
+                yield cls
+
+    monkeypatch.setattr(
+        sentry_sdk.integrations, "iter_default_integrations", filtered_iter
+    )
+
+    sentry_init(
+        default_integrations=False,
+        auto_enabling_integrations=True,
+    )
+
+    client = get_client()
+    integration_types = {
+        type(integration) for integration in client.integrations.values()
+    }
+
+    assert LangchainIntegration not in integration_types
+
+
+def test_deactivation_with_default_integrations_enabled(
+    sentry_init, reset_integrations
+):
+    sentry_init(
+        default_integrations=True,
+        auto_enabling_integrations=True,
+    )
+
+    client = get_client()
+    integration_types = {
+        type(integration) for integration in client.integrations.values()
+    }
+
+    if LangchainIntegration in integration_types:
+        assert OpenAIIntegration not in integration_types
+        assert AnthropicIntegration not in integration_types
+
+
+def test_only_auto_enabling_integrations_without_defaults(
+    sentry_init, reset_integrations
+):
+    sentry_init(
+        default_integrations=False,
+        auto_enabling_integrations=True,
+    )
+
+    client = get_client()
+    integration_types = {
+        type(integration) for integration in client.integrations.values()
+    }
+
+    if LangchainIntegration in integration_types:
+        assert OpenAIIntegration not in integration_types
+        assert AnthropicIntegration not in integration_types
diff --git a/tests/test_ai_monitoring.py b/tests/test_ai_monitoring.py
new file mode 100644
index 0000000000..8d3d4ba204
--- /dev/null
+++ b/tests/test_ai_monitoring.py
@@ -0,0 +1,544 @@
+import json
+import uuid
+
+import pytest
+
+import sentry_sdk
+from sentry_sdk._types import AnnotatedValue
+from sentry_sdk.ai.monitoring import ai_track
+from sentry_sdk.ai.utils import (
+    MAX_GEN_AI_MESSAGE_BYTES,
+    MAX_SINGLE_MESSAGE_CONTENT_CHARS,
+    set_data_normalized,
+    truncate_and_annotate_messages,
+    truncate_messages_by_size,
+    _find_truncation_index,
+)
+from sentry_sdk.serializer import serialize
+from sentry_sdk.utils import safe_serialize
+
+
+def test_ai_track(sentry_init, capture_events):
+    sentry_init(traces_sample_rate=1.0)
+    events = capture_events()
+
+    @ai_track("my tool")
+    def tool(**kwargs):
+        pass
+
+    @ai_track("some test pipeline")
+    def pipeline():
+        tool()
+
+    with sentry_sdk.start_transaction():
+        pipeline()
+
+    transaction = events[0]
+    assert transaction["type"] == "transaction"
+    assert len(transaction["spans"]) == 2
+    spans = transaction["spans"]
+
+    ai_pipeline_span = spans[0] if spans[0]["op"] == "ai.pipeline" else spans[1]
+    ai_run_span = spans[0] if spans[0]["op"] == "ai.run" else spans[1]
+
+    assert ai_pipeline_span["description"] == "some test pipeline"
+    assert ai_run_span["description"] == "my tool"
+
+
+def test_ai_track_with_tags(sentry_init, capture_events):
+    sentry_init(traces_sample_rate=1.0)
+    events = capture_events()
+
+    @ai_track("my tool")
+    def tool(**kwargs):
+        pass
+
+    @ai_track("some test pipeline")
+    def pipeline():
+        tool()
+
+    with sentry_sdk.start_transaction():
+        pipeline(sentry_tags={"user": "colin"}, sentry_data={"some_data": "value"})
+
+    transaction = events[0]
+    assert transaction["type"] == "transaction"
+    assert len(transaction["spans"]) == 2
+    spans = transaction["spans"]
+
+    ai_pipeline_span = spans[0] if spans[0]["op"] == "ai.pipeline" else spans[1]
+    ai_run_span = spans[0] if spans[0]["op"] == "ai.run" else spans[1]
+
+    assert ai_pipeline_span["description"] == "some test pipeline"
+    print(ai_pipeline_span)
+    assert ai_pipeline_span["tags"]["user"] == "colin"
+    assert ai_pipeline_span["data"]["some_data"] == "value"
+    assert ai_run_span["description"] == "my tool"
+
+
+@pytest.mark.asyncio
+async def test_ai_track_async(sentry_init, capture_events):
+    sentry_init(traces_sample_rate=1.0)
+    events = capture_events()
+
+    @ai_track("my async tool")
+    async def async_tool(**kwargs):
+        pass
+
+    @ai_track("some async test pipeline")
+    async def async_pipeline():
+        await async_tool()
+
+    with sentry_sdk.start_transaction():
+        await async_pipeline()
+
+    transaction = events[0]
+    assert transaction["type"] == "transaction"
+    assert len(transaction["spans"]) == 2
+    spans = transaction["spans"]
+
+    ai_pipeline_span = spans[0] if spans[0]["op"] == "ai.pipeline" else spans[1]
+    ai_run_span = spans[0] if spans[0]["op"] == "ai.run" else spans[1]
+
+    assert ai_pipeline_span["description"] == "some async test pipeline"
+    assert ai_run_span["description"] == "my async tool"
+
+
+@pytest.mark.asyncio
+async def test_ai_track_async_with_tags(sentry_init, capture_events):
+    sentry_init(traces_sample_rate=1.0)
+    events = capture_events()
+
+    @ai_track("my async tool")
+    async def async_tool(**kwargs):
+        pass
+
+    @ai_track("some async test pipeline")
+    async def async_pipeline():
+        await async_tool()
+
+    with sentry_sdk.start_transaction():
+        await async_pipeline(
+            sentry_tags={"user": "czyber"}, sentry_data={"some_data": "value"}
+        )
+
+    transaction = events[0]
+    assert transaction["type"] == "transaction"
+    assert len(transaction["spans"]) == 2
+    spans = transaction["spans"]
+
+    ai_pipeline_span = spans[0] if spans[0]["op"] == "ai.pipeline" else spans[1]
+    ai_run_span = spans[0] if spans[0]["op"] == "ai.run" else spans[1]
+
+    assert ai_pipeline_span["description"] == "some async test pipeline"
+    assert ai_pipeline_span["tags"]["user"] == "czyber"
+    assert ai_pipeline_span["data"]["some_data"] == "value"
+    assert ai_run_span["description"] == "my async tool"
+
+
+def test_ai_track_with_explicit_op(sentry_init, capture_events):
+    sentry_init(traces_sample_rate=1.0)
+    events = capture_events()
+
+    @ai_track("my tool", op="custom.operation")
+    def tool(**kwargs):
+        pass
+
+    with sentry_sdk.start_transaction():
+        tool()
+
+    transaction = events[0]
+    assert transaction["type"] == "transaction"
+    assert len(transaction["spans"]) == 1
+    span = transaction["spans"][0]
+
+    assert span["description"] == "my tool"
+    assert span["op"] == "custom.operation"
+
+
+@pytest.mark.asyncio
+async def test_ai_track_async_with_explicit_op(sentry_init, capture_events):
+    sentry_init(traces_sample_rate=1.0)
+    events = capture_events()
+
+    @ai_track("my async tool", op="custom.async.operation")
+    async def async_tool(**kwargs):
+        pass
+
+    with sentry_sdk.start_transaction():
+        await async_tool()
+
+    transaction = events[0]
+    assert transaction["type"] == "transaction"
+    assert len(transaction["spans"]) == 1
+    span = transaction["spans"][0]
+
+    assert span["description"] == "my async tool"
+    assert span["op"] == "custom.async.operation"
+
+
+@pytest.fixture
+def sample_messages():
+    """Sample messages similar to what gen_ai integrations would use"""
+    return [
+        {"role": "system", "content": "You are a helpful assistant."},
+        {
+            "role": "user",
+            "content": "What is the difference between a list and a tuple in Python?",
+        },
+        {
+            "role": "assistant",
+            "content": "Lists are mutable and use [], tuples are immutable and use ().",
+        },
+        {"role": "user", "content": "Can you give me some examples?"},
+        {
+            "role": "assistant",
+            "content": "Sure! Here are examples:\n\n```python\n# List\nmy_list = [1, 2, 3]\nmy_list.append(4)\n\n# Tuple\nmy_tuple = (1, 2, 3)\n# my_tuple.append(4) would error\n```",
+        },
+    ]
+
+
+@pytest.fixture
+def large_messages():
+    """Messages that will definitely exceed size limits"""
+    large_content = "This is a very long message. " * 100
+    return [
+        {"role": "system", "content": large_content},
+        {"role": "user", "content": large_content},
+        {"role": "assistant", "content": large_content},
+        {"role": "user", "content": large_content},
+    ]
+
+
+class TestTruncateMessagesBySize:
+    def test_no_truncation_needed(self, sample_messages):
+        """Test that messages under the limit are not truncated"""
+        result, truncation_index = truncate_messages_by_size(
+            sample_messages, max_bytes=MAX_GEN_AI_MESSAGE_BYTES
+        )
+        assert len(result) == len(sample_messages)
+        assert result == sample_messages
+        assert truncation_index == 0
+
+    def test_truncation_removes_oldest_first(self, large_messages):
+        """Test that oldest messages are removed first during truncation"""
+        small_limit = 3000
+        result, truncation_index = truncate_messages_by_size(
+            large_messages, max_bytes=small_limit
+        )
+        assert len(result) < len(large_messages)
+
+        assert result[-1] == large_messages[-1]
+        assert truncation_index == len(large_messages) - len(result)
+
+    def test_empty_messages_list(self):
+        """Test handling of empty messages list"""
+        result, truncation_index = truncate_messages_by_size(
+            [], max_bytes=MAX_GEN_AI_MESSAGE_BYTES // 500
+        )
+        assert result == []
+        assert truncation_index == 0
+
+    def test_find_truncation_index(
+        self,
+    ):
+        """Test that the truncation index is found correctly"""
+        # when represented in JSON, these are each 7 bytes long
+        messages = ["A" * 5, "B" * 5, "C" * 5, "D" * 5, "E" * 5]
+        truncation_index = _find_truncation_index(messages, 20)
+        assert truncation_index == 3
+        assert messages[truncation_index:] == ["D" * 5, "E" * 5]
+
+        messages = ["A" * 5, "B" * 5, "C" * 5, "D" * 5, "E" * 5]
+        truncation_index = _find_truncation_index(messages, 40)
+        assert truncation_index == 0
+        assert messages[truncation_index:] == [
+            "A" * 5,
+            "B" * 5,
+            "C" * 5,
+            "D" * 5,
+            "E" * 5,
+        ]
+
+    def test_progressive_truncation(self, large_messages):
+        """Test that truncation works progressively with different limits"""
+        limits = [
+            MAX_GEN_AI_MESSAGE_BYTES // 5,
+            MAX_GEN_AI_MESSAGE_BYTES // 10,
+            MAX_GEN_AI_MESSAGE_BYTES // 25,
+            MAX_GEN_AI_MESSAGE_BYTES // 100,
+            MAX_GEN_AI_MESSAGE_BYTES // 500,
+        ]
+        prev_count = len(large_messages)
+
+        for limit in limits:
+            result = truncate_messages_by_size(large_messages, max_bytes=limit)
+            current_count = len(result)
+
+            assert current_count <= prev_count
+            assert current_count >= 1
+            prev_count = current_count
+
+    def test_single_message_truncation(self):
+        large_content = "This is a very long message. " * 10_000
+
+        messages = [
+            {"role": "system", "content": "You are a helpful assistant."},
+            {"role": "user", "content": large_content},
+        ]
+
+        result, truncation_index = truncate_messages_by_size(
+            messages, max_single_message_chars=MAX_SINGLE_MESSAGE_CONTENT_CHARS
+        )
+
+        assert len(result) == 1
+        assert (
+            len(result[0]["content"].rstrip("...")) <= MAX_SINGLE_MESSAGE_CONTENT_CHARS
+        )
+
+        # If the last message is too large, the system message is not present
+        system_msgs = [m for m in result if m.get("role") == "system"]
+        assert len(system_msgs) == 0
+
+        # Confirm the user message is truncated with '...'
+        user_msgs = [m for m in result if m.get("role") == "user"]
+        assert len(user_msgs) == 1
+        assert user_msgs[0]["content"].endswith("...")
+        assert len(user_msgs[0]["content"]) < len(large_content)
+
+
+class TestTruncateAndAnnotateMessages:
+    def test_no_truncation_returns_list(self, sample_messages):
+        class MockSpan:
+            def __init__(self):
+                self.span_id = "test_span_id"
+                self.data = {}
+
+            def set_data(self, key, value):
+                self.data[key] = value
+
+        class MockScope:
+            def __init__(self):
+                self._gen_ai_original_message_count = {}
+
+        span = MockSpan()
+        scope = MockScope()
+        result = truncate_and_annotate_messages(sample_messages, span, scope)
+
+        assert isinstance(result, list)
+        assert not isinstance(result, AnnotatedValue)
+        assert len(result) == len(sample_messages)
+        assert result == sample_messages
+        assert span.span_id not in scope._gen_ai_original_message_count
+
+    def test_truncation_sets_metadata_on_scope(self, large_messages):
+        class MockSpan:
+            def __init__(self):
+                self.span_id = "test_span_id"
+                self.data = {}
+
+            def set_data(self, key, value):
+                self.data[key] = value
+
+        class MockScope:
+            def __init__(self):
+                self._gen_ai_original_message_count = {}
+
+        small_limit = 3000
+        span = MockSpan()
+        scope = MockScope()
+        original_count = len(large_messages)
+        result = truncate_and_annotate_messages(
+            large_messages, span, scope, max_bytes=small_limit
+        )
+
+        assert isinstance(result, list)
+        assert not isinstance(result, AnnotatedValue)
+        assert len(result) < len(large_messages)
+        assert scope._gen_ai_original_message_count[span.span_id] == original_count
+
+    def test_scope_tracks_original_message_count(self, large_messages):
+        class MockSpan:
+            def __init__(self):
+                self.span_id = "test_span_id"
+                self.data = {}
+
+            def set_data(self, key, value):
+                self.data[key] = value
+
+        class MockScope:
+            def __init__(self):
+                self._gen_ai_original_message_count = {}
+
+        small_limit = 3000
+        original_count = len(large_messages)
+        span = MockSpan()
+        scope = MockScope()
+
+        result = truncate_and_annotate_messages(
+            large_messages, span, scope, max_bytes=small_limit
+        )
+
+        assert scope._gen_ai_original_message_count[span.span_id] == original_count
+        assert len(result) == 1
+
+    def test_empty_messages_returns_none(self):
+        class MockSpan:
+            def __init__(self):
+                self.span_id = "test_span_id"
+                self.data = {}
+
+            def set_data(self, key, value):
+                self.data[key] = value
+
+        class MockScope:
+            def __init__(self):
+                self._gen_ai_original_message_count = {}
+
+        span = MockSpan()
+        scope = MockScope()
+        result = truncate_and_annotate_messages([], span, scope)
+        assert result is None
+
+        result = truncate_and_annotate_messages(None, span, scope)
+        assert result is None
+
+    def test_truncated_messages_newest_first(self, large_messages):
+        class MockSpan:
+            def __init__(self):
+                self.span_id = "test_span_id"
+                self.data = {}
+
+            def set_data(self, key, value):
+                self.data[key] = value
+
+        class MockScope:
+            def __init__(self):
+                self._gen_ai_original_message_count = {}
+
+        small_limit = 3000
+        span = MockSpan()
+        scope = MockScope()
+        result = truncate_and_annotate_messages(
+            large_messages, span, scope, max_bytes=small_limit
+        )
+
+        assert isinstance(result, list)
+        assert result[0] == large_messages[-len(result)]
+
+
+class TestClientAnnotation:
+    def test_client_wraps_truncated_messages_in_annotated_value(self, large_messages):
+        """Test that client.py properly wraps truncated messages in AnnotatedValue using scope data"""
+        from sentry_sdk._types import AnnotatedValue
+        from sentry_sdk.consts import SPANDATA
+
+        class MockSpan:
+            def __init__(self):
+                self.span_id = "test_span_123"
+                self.data = {}
+
+            def set_data(self, key, value):
+                self.data[key] = value
+
+        class MockScope:
+            def __init__(self):
+                self._gen_ai_original_message_count = {}
+
+        small_limit = 3000
+        span = MockSpan()
+        scope = MockScope()
+        original_count = len(large_messages)
+
+        # Simulate what integrations do
+        truncated_messages = truncate_and_annotate_messages(
+            large_messages, span, scope, max_bytes=small_limit
+        )
+        span.set_data(SPANDATA.GEN_AI_REQUEST_MESSAGES, truncated_messages)
+
+        # Verify metadata was set on scope
+        assert span.span_id in scope._gen_ai_original_message_count
+        assert scope._gen_ai_original_message_count[span.span_id] > 0
+
+        # Simulate what client.py does
+        event = {"spans": [{"span_id": span.span_id, "data": span.data.copy()}]}
+
+        # Mimic client.py logic - using scope to get the original length
+        for event_span in event["spans"]:
+            span_id = event_span.get("span_id")
+            span_data = event_span.get("data", {})
+            if (
+                span_id
+                and span_id in scope._gen_ai_original_message_count
+                and SPANDATA.GEN_AI_REQUEST_MESSAGES in span_data
+            ):
+                messages = span_data[SPANDATA.GEN_AI_REQUEST_MESSAGES]
+                n_original_count = scope._gen_ai_original_message_count[span_id]
+
+                span_data[SPANDATA.GEN_AI_REQUEST_MESSAGES] = AnnotatedValue(
+                    safe_serialize(messages),
+                    {"len": n_original_count},
+                )
+
+        # Verify the annotation happened
+        messages_value = event["spans"][0]["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES]
+        assert isinstance(messages_value, AnnotatedValue)
+        assert messages_value.metadata["len"] == original_count
+        assert isinstance(messages_value.value, str)
+
+    def test_annotated_value_shows_correct_original_length(self, large_messages):
+        """Test that the annotated value correctly shows the original message count before truncation"""
+        from sentry_sdk.consts import SPANDATA
+
+        class MockSpan:
+            def __init__(self):
+                self.span_id = "test_span_456"
+                self.data = {}
+
+            def set_data(self, key, value):
+                self.data[key] = value
+
+        class MockScope:
+            def __init__(self):
+                self._gen_ai_original_message_count = {}
+
+        small_limit = 3000
+        span = MockSpan()
+        scope = MockScope()
+        original_message_count = len(large_messages)
+
+        truncated_messages = truncate_and_annotate_messages(
+            large_messages, span, scope, max_bytes=small_limit
+        )
+
+        assert len(truncated_messages) < original_message_count
+
+        assert span.span_id in scope._gen_ai_original_message_count
+        stored_original_length = scope._gen_ai_original_message_count[span.span_id]
+        assert stored_original_length == original_message_count
+
+        event = {
+            "spans": [
+                {
+                    "span_id": span.span_id,
+                    "data": {SPANDATA.GEN_AI_REQUEST_MESSAGES: truncated_messages},
+                }
+            ]
+        }
+
+        for event_span in event["spans"]:
+            span_id = event_span.get("span_id")
+            span_data = event_span.get("data", {})
+            if (
+                span_id
+                and span_id in scope._gen_ai_original_message_count
+                and SPANDATA.GEN_AI_REQUEST_MESSAGES in span_data
+            ):
+                span_data[SPANDATA.GEN_AI_REQUEST_MESSAGES] = AnnotatedValue(
+                    span_data[SPANDATA.GEN_AI_REQUEST_MESSAGES],
+                    {"len": scope._gen_ai_original_message_count[span_id]},
+                )
+
+        messages_value = event["spans"][0]["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES]
+        assert isinstance(messages_value, AnnotatedValue)
+        assert messages_value.metadata["len"] == stored_original_length
+        assert len(messages_value.value) == len(truncated_messages)
diff --git a/tests/test_api.py b/tests/test_api.py
index 1adb9095f0..acc33cdf4c 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -1,28 +1,36 @@
+import pytest
+
+import re
+from unittest import mock
+
+import sentry_sdk
 from sentry_sdk import (
-    configure_scope,
+    capture_exception,
     continue_trace,
     get_baggage,
+    get_client,
     get_current_span,
     get_traceparent,
+    is_initialized,
     start_transaction,
+    set_tags,
+    configure_scope,
+    push_scope,
+    get_global_scope,
+    get_current_scope,
+    get_isolation_scope,
 )
-from sentry_sdk.hub import Hub
 
-try:
-    from unittest import mock  # python 3.3 and above
-except ImportError:
-    import mock  # python < 3.3
+from sentry_sdk.client import Client, NonRecordingClient
 
 
 def test_get_current_span():
-    fake_hub = mock.MagicMock()
-    fake_hub.scope = mock.MagicMock()
+    fake_scope = mock.MagicMock()
+    fake_scope.span = mock.MagicMock()
+    assert get_current_span(fake_scope) == fake_scope.span
 
-    fake_hub.scope.span = mock.MagicMock()
-    assert get_current_span(fake_hub) == fake_hub.scope.span
-
-    fake_hub.scope.span = None
-    assert get_current_span(fake_hub) is None
+    fake_scope.span = None
+    assert get_current_span(fake_scope) is None
 
 
 def test_get_current_span_default_hub(sentry_init):
@@ -30,11 +38,11 @@ def test_get_current_span_default_hub(sentry_init):
 
     assert get_current_span() is None
 
-    with configure_scope() as scope:
-        fake_span = mock.MagicMock()
-        scope.span = fake_span
+    scope = get_current_scope()
+    fake_span = mock.MagicMock()
+    scope.span = fake_span
 
-        assert get_current_span() == fake_span
+    assert get_current_span() == fake_span
 
 
 def test_get_current_span_default_hub_with_transaction(sentry_init):
@@ -60,34 +68,32 @@ def test_traceparent_with_tracing_enabled(sentry_init):
 def test_traceparent_with_tracing_disabled(sentry_init):
     sentry_init()
 
-    propagation_context = Hub.current.scope._propagation_context
+    propagation_context = get_isolation_scope()._propagation_context
     expected_traceparent = "%s-%s" % (
-        propagation_context["trace_id"],
-        propagation_context["span_id"],
+        propagation_context.trace_id,
+        propagation_context.span_id,
     )
     assert get_traceparent() == expected_traceparent
 
 
 def test_baggage_with_tracing_disabled(sentry_init):
     sentry_init(release="1.0.0", environment="dev")
-    propagation_context = Hub.current.scope._propagation_context
+    propagation_context = get_isolation_scope()._propagation_context
     expected_baggage = (
         "sentry-trace_id={},sentry-environment=dev,sentry-release=1.0.0".format(
-            propagation_context["trace_id"]
+            propagation_context.trace_id
         )
     )
-    # order not guaranteed in older python versions
-    assert sorted(get_baggage().split(",")) == sorted(expected_baggage.split(","))
+    assert get_baggage() == expected_baggage
 
 
 def test_baggage_with_tracing_enabled(sentry_init):
     sentry_init(traces_sample_rate=1.0, release="1.0.0", environment="dev")
     with start_transaction() as transaction:
-        expected_baggage = "sentry-trace_id={},sentry-environment=dev,sentry-release=1.0.0,sentry-sample_rate=1.0,sentry-sampled={}".format(
+        expected_baggage_re = r"^sentry-trace_id={},sentry-sample_rand=0\.\d{{6}},sentry-environment=dev,sentry-release=1\.0\.0,sentry-sample_rate=1\.0,sentry-sampled={}$".format(
             transaction.trace_id, "true" if transaction.sampled else "false"
         )
-        # order not guaranteed in older python versions
-        assert sorted(get_baggage().split(",")) == sorted(expected_baggage.split(","))
+        assert re.match(expected_baggage_re, get_baggage())
 
 
 def test_continue_trace(sentry_init):
@@ -99,17 +105,103 @@ def test_continue_trace(sentry_init):
     transaction = continue_trace(
         {
             "sentry-trace": "{}-{}-{}".format(trace_id, parent_span_id, parent_sampled),
-            "baggage": "sentry-trace_id=566e3688a61d4bc888951642d6f14a19",
+            "baggage": "sentry-trace_id=566e3688a61d4bc888951642d6f14a19,sentry-sample_rand=0.123456",
         },
         name="some name",
     )
     with start_transaction(transaction):
         assert transaction.name == "some name"
 
-        propagation_context = Hub.current.scope._propagation_context
-        assert propagation_context["trace_id"] == transaction.trace_id == trace_id
-        assert propagation_context["parent_span_id"] == parent_span_id
-        assert propagation_context["parent_sampled"] == parent_sampled
-        assert propagation_context["dynamic_sampling_context"] == {
-            "trace_id": "566e3688a61d4bc888951642d6f14a19"
+        propagation_context = get_isolation_scope()._propagation_context
+        assert propagation_context.trace_id == transaction.trace_id == trace_id
+        assert propagation_context.parent_span_id == parent_span_id
+        assert propagation_context.parent_sampled == parent_sampled
+        assert propagation_context.dynamic_sampling_context == {
+            "trace_id": "566e3688a61d4bc888951642d6f14a19",
+            "sample_rand": "0.123456",
         }
+
+
+def test_is_initialized():
+    assert not is_initialized()
+
+    scope = get_global_scope()
+    scope.set_client(Client())
+    assert is_initialized()
+
+
+def test_get_client():
+    client = get_client()
+    assert client is not None
+    assert client.__class__ == NonRecordingClient
+    assert not client.is_active()
+
+
+def raise_and_capture():
+    """Raise an exception and capture it.
+
+    This is a utility function for test_set_tags.
+    """
+    try:
+        1 / 0
+    except ZeroDivisionError:
+        capture_exception()
+
+
+def test_set_tags(sentry_init, capture_events):
+    sentry_init()
+    events = capture_events()
+
+    set_tags({"tag1": "value1", "tag2": "value2"})
+    raise_and_capture()
+
+    (*_, event) = events
+    assert event["tags"] == {"tag1": "value1", "tag2": "value2"}, "Setting tags failed"
+
+    set_tags({"tag2": "updated", "tag3": "new"})
+    raise_and_capture()
+
+    (*_, event) = events
+    assert event["tags"] == {
+        "tag1": "value1",
+        "tag2": "updated",
+        "tag3": "new",
+    }, "Updating tags failed"
+
+    set_tags({})
+    raise_and_capture()
+
+    (*_, event) = events
+    assert event["tags"] == {
+        "tag1": "value1",
+        "tag2": "updated",
+        "tag3": "new",
+    }, "Updating tags with empty dict changed tags"
+
+
+def test_configure_scope_deprecation():
+    with pytest.warns(DeprecationWarning):
+        with configure_scope():
+            ...
+
+
+def test_push_scope_deprecation():
+    with pytest.warns(DeprecationWarning):
+        with push_scope():
+            ...
+
+
+def test_init_context_manager_deprecation():
+    with pytest.warns(DeprecationWarning):
+        with sentry_sdk.init():
+            ...
+
+
+def test_init_enter_deprecation():
+    with pytest.warns(DeprecationWarning):
+        sentry_sdk.init().__enter__()
+
+
+def test_init_exit_deprecation():
+    with pytest.warns(DeprecationWarning):
+        sentry_sdk.init().__exit__(None, None, None)
diff --git a/tests/test_attributes.py b/tests/test_attributes.py
new file mode 100644
index 0000000000..40b31fa7f1
--- /dev/null
+++ b/tests/test_attributes.py
@@ -0,0 +1,157 @@
+import sentry_sdk
+
+from tests.test_metrics import envelopes_to_metrics
+
+
+def test_scope_precedence(sentry_init, capture_envelopes):
+    # Order of precedence, from most important to least:
+    # 1. telemetry attributes (directly supplying attributes on creation or using set_attribute)
+    # 2. current scope attributes
+    # 3. isolation scope attributes
+    # 4. global scope attributes
+    sentry_init()
+
+    envelopes = capture_envelopes()
+
+    global_scope = sentry_sdk.get_global_scope()
+    global_scope.set_attribute("global.attribute", "global")
+    global_scope.set_attribute("overwritten.attribute", "global")
+
+    isolation_scope = sentry_sdk.get_isolation_scope()
+    isolation_scope.set_attribute("isolation.attribute", "isolation")
+    isolation_scope.set_attribute("overwritten.attribute", "isolation")
+
+    current_scope = sentry_sdk.get_current_scope()
+    current_scope.set_attribute("current.attribute", "current")
+    current_scope.set_attribute("overwritten.attribute", "current")
+
+    sentry_sdk.metrics.count("test", 1)
+    sentry_sdk.get_client().flush()
+
+    metrics = envelopes_to_metrics(envelopes)
+    (metric,) = metrics
+
+    assert metric["attributes"]["global.attribute"] == "global"
+    assert metric["attributes"]["isolation.attribute"] == "isolation"
+    assert metric["attributes"]["current.attribute"] == "current"
+
+    assert metric["attributes"]["overwritten.attribute"] == "current"
+
+
+def test_telemetry_precedence(sentry_init, capture_envelopes):
+    # Order of precedence, from most important to least:
+    # 1. telemetry attributes (directly supplying attributes on creation or using set_attribute)
+    # 2. current scope attributes
+    # 3. isolation scope attributes
+    # 4. global scope attributes
+    sentry_init()
+
+    envelopes = capture_envelopes()
+
+    global_scope = sentry_sdk.get_global_scope()
+    global_scope.set_attribute("global.attribute", "global")
+    global_scope.set_attribute("overwritten.attribute", "global")
+
+    isolation_scope = sentry_sdk.get_isolation_scope()
+    isolation_scope.set_attribute("isolation.attribute", "isolation")
+    isolation_scope.set_attribute("overwritten.attribute", "isolation")
+
+    current_scope = sentry_sdk.get_current_scope()
+    current_scope.set_attribute("current.attribute", "current")
+    current_scope.set_attribute("overwritten.attribute", "current")
+
+    sentry_sdk.metrics.count(
+        "test",
+        1,
+        attributes={
+            "telemetry.attribute": "telemetry",
+            "overwritten.attribute": "telemetry",
+        },
+    )
+
+    sentry_sdk.get_client().flush()
+
+    metrics = envelopes_to_metrics(envelopes)
+    (metric,) = metrics
+
+    assert metric["attributes"]["global.attribute"] == "global"
+    assert metric["attributes"]["isolation.attribute"] == "isolation"
+    assert metric["attributes"]["current.attribute"] == "current"
+    assert metric["attributes"]["telemetry.attribute"] == "telemetry"
+
+    assert metric["attributes"]["overwritten.attribute"] == "telemetry"
+
+
+def test_attribute_out_of_scope(sentry_init, capture_envelopes):
+    sentry_init()
+
+    envelopes = capture_envelopes()
+
+    with sentry_sdk.new_scope() as scope:
+        scope.set_attribute("outofscope.attribute", "out of scope")
+
+    sentry_sdk.metrics.count("test", 1)
+
+    sentry_sdk.get_client().flush()
+
+    metrics = envelopes_to_metrics(envelopes)
+    (metric,) = metrics
+
+    assert "outofscope.attribute" not in metric["attributes"]
+
+
+def test_remove_attribute(sentry_init, capture_envelopes):
+    sentry_init()
+
+    envelopes = capture_envelopes()
+
+    with sentry_sdk.new_scope() as scope:
+        scope.set_attribute("some.attribute", 123)
+        scope.remove_attribute("some.attribute")
+
+        sentry_sdk.metrics.count("test", 1)
+
+    sentry_sdk.get_client().flush()
+
+    metrics = envelopes_to_metrics(envelopes)
+    (metric,) = metrics
+
+    assert "some.attribute" not in metric["attributes"]
+
+
+def test_scope_attributes_preserialized(sentry_init, capture_envelopes):
+    def before_send_metric(metric, _):
+        # Scope attrs show up serialized in before_send
+        assert isinstance(metric["attributes"]["instance"], str)
+        assert isinstance(metric["attributes"]["dictionary"], str)
+
+        return metric
+
+    sentry_init(before_send_metric=before_send_metric)
+
+    envelopes = capture_envelopes()
+
+    class Cat:
+        pass
+
+    instance = Cat()
+    dictionary = {"color": "tortoiseshell"}
+
+    with sentry_sdk.new_scope() as scope:
+        scope.set_attribute("instance", instance)
+        scope.set_attribute("dictionary", dictionary)
+
+        # Scope attrs are stored preserialized
+        assert isinstance(scope._attributes["instance"], str)
+        assert isinstance(scope._attributes["dictionary"], str)
+
+        sentry_sdk.metrics.count("test", 1)
+
+    sentry_sdk.get_client().flush()
+
+    metrics = envelopes_to_metrics(envelopes)
+    (metric,) = metrics
+
+    # Attrs originally from the scope are serialized when sent
+    assert isinstance(metric["attributes"]["instance"], str)
+    assert isinstance(metric["attributes"]["dictionary"], str)
diff --git a/tests/test_basics.py b/tests/test_basics.py
index b2b8846eb9..da836462d8 100644
--- a/tests/test_basics.py
+++ b/tests/test_basics.py
@@ -1,44 +1,71 @@
+import datetime
+import importlib
 import logging
 import os
 import sys
 import time
+from collections import Counter
 
 import pytest
+from sentry_sdk.client import Client
+from sentry_sdk.utils import datetime_from_isoformat
 
+import sentry_sdk
+import sentry_sdk.scope
 from sentry_sdk import (
-    Client,
+    get_client,
     push_scope,
-    configure_scope,
     capture_event,
     capture_exception,
     capture_message,
     start_transaction,
-    add_breadcrumb,
     last_event_id,
+    add_breadcrumb,
+    isolation_scope,
+    new_scope,
     Hub,
 )
-from sentry_sdk._compat import reraise
-from sentry_sdk.integrations import _AUTO_ENABLING_INTEGRATIONS
-from sentry_sdk.integrations.logging import LoggingIntegration
-from sentry_sdk.scope import (  # noqa: F401
-    add_global_event_processor,
-    global_event_processors,
+from sentry_sdk.integrations import (
+    _AUTO_ENABLING_INTEGRATIONS,
+    _DEFAULT_INTEGRATIONS,
+    DidNotEnable,
+    Integration,
+    setup_integrations,
 )
-from sentry_sdk.utils import get_sdk_name
+from sentry_sdk.integrations.logging import LoggingIntegration
+from sentry_sdk.integrations.stdlib import StdlibIntegration
+from sentry_sdk.scope import add_global_event_processor
+from sentry_sdk.utils import get_sdk_name, reraise
 from sentry_sdk.tracing_utils import has_tracing_enabled
 
 
+class NoOpIntegration(Integration):
+    """
+    A simple no-op integration for testing purposes.
+    """
+
+    identifier = "noop"
+
+    @staticmethod
+    def setup_once() -> None:
+        pass
+
+    def __eq__(self, __value: object) -> bool:
+        """
+        All instances of NoOpIntegration should be considered equal to each other.
+        """
+        return type(__value) == type(self)
+
+
 def test_processors(sentry_init, capture_events):
     sentry_init()
     events = capture_events()
 
-    with configure_scope() as scope:
-
-        def error_processor(event, exc_info):
-            event["exception"]["values"][0]["value"] += " whatever"
-            return event
+    def error_processor(event, exc_info):
+        event["exception"]["values"][0]["value"] += " whatever"
+        return event
 
-        scope.add_error_processor(error_processor, ValueError)
+    sentry_sdk.get_isolation_scope().add_error_processor(error_processor, ValueError)
 
     try:
         raise ValueError("aha!")
@@ -50,20 +77,35 @@ def error_processor(event, exc_info):
     assert event["exception"]["values"][0]["value"] == "aha! whatever"
 
 
+class ModuleImportErrorSimulator:
+    def __init__(self, modules, error_cls=DidNotEnable):
+        self.modules = modules
+        self.error_cls = error_cls
+        for sys_module in list(sys.modules.keys()):
+            if any(sys_module.startswith(module) for module in modules):
+                del sys.modules[sys_module]
+
+    def find_spec(self, fullname, _path, _target=None):
+        if fullname in self.modules:
+            raise self.error_cls("Test import failure for %s" % fullname)
+
+    def __enter__(self):
+        # WARNING: We need to be first to avoid pytest messing with local imports
+        sys.meta_path.insert(0, self)
+
+    def __exit__(self, *_args):
+        sys.meta_path.remove(self)
+
+
 def test_auto_enabling_integrations_catches_import_error(sentry_init, caplog):
     caplog.set_level(logging.DEBUG)
-    redis_index = _AUTO_ENABLING_INTEGRATIONS.index(
-        "sentry_sdk.integrations.redis.RedisIntegration"
-    )  # noqa: N806
 
-    sentry_init(auto_enabling_integrations=True, debug=True)
+    with ModuleImportErrorSimulator(
+        [i.rsplit(".", 1)[0] for i in _AUTO_ENABLING_INTEGRATIONS]
+    ):
+        sentry_init(auto_enabling_integrations=True, debug=True)
 
     for import_string in _AUTO_ENABLING_INTEGRATIONS:
-        # Ignore redis in the test case, because it is installed as a
-        # dependency for running tests, and therefore always enabled.
-        if _AUTO_ENABLING_INTEGRATIONS[redis_index] == import_string:
-            continue
-
         assert any(
             record.message.startswith(
                 "Did not import default integration {}:".format(import_string)
@@ -72,28 +114,6 @@ def test_auto_enabling_integrations_catches_import_error(sentry_init, caplog):
         ), "Problem with checking auto enabling {}".format(import_string)
 
 
-def test_event_id(sentry_init, capture_events):
-    sentry_init()
-    events = capture_events()
-
-    try:
-        raise ValueError("aha!")
-    except Exception:
-        event_id = capture_exception()
-        int(event_id, 16)
-        assert len(event_id) == 32
-
-    (event,) = events
-    assert event["event_id"] == event_id
-    assert last_event_id() == event_id
-    assert Hub.current.last_event_id() == event_id
-
-    new_event_id = Hub.current.capture_event({"type": "transaction"})
-    assert new_event_id is not None
-    assert new_event_id != event_id
-    assert Hub.current.last_event_id() == event_id
-
-
 def test_generic_mechanism(sentry_init, capture_events):
     sentry_init()
     events = capture_events()
@@ -204,7 +224,7 @@ def before_breadcrumb(crumb, hint):
     events = capture_events()
 
     monkeypatch.setattr(
-        Hub.current.client.transport, "record_lost_event", record_lost_event
+        sentry_sdk.get_client().transport, "record_lost_event", record_lost_event
     )
 
     def do_this():
@@ -253,7 +273,7 @@ def test_option_enable_tracing(
     updated_traces_sample_rate,
 ):
     sentry_init(enable_tracing=enable_tracing, traces_sample_rate=traces_sample_rate)
-    options = Hub.current.client.options
+    options = sentry_sdk.get_client().options
     assert has_tracing_enabled(options) is tracing_enabled
     assert options["traces_sample_rate"] == updated_traces_sample_rate
 
@@ -277,7 +297,7 @@ def before_breadcrumb(crumb, hint):
     add_breadcrumb(crumb=dict(foo=42))
 
 
-def test_push_scope(sentry_init, capture_events):
+def test_push_scope(sentry_init, capture_events, suppress_deprecation_warnings):
     sentry_init()
     events = capture_events()
 
@@ -294,7 +314,12 @@ def test_push_scope(sentry_init, capture_events):
     assert "exception" in event
 
 
-def test_push_scope_null_client(sentry_init, capture_events):
+def test_push_scope_null_client(
+    sentry_init, capture_events, suppress_deprecation_warnings
+):
+    """
+    This test can be removed when we remove push_scope and the Hub from the SDK.
+    """
     sentry_init()
     events = capture_events()
 
@@ -310,8 +335,14 @@ def test_push_scope_null_client(sentry_init, capture_events):
     assert len(events) == 0
 
 
+@pytest.mark.skip(
+    reason="This test is not valid anymore, because push_scope just returns the isolation scope. This test should be removed once the Hub is removed"
+)
 @pytest.mark.parametrize("null_client", (True, False))
 def test_push_scope_callback(sentry_init, null_client, capture_events):
+    """
+    This test can be removed when we remove push_scope and the Hub from the SDK.
+    """
     sentry_init()
 
     if null_client:
@@ -359,23 +390,92 @@ def test_breadcrumbs(sentry_init, capture_events):
             category="auth", message="Authenticated user %s" % i, level="info"
         )
 
-    with configure_scope() as scope:
-        scope.clear()
+    sentry_sdk.get_isolation_scope().clear()
 
     capture_exception(ValueError())
     (event,) = events
     assert len(event["breadcrumbs"]["values"]) == 0
 
 
+def test_breadcrumb_ordering(sentry_init, capture_events):
+    sentry_init()
+    events = capture_events()
+    now = datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0)
+
+    timestamps = [
+        now - datetime.timedelta(days=10),
+        now - datetime.timedelta(days=8),
+        now - datetime.timedelta(days=12),
+    ]
+
+    for timestamp in timestamps:
+        add_breadcrumb(
+            message="Authenticated at %s" % timestamp,
+            category="auth",
+            level="info",
+            timestamp=timestamp,
+        )
+
+    capture_exception(ValueError())
+    (event,) = events
+
+    assert len(event["breadcrumbs"]["values"]) == len(timestamps)
+    timestamps_from_event = [
+        datetime_from_isoformat(x["timestamp"]) for x in event["breadcrumbs"]["values"]
+    ]
+    assert timestamps_from_event == sorted(timestamps)
+
+
+def test_breadcrumb_ordering_different_types(sentry_init, capture_events):
+    sentry_init()
+    events = capture_events()
+    now = datetime.datetime.now(datetime.timezone.utc)
+
+    timestamps = [
+        now - datetime.timedelta(days=10),
+        now - datetime.timedelta(days=8),
+        now.replace(microsecond=0) - datetime.timedelta(days=12),
+        now - datetime.timedelta(days=9),
+        now - datetime.timedelta(days=13),
+        now.replace(microsecond=0) - datetime.timedelta(days=11),
+    ]
+
+    breadcrumb_timestamps = [
+        timestamps[0],
+        timestamps[1].isoformat(),
+        datetime.datetime.strftime(timestamps[2], "%Y-%m-%dT%H:%M:%S") + "Z",
+        datetime.datetime.strftime(timestamps[3], "%Y-%m-%dT%H:%M:%S.%f") + "+00:00",
+        datetime.datetime.strftime(timestamps[4], "%Y-%m-%dT%H:%M:%S.%f") + "+0000",
+        datetime.datetime.strftime(timestamps[5], "%Y-%m-%dT%H:%M:%S.%f") + "-0000",
+    ]
+
+    for i, timestamp in enumerate(timestamps):
+        add_breadcrumb(
+            message="Authenticated at %s" % timestamp,
+            category="auth",
+            level="info",
+            timestamp=breadcrumb_timestamps[i],
+        )
+
+    capture_exception(ValueError())
+    (event,) = events
+
+    assert len(event["breadcrumbs"]["values"]) == len(timestamps)
+    timestamps_from_event = [
+        datetime_from_isoformat(x["timestamp"]) for x in event["breadcrumbs"]["values"]
+    ]
+    assert timestamps_from_event == sorted(timestamps)
+
+
 def test_attachments(sentry_init, capture_envelopes):
     sentry_init()
     envelopes = capture_envelopes()
 
     this_file = os.path.abspath(__file__.rstrip("c"))
 
-    with configure_scope() as scope:
-        scope.add_attachment(bytes=b"Hello World!", filename="message.txt")
-        scope.add_attachment(path=this_file)
+    scope = sentry_sdk.get_isolation_scope()
+    scope.add_attachment(bytes=b"Hello World!", filename="message.txt")
+    scope.add_attachment(path=this_file)
 
     capture_exception(ValueError())
 
@@ -400,6 +500,81 @@ def test_attachments(sentry_init, capture_envelopes):
         assert pyfile.payload.get_bytes() == f.read()
 
 
+@pytest.mark.tests_internal_exceptions
+def test_attachments_graceful_failure(
+    sentry_init, capture_envelopes, internal_exceptions
+):
+    sentry_init()
+    envelopes = capture_envelopes()
+
+    sentry_sdk.get_isolation_scope().add_attachment(path="non_existent")
+    capture_exception(ValueError())
+
+    (envelope,) = envelopes
+    assert len(envelope.items) == 2
+    assert envelope.items[1].payload.get_bytes() == b""
+
+
+def test_attachments_exceptions(sentry_init):
+    sentry_init()
+
+    scope = sentry_sdk.get_isolation_scope()
+
+    # bytes and path are None
+    with pytest.raises(TypeError) as e:
+        scope.add_attachment()
+
+    assert str(e.value) == "path or raw bytes required for attachment"
+
+    # filename is None
+    with pytest.raises(TypeError) as e:
+        scope.add_attachment(bytes=b"Hello World!")
+
+    assert str(e.value) == "filename is required for attachment"
+
+
+def test_attachments_content_type_is_none(sentry_init, capture_envelopes):
+    sentry_init()
+    envelopes = capture_envelopes()
+
+    scope = sentry_sdk.get_isolation_scope()
+
+    scope.add_attachment(
+        bytes=b"Hello World!", filename="message.txt", content_type="foo/bar"
+    )
+    capture_exception(ValueError())
+
+    (envelope,) = envelopes
+    attachments = [x for x in envelope.items if x.type == "attachment"]
+    (message,) = attachments
+
+    assert message.headers["filename"] == "message.txt"
+    assert message.headers["content_type"] == "foo/bar"
+
+
+def test_attachments_repr(sentry_init):
+    sentry_init()
+
+    scope = sentry_sdk.get_isolation_scope()
+
+    scope.add_attachment(bytes=b"Hello World!", filename="message.txt")
+
+    assert repr(scope._attachments[0]) == ""
+
+
+def test_attachments_bytes_callable_payload(sentry_init):
+    sentry_init()
+
+    scope = sentry_sdk.get_isolation_scope()
+
+    scope.add_attachment(bytes=bytes, filename="message.txt")
+
+    attachment = scope._attachments[0]
+    item = attachment.to_envelope_item()
+
+    assert item.payload.bytes == b""
+
+
 def test_integration_scoping(sentry_init, capture_events):
     logger = logging.getLogger("test_basics")
 
@@ -417,10 +592,61 @@ def test_integration_scoping(sentry_init, capture_events):
     assert not events
 
 
+default_integrations = [
+    getattr(
+        importlib.import_module(integration.rsplit(".", 1)[0]),
+        integration.rsplit(".", 1)[1],
+    )
+    for integration in _DEFAULT_INTEGRATIONS
+]
+
+
+@pytest.mark.forked
+@pytest.mark.parametrize(
+    "provided_integrations,default_integrations,disabled_integrations,expected_integrations",
+    [
+        ([], False, None, set()),
+        ([], False, [], set()),
+        ([LoggingIntegration()], False, None, {LoggingIntegration}),
+        ([], True, None, set(default_integrations)),
+        (
+            [],
+            True,
+            [LoggingIntegration(), StdlibIntegration],
+            set(default_integrations) - {LoggingIntegration, StdlibIntegration},
+        ),
+    ],
+)
+def test_integrations(
+    sentry_init,
+    provided_integrations,
+    default_integrations,
+    disabled_integrations,
+    expected_integrations,
+    reset_integrations,
+):
+    sentry_init(
+        integrations=provided_integrations,
+        default_integrations=default_integrations,
+        disabled_integrations=disabled_integrations,
+        auto_enabling_integrations=False,
+        debug=True,
+    )
+    assert {
+        type(integration) for integration in get_client().integrations.values()
+    } == expected_integrations
+
+
+@pytest.mark.skip(
+    reason="This test is not valid anymore, because with the new Scopes calling bind_client on the Hub sets the client on the global scope. This test should be removed once the Hub is removed"
+)
 def test_client_initialized_within_scope(sentry_init, caplog):
+    """
+    This test can be removed when we remove push_scope and the Hub from the SDK.
+    """
     caplog.set_level(logging.WARNING)
 
-    sentry_init(debug=True)
+    sentry_init()
 
     with push_scope():
         Hub.current.bind_client(Client())
@@ -430,10 +656,16 @@ def test_client_initialized_within_scope(sentry_init, caplog):
     assert record.msg.startswith("init() called inside of pushed scope.")
 
 
+@pytest.mark.skip(
+    reason="This test is not valid anymore, because with the new Scopes the push_scope just returns the isolation scope. This test should be removed once the Hub is removed"
+)
 def test_scope_leaks_cleaned_up(sentry_init, caplog):
+    """
+    This test can be removed when we remove push_scope and the Hub from the SDK.
+    """
     caplog.set_level(logging.WARNING)
 
-    sentry_init(debug=True)
+    sentry_init()
 
     old_stack = list(Hub.current._stack)
 
@@ -447,10 +679,16 @@ def test_scope_leaks_cleaned_up(sentry_init, caplog):
     assert record.message.startswith("Leaked 1 scopes:")
 
 
+@pytest.mark.skip(
+    reason="This test is not valid anymore, because with the new Scopes there is not pushing and popping of scopes. This test should be removed once the Hub is removed"
+)
 def test_scope_popped_too_soon(sentry_init, caplog):
+    """
+    This test can be removed when we remove push_scope and the Hub from the SDK.
+    """
     caplog.set_level(logging.ERROR)
 
-    sentry_init(debug=True)
+    sentry_init()
 
     old_stack = list(Hub.current._stack)
 
@@ -472,14 +710,14 @@ def before_send(event, hint):
     sentry_init(debug=True, before_send=before_send)
     events = capture_events()
 
-    with push_scope() as scope:
+    with new_scope() as scope:
 
         @scope.add_event_processor
         def foo(event, hint):
             event["message"] += "foo"
             return event
 
-        with push_scope() as scope:
+        with new_scope() as scope:
 
             @scope.add_event_processor
             def bar(event, hint):
@@ -494,7 +732,7 @@ def bar(event, hint):
 
 
 def test_capture_event_with_scope_kwargs(sentry_init, capture_events):
-    sentry_init(debug=True)
+    sentry_init()
     events = capture_events()
     capture_event({}, level="info", extras={"foo": "bar"})
     (event,) = events
@@ -503,7 +741,7 @@ def test_capture_event_with_scope_kwargs(sentry_init, capture_events):
 
 
 def test_dedupe_event_processor_drop_records_client_report(
-    sentry_init, capture_events, capture_client_reports
+    sentry_init, capture_events, capture_record_lost_event_calls
 ):
     """
     DedupeIntegration internally has an event_processor that filters duplicate exceptions.
@@ -512,7 +750,7 @@ def test_dedupe_event_processor_drop_records_client_report(
     """
     sentry_init()
     events = capture_events()
-    reports = capture_client_reports()
+    record_lost_event_calls = capture_record_lost_event_calls()
 
     try:
         raise ValueError("aha!")
@@ -524,35 +762,81 @@ def test_dedupe_event_processor_drop_records_client_report(
             capture_exception()
 
     (event,) = events
-    (report,) = reports
+    (lost_event_call,) = record_lost_event_calls
 
     assert event["level"] == "error"
     assert "exception" in event
-    assert report == ("event_processor", "error")
+    assert lost_event_call == ("event_processor", "error", None, 1)
+
+
+def test_dedupe_doesnt_take_into_account_dropped_exception(sentry_init, capture_events):
+    # Two exceptions happen one after another. The first one is dropped in the
+    # user's before_send. The second one isn't.
+    # Originally, DedupeIntegration would drop the second exception. This test
+    # is making sure that that is no longer the case -- i.e., DedupeIntegration
+    # doesn't consider exceptions dropped in before_send.
+    count = 0
+
+    def before_send(event, hint):
+        nonlocal count
+        count += 1
+        if count == 1:
+            return None
+        return event
+
+    sentry_init(before_send=before_send)
+    events = capture_events()
+
+    exc = ValueError("aha!")
+    for _ in range(2):
+        # The first ValueError will be dropped by before_send. The second
+        # ValueError will be accepted by before_send, and should be sent to
+        # Sentry.
+        try:
+            raise exc
+        except Exception:
+            capture_exception()
+
+    assert len(events) == 1
 
 
 def test_event_processor_drop_records_client_report(
-    sentry_init, capture_events, capture_client_reports
+    sentry_init, capture_events, capture_record_lost_event_calls
 ):
     sentry_init(traces_sample_rate=1.0)
     events = capture_events()
-    reports = capture_client_reports()
+    record_lost_event_calls = capture_record_lost_event_calls()
 
-    global global_event_processors
+    # Ensure full idempotency by restoring the original global event processors list object, not just a copy.
+    old_processors = sentry_sdk.scope.global_event_processors
 
-    @add_global_event_processor
-    def foo(event, hint):
-        return None
+    try:
+        sentry_sdk.scope.global_event_processors = (
+            sentry_sdk.scope.global_event_processors.copy()
+        )
 
-    capture_message("dropped")
+        @add_global_event_processor
+        def foo(event, hint):
+            return None
 
-    with start_transaction(name="dropped"):
-        pass
+        capture_message("dropped")
 
-    assert len(events) == 0
-    assert reports == [("event_processor", "error"), ("event_processor", "transaction")]
+        with start_transaction(name="dropped"):
+            pass
 
-    global_event_processors.pop()
+        assert len(events) == 0
+
+        # Using Counter because order of record_lost_event calls does not matter
+        assert Counter(record_lost_event_calls) == Counter(
+            [
+                ("event_processor", "error", None, 1),
+                ("event_processor", "transaction", None, 1),
+                ("event_processor", "span", None, 1),
+            ]
+        )
+
+    finally:
+        sentry_sdk.scope.global_event_processors = old_processors
 
 
 @pytest.mark.parametrize(
@@ -567,6 +851,8 @@ def foo(event, hint):
         (["quart"], "sentry.python.quart"),
         (["sanic"], "sentry.python.sanic"),
         (["starlette"], "sentry.python.starlette"),
+        (["starlite"], "sentry.python.starlite"),
+        (["litestar"], "sentry.python.litestar"),
         (["chalice"], "sentry.python.chalice"),
         (["serverless"], "sentry.python.serverless"),
         (["pyramid"], "sentry.python.pyramid"),
@@ -584,6 +870,7 @@ def foo(event, hint):
         (["celery"], "sentry.python"),
         (["dedupe"], "sentry.python"),
         (["excepthook"], "sentry.python"),
+        (["unraisablehook"], "sentry.python"),
         (["executing"], "sentry.python"),
         (["modules"], "sentry.python"),
         (["pure_eval"], "sentry.python"),
@@ -605,6 +892,8 @@ def foo(event, hint):
         (["sanic", "quart", "sqlalchemy"], "sentry.python.quart"),
         (["starlette", "sanic", "rq"], "sentry.python.sanic"),
         (["chalice", "starlette", "modules"], "sentry.python.starlette"),
+        (["chalice", "starlite", "modules"], "sentry.python.starlite"),
+        (["chalice", "litestar", "modules"], "sentry.python.litestar"),
         (["serverless", "chalice", "pure_eval"], "sentry.python.chalice"),
         (["pyramid", "serverless", "modules"], "sentry.python.serverless"),
         (["tornado", "pyramid", "executing"], "sentry.python.pyramid"),
@@ -686,3 +975,249 @@ def test_functions_to_trace_with_class(sentry_init, capture_events):
     assert len(event["spans"]) == 2
     assert event["spans"][0]["description"] == "tests.test_basics.WorldGreeter.greet"
     assert event["spans"][1]["description"] == "tests.test_basics.WorldGreeter.greet"
+
+
+def test_multiple_setup_integrations_calls():
+    first_call_return = setup_integrations([NoOpIntegration()], with_defaults=False)
+    assert first_call_return == {NoOpIntegration.identifier: NoOpIntegration()}
+
+    second_call_return = setup_integrations([NoOpIntegration()], with_defaults=False)
+    assert second_call_return == {NoOpIntegration.identifier: NoOpIntegration()}
+
+
+class TracingTestClass:
+    @staticmethod
+    def static(arg):
+        return arg
+
+    @classmethod
+    def class_(cls, arg):
+        return cls, arg
+
+
+# We need to fork here because the test modifies tests.test_basics.TracingTestClass
+@pytest.mark.forked
+def test_staticmethod_class_tracing(sentry_init, capture_events):
+    sentry_init(
+        debug=True,
+        traces_sample_rate=1.0,
+        functions_to_trace=[
+            {"qualified_name": "tests.test_basics.TracingTestClass.static"}
+        ],
+    )
+
+    events = capture_events()
+
+    with sentry_sdk.start_transaction(name="test"):
+        assert TracingTestClass.static(1) == 1
+
+    (event,) = events
+    assert event["type"] == "transaction"
+    assert event["transaction"] == "test"
+
+    (span,) = event["spans"]
+    assert span["description"] == "tests.test_basics.TracingTestClass.static"
+
+
+# We need to fork here because the test modifies tests.test_basics.TracingTestClass
+@pytest.mark.forked
+def test_staticmethod_instance_tracing(sentry_init, capture_events):
+    sentry_init(
+        debug=True,
+        traces_sample_rate=1.0,
+        functions_to_trace=[
+            {"qualified_name": "tests.test_basics.TracingTestClass.static"}
+        ],
+    )
+
+    events = capture_events()
+
+    with sentry_sdk.start_transaction(name="test"):
+        assert TracingTestClass().static(1) == 1
+
+    (event,) = events
+    assert event["type"] == "transaction"
+    assert event["transaction"] == "test"
+
+    (span,) = event["spans"]
+    assert span["description"] == "tests.test_basics.TracingTestClass.static"
+
+
+# We need to fork here because the test modifies tests.test_basics.TracingTestClass
+@pytest.mark.forked
+def test_classmethod_class_tracing(sentry_init, capture_events):
+    sentry_init(
+        debug=True,
+        traces_sample_rate=1.0,
+        functions_to_trace=[
+            {"qualified_name": "tests.test_basics.TracingTestClass.class_"}
+        ],
+    )
+
+    events = capture_events()
+
+    with sentry_sdk.start_transaction(name="test"):
+        assert TracingTestClass.class_(1) == (TracingTestClass, 1)
+
+    (event,) = events
+    assert event["type"] == "transaction"
+    assert event["transaction"] == "test"
+
+    (span,) = event["spans"]
+    assert span["description"] == "tests.test_basics.TracingTestClass.class_"
+
+
+# We need to fork here because the test modifies tests.test_basics.TracingTestClass
+@pytest.mark.forked
+def test_classmethod_instance_tracing(sentry_init, capture_events):
+    sentry_init(
+        debug=True,
+        traces_sample_rate=1.0,
+        functions_to_trace=[
+            {"qualified_name": "tests.test_basics.TracingTestClass.class_"}
+        ],
+    )
+
+    events = capture_events()
+
+    with sentry_sdk.start_transaction(name="test"):
+        assert TracingTestClass().class_(1) == (TracingTestClass, 1)
+
+    (event,) = events
+    assert event["type"] == "transaction"
+    assert event["transaction"] == "test"
+
+    (span,) = event["spans"]
+    assert span["description"] == "tests.test_basics.TracingTestClass.class_"
+
+
+def test_last_event_id(sentry_init):
+    sentry_init(enable_tracing=True)
+
+    assert last_event_id() is None
+
+    capture_exception(Exception("test"))
+
+    assert last_event_id() is not None
+
+
+def test_last_event_id_transaction(sentry_init):
+    sentry_init(enable_tracing=True)
+
+    assert last_event_id() is None
+
+    with start_transaction(name="test"):
+        pass
+
+    assert last_event_id() is None, "Transaction should not set last_event_id"
+
+
+def test_last_event_id_scope(sentry_init):
+    sentry_init(enable_tracing=True)
+
+    # Should not crash
+    with isolation_scope() as scope:
+        assert scope.last_event_id() is None
+
+
+def test_hub_constructor_deprecation_warning():
+    with pytest.warns(sentry_sdk.hub.SentryHubDeprecationWarning):
+        Hub()
+
+
+def test_hub_current_deprecation_warning():
+    with pytest.warns(sentry_sdk.hub.SentryHubDeprecationWarning) as warning_records:
+        Hub.current
+
+    # Make sure we only issue one deprecation warning
+    assert len(warning_records) == 1
+
+
+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"
+
+
+@pytest.mark.skipif(
+    sys.version_info < (3, 11),
+    reason="this test appears to cause a segfault on Python < 3.11",
+)
+def test_stacktrace_big_recursion(sentry_init, capture_events):
+    """
+    Ensure that if the recursion limit is increased, the full stacktrace is not captured,
+    as it would take too long to process the entire stack trace.
+    Also, ensure that the capturing does not take too long.
+    """
+    sentry_init()
+    events = capture_events()
+
+    def recurse():
+        recurse()
+
+    old_recursion_limit = sys.getrecursionlimit()
+
+    try:
+        sys.setrecursionlimit(100_000)
+        recurse()
+    except RecursionError as e:
+        capture_start_time = time.perf_counter_ns()
+        sentry_sdk.capture_exception(e)
+        capture_end_time = time.perf_counter_ns()
+    finally:
+        sys.setrecursionlimit(old_recursion_limit)
+
+    (event,) = events
+
+    assert event["exception"]["values"][0]["stacktrace"] is None
+    assert event["_meta"]["exception"] == {
+        "values": {"0": {"stacktrace": {"": {"rem": [["!config", "x"]]}}}}
+    }
+
+    # On my machine, it takes about 100-200ms to capture the exception,
+    # so this limit should be generous enough.
+    assert capture_end_time - capture_start_time < 10**9 * 2, (
+        "stacktrace capture took too long, check that frame limit is set correctly"
+    )
diff --git a/tests/test_client.py b/tests/test_client.py
index 83257ab213..043c7c6ae5 100644
--- a/tests/test_client.py
+++ b/tests/test_client.py
@@ -1,12 +1,17 @@
-# coding: utf-8
+import contextlib
 import os
 import json
-import pytest
 import subprocess
 import sys
 import time
-
+from collections import Counter, defaultdict
+from collections.abc import Mapping
 from textwrap import dedent
+from unittest import mock
+
+import pytest
+
+import sentry_sdk
 from sentry_sdk import (
     Hub,
     Client,
@@ -15,39 +20,37 @@
     capture_message,
     capture_exception,
     capture_event,
-    start_transaction,
     set_tag,
+    start_transaction,
 )
+from sentry_sdk.spotlight import DEFAULT_SPOTLIGHT_URL
+from sentry_sdk.utils import capture_internal_exception
 from sentry_sdk.integrations.executing import ExecutingIntegration
 from sentry_sdk.transport import Transport
-from sentry_sdk._compat import reraise, text_type, PY2
-from sentry_sdk.utils import HAS_CHAINED_EXCEPTIONS
-from sentry_sdk.utils import logger
 from sentry_sdk.serializer import MAX_DATABAG_BREADTH
 from sentry_sdk.consts import DEFAULT_MAX_BREADCRUMBS, DEFAULT_MAX_VALUE_LENGTH
 
-try:
-    from unittest import mock  # python 3.3 and above
-except ImportError:
-    import mock  # python < 3.3
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+    from collections.abc import Callable
+    from typing import Any, Optional, Union
+    from sentry_sdk._types import Event
 
-if PY2:
-    # Importing ABCs from collections is deprecated, and will stop working in 3.8
-    # https://github.com/python/cpython/blob/master/Lib/collections/__init__.py#L49
-    from collections import Mapping
-else:
-    # New in 3.3
-    # https://docs.python.org/3/library/collections.abc.html
-    from collections.abc import Mapping
+
+maximum_python_312 = pytest.mark.skipif(
+    sys.version_info > (3, 12),
+    reason="Since Python 3.13, `FrameLocalsProxy` skips items of `locals()` that have non-`str` keys; this is a CPython implementation detail: https://github.com/python/cpython/blame/7b413952e817ae87bfda2ac85dd84d30a6ce743b/Objects/frameobject.c#L148",
+)
 
 
-class EventCapturedError(Exception):
+class EnvelopeCapturedError(Exception):
     pass
 
 
 class _TestTransport(Transport):
-    def capture_event(self, event):
-        raise EventCapturedError(event)
+    def capture_envelope(self, envelope):
+        raise EnvelopeCapturedError(envelope)
 
 
 def test_transport_option(monkeypatch):
@@ -60,8 +63,8 @@ def test_transport_option(monkeypatch):
     assert Client().dsn is None
 
     monkeypatch.setenv("SENTRY_DSN", dsn)
-    transport = Transport({"dsn": dsn2})
-    assert text_type(transport.parsed_dsn) == dsn2
+    transport = _TestTransport({"dsn": dsn2})
+    assert str(transport.parsed_dsn) == dsn2
     assert str(Client(transport=transport).dsn) == dsn
 
 
@@ -245,7 +248,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:
@@ -255,6 +261,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:
@@ -264,13 +273,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(
@@ -280,68 +307,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):
@@ -352,15 +390,12 @@ def test_simple_transport(sentry_init):
 
 
 def test_ignore_errors(sentry_init, capture_events):
+    sentry_init(ignore_errors=[ZeroDivisionError])
+    events = capture_events()
+
     class MyDivisionError(ZeroDivisionError):
         pass
 
-    def raise_it(exc_info):
-        reraise(*exc_info)
-
-    sentry_init(ignore_errors=[ZeroDivisionError], transport=_TestTransport())
-    Hub.current._capture_internal_exception = raise_it
-
     def e(exc):
         try:
             raise exc
@@ -369,61 +404,10 @@ def e(exc):
 
     e(ZeroDivisionError())
     e(MyDivisionError())
-    pytest.raises(EventCapturedError, lambda: e(ValueError()))
+    e(ValueError())
 
-
-def test_with_locals_deprecation_enabled(sentry_init):
-    with mock.patch.object(logger, "warning", mock.Mock()) as fake_warning:
-        sentry_init(with_locals=True)
-
-        client = Hub.current.client
-        assert "with_locals" not in client.options
-        assert "include_local_variables" in client.options
-        assert client.options["include_local_variables"]
-
-        fake_warning.assert_called_once_with(
-            "Deprecated: The option 'with_locals' was renamed to 'include_local_variables'. Please use 'include_local_variables'. The option 'with_locals' will be removed in the future."
-        )
-
-
-def test_with_locals_deprecation_disabled(sentry_init):
-    with mock.patch.object(logger, "warning", mock.Mock()) as fake_warning:
-        sentry_init(with_locals=False)
-
-        client = Hub.current.client
-        assert "with_locals" not in client.options
-        assert "include_local_variables" in client.options
-        assert not client.options["include_local_variables"]
-
-        fake_warning.assert_called_once_with(
-            "Deprecated: The option 'with_locals' was renamed to 'include_local_variables'. Please use 'include_local_variables'. The option 'with_locals' will be removed in the future."
-        )
-
-
-def test_include_local_variables_deprecation(sentry_init):
-    with mock.patch.object(logger, "warning", mock.Mock()) as fake_warning:
-        sentry_init(include_local_variables=False)
-
-        client = Hub.current.client
-        assert "with_locals" not in client.options
-        assert "include_local_variables" in client.options
-        assert not client.options["include_local_variables"]
-
-        fake_warning.assert_not_called()
-
-
-def test_request_bodies_deprecation(sentry_init):
-    with mock.patch.object(logger, "warning", mock.Mock()) as fake_warning:
-        sentry_init(request_bodies="small")
-
-        client = Hub.current.client
-        assert "request_bodies" not in client.options
-        assert "max_request_body_size" in client.options
-        assert client.options["max_request_body_size"] == "small"
-
-        fake_warning.assert_called_once_with(
-            "Deprecated: The option 'request_bodies' was renamed to 'max_request_body_size'. Please use 'max_request_body_size'. The option 'request_bodies' will be removed in the future."
-        )
+    assert len(events) == 1
+    assert events[0]["exception"]["values"][0]["type"] == "ValueError"
 
 
 def test_include_local_variables_enabled(sentry_init, capture_events):
@@ -579,14 +563,33 @@ def test_attach_stacktrace_disabled(sentry_init, capture_events):
     assert "threads" not in event
 
 
+def test_attach_stacktrace_transaction(sentry_init, capture_events):
+    sentry_init(traces_sample_rate=1.0, attach_stacktrace=True)
+    events = capture_events()
+    with start_transaction(name="transaction"):
+        pass
+    (event,) = events
+    assert "threads" not in event
+
+
 def test_capture_event_works(sentry_init):
     sentry_init(transport=_TestTransport())
-    pytest.raises(EventCapturedError, lambda: capture_event({}))
-    pytest.raises(EventCapturedError, lambda: capture_event({}))
+    pytest.raises(EnvelopeCapturedError, lambda: capture_event({}))
+    pytest.raises(EnvelopeCapturedError, lambda: capture_event({}))
 
 
 @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(
@@ -594,18 +597,18 @@ def test_atexit(tmpdir, monkeypatch, num_messages):
     import time
     from sentry_sdk import init, transport, capture_message
 
-    def send_event(self, event):
+    def capture_envelope(self, envelope):
         time.sleep(0.1)
-        print(event["message"])
+        event = envelope.get_event() or dict()
+        message = event.get("message", "")
+        print(message)
 
-    transport.HttpTransport._send_event = send_event
-    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
-            )
+    """.format(transport=transport, options=options, num_messages=num_messages)
         )
     )
 
@@ -619,8 +622,14 @@ def send_event(self, event):
     assert output.count(b"HI") == num_messages
 
 
-def test_configure_scope_available(sentry_init, request, monkeypatch):
-    # Test that scope is configured if client is configured
+def test_configure_scope_available(
+    sentry_init, request, monkeypatch, suppress_deprecation_warnings
+):
+    """
+    Test that scope is configured if client is configured
+
+    This test can be removed once configure_scope and the Hub are removed.
+    """
     sentry_init()
 
     with configure_scope() as scope:
@@ -642,7 +651,7 @@ def callback(scope):
 def test_client_debug_option_enabled(sentry_init, caplog):
     sentry_init(debug=True)
 
-    Hub.current._capture_internal_exception((ValueError, ValueError("OK"), None))
+    capture_internal_exception((ValueError, ValueError("OK"), None))
     assert "OK" in caplog.text
 
 
@@ -652,10 +661,13 @@ def test_client_debug_option_disabled(with_client, sentry_init, caplog):
     if with_client:
         sentry_init()
 
-    Hub.current._capture_internal_exception((ValueError, ValueError("OK"), None))
+    capture_internal_exception((ValueError, ValueError("OK"), None))
     assert "OK" not in caplog.text
 
 
+@pytest.mark.skip(
+    reason="New behavior in SDK 2.0: You have a scope before init and add data to it."
+)
 def test_scope_initialized_before_client(sentry_init, capture_events):
     """
     This is a consequence of how configure_scope() works. We must
@@ -677,9 +689,7 @@ def test_scope_initialized_before_client(sentry_init, capture_events):
 def test_weird_chars(sentry_init, capture_events):
     sentry_init()
     events = capture_events()
-    # fmt: off
-    capture_message(u"föö".encode("latin1"))
-    # fmt: on
+    capture_message("föö".encode("latin1"))
     (event,) = events
     assert json.loads(json.dumps(event)) == event
 
@@ -726,14 +736,13 @@ def test_cyclic_data(sentry_init, capture_events):
     sentry_init()
     events = capture_events()
 
-    with configure_scope() as scope:
-        data = {}
-        data["is_cyclic"] = data
+    data = {}
+    data["is_cyclic"] = data
 
-        other_data = ""
-        data["not_cyclic"] = other_data
-        data["not_cyclic2"] = other_data
-        scope.set_extra("foo", data)
+    other_data = ""
+    data["not_cyclic"] = other_data
+    data["not_cyclic2"] = other_data
+    sentry_sdk.get_isolation_scope().set_extra("foo", data)
 
     capture_message("hi")
     (event,) = events
@@ -742,7 +751,7 @@ def test_cyclic_data(sentry_init, capture_events):
     assert data == {"not_cyclic2": "", "not_cyclic": "", "is_cyclic": ""}
 
 
-def test_databag_depth_stripping(sentry_init, capture_events, benchmark):
+def test_databag_depth_stripping(sentry_init, capture_events):
     sentry_init()
     events = capture_events()
 
@@ -750,61 +759,73 @@ def test_databag_depth_stripping(sentry_init, capture_events, benchmark):
     for _ in range(100000):
         value = [value]
 
-    @benchmark
-    def inner():
-        del events[:]
-        try:
-            a = value  # noqa
-            1 / 0
-        except Exception:
-            capture_exception()
+    del events[:]
+    try:
+        a = value  # noqa
+        1 / 0
+    except Exception:
+        capture_exception()
+
+    (event,) = events
 
-        (event,) = events
+    stacktrace_frame = event["exception"]["values"][0]["stacktrace"]["frames"][0]
+    a_var = stacktrace_frame["vars"]["a"]
 
-        assert len(json.dumps(event)) < 10000
+    assert type(a_var) == list
+    assert len(a_var) == 1 and type(a_var[0]) == list
 
+    first_level_list = a_var[0]
+    assert type(first_level_list) == list
+    assert len(first_level_list) == 1
 
-def test_databag_string_stripping(sentry_init, capture_events, benchmark):
+    second_level_list = first_level_list[0]
+    assert type(second_level_list) == list
+    assert len(second_level_list) == 1
+
+    third_level_list = second_level_list[0]
+    assert type(third_level_list) == list
+    assert len(third_level_list) == 1
+
+    inner_value_repr = third_level_list[0]
+    assert type(inner_value_repr) == str
+
+
+def test_databag_string_stripping(sentry_init, capture_events):
     sentry_init()
     events = capture_events()
 
-    @benchmark
-    def inner():
-        del events[:]
-        try:
-            a = "A" * 1000000  # noqa
-            1 / 0
-        except Exception:
-            capture_exception()
+    del events[:]
+    try:
+        a = "A" * DEFAULT_MAX_VALUE_LENGTH * 10  # noqa
+        1 / 0
+    except Exception:
+        capture_exception()
 
-        (event,) = events
+    (event,) = events
 
-        assert len(json.dumps(event)) < 10000
+    assert len(json.dumps(event)) < DEFAULT_MAX_VALUE_LENGTH * 10
 
 
-def test_databag_breadth_stripping(sentry_init, capture_events, benchmark):
+def test_databag_breadth_stripping(sentry_init, capture_events):
     sentry_init()
     events = capture_events()
 
-    @benchmark
-    def inner():
-        del events[:]
-        try:
-            a = ["a"] * 1000000  # noqa
-            1 / 0
-        except Exception:
-            capture_exception()
+    del events[:]
+    try:
+        a = ["a"] * 1000000  # noqa
+        1 / 0
+    except Exception:
+        capture_exception()
 
-        (event,) = events
+    (event,) = events
 
-        assert (
-            len(event["exception"]["values"][0]["stacktrace"]["frames"][0]["vars"]["a"])
-            == MAX_DATABAG_BREADTH
-        )
-        assert len(json.dumps(event)) < 10000
+    assert (
+        len(event["exception"]["values"][0]["stacktrace"]["frames"][0]["vars"]["a"])
+        == MAX_DATABAG_BREADTH
+    )
+    assert len(json.dumps(event)) < 10000
 
 
-@pytest.mark.skipif(not HAS_CHAINED_EXCEPTIONS, reason="Only works on 3.3+")
 def test_chained_exceptions(sentry_init, capture_events):
     sentry_init()
     events = capture_events()
@@ -899,7 +920,7 @@ def test_object_sends_exception(sentry_init, capture_events):
     sentry_init()
     events = capture_events()
 
-    class C(object):
+    class C:
         def __repr__(self):
             try:
                 1 / 0
@@ -936,6 +957,7 @@ class FooError(Exception):
     assert exception["mechanism"]["meta"]["errno"]["number"] == 69
 
 
+@maximum_python_312
 def test_non_string_variables(sentry_init, capture_events):
     """There is some extremely terrible code in the wild that
     inserts non-strings as variable names into `locals()`."""
@@ -967,7 +989,7 @@ def test_dict_changed_during_iteration(sentry_init, capture_events):
     sentry_init(send_default_pii=True)
     events = capture_events()
 
-    class TooSmartClass(object):
+    class TooSmartClass:
         def __init__(self, environ):
             self.environ = environ
 
@@ -991,6 +1013,39 @@ def __repr__(self):
     assert frame["vars"]["environ"] == {"a": ""}
 
 
+def test_custom_repr_on_vars(sentry_init, capture_events):
+    class Foo:
+        pass
+
+    class Fail:
+        pass
+
+    def custom_repr(value):
+        if isinstance(value, Foo):
+            return "custom repr"
+        elif isinstance(value, Fail):
+            raise ValueError("oops")
+        else:
+            return None
+
+    sentry_init(custom_repr=custom_repr)
+    events = capture_events()
+
+    try:
+        my_vars = {"foo": Foo(), "fail": Fail(), "normal": 42}
+        1 / 0
+    except ZeroDivisionError:
+        capture_exception()
+
+    (event,) = events
+    (exception,) = event["exception"]["values"]
+    (frame,) = exception["stacktrace"]["frames"]
+    my_vars = frame["vars"]["my_vars"]
+    assert my_vars["foo"] == "custom repr"
+    assert my_vars["normal"] == "42"
+    assert "Fail object" in my_vars["fail"]
+
+
 @pytest.mark.parametrize(
     "dsn",
     [
@@ -1006,96 +1061,11 @@ def test_init_string_types(dsn, sentry_init):
     # extra code
     sentry_init(dsn)
     assert (
-        Hub.current.client.dsn
+        sentry_sdk.get_client().dsn
         == "http://894b7d594095440f8dfea9b300e6f572@localhost:8000/2"
     )
 
 
-def test_sending_events_with_tracing():
-    """
-    Tests for calling the right transport method (capture_event vs
-    capture_envelope) from the SDK client for different data types.
-    """
-
-    envelopes = []
-    events = []
-
-    class CustomTransport(Transport):
-        def capture_envelope(self, envelope):
-            envelopes.append(envelope)
-
-        def capture_event(self, event):
-            events.append(event)
-
-    with Hub(Client(enable_tracing=True, transport=CustomTransport())):
-        try:
-            1 / 0
-        except Exception:
-            event_id = capture_exception()
-
-        # Assert error events get passed in via capture_envelope
-        assert not events
-        envelope = envelopes.pop()
-        (item,) = envelope.items
-        assert item.data_category == "error"
-        assert item.headers.get("type") == "event"
-        assert item.get_event()["event_id"] == event_id
-
-        with start_transaction(name="foo"):
-            pass
-
-        # Assert transactions get passed in via capture_envelope
-        assert not events
-        envelope = envelopes.pop()
-
-        (item,) = envelope.items
-        assert item.data_category == "transaction"
-        assert item.headers.get("type") == "transaction"
-
-    assert not envelopes
-    assert not events
-
-
-def test_sending_events_with_no_tracing():
-    """
-    Tests for calling the right transport method (capture_event vs
-    capture_envelope) from the SDK client for different data types.
-    """
-
-    envelopes = []
-    events = []
-
-    class CustomTransport(Transport):
-        def capture_envelope(self, envelope):
-            envelopes.append(envelope)
-
-        def capture_event(self, event):
-            events.append(event)
-
-    with Hub(Client(enable_tracing=False, transport=CustomTransport())):
-        try:
-            1 / 0
-        except Exception:
-            event_id = capture_exception()
-
-        # Assert error events get passed in via capture_event
-        assert not envelopes
-        event = events.pop()
-
-        assert event["event_id"] == event_id
-        assert "type" not in event
-
-        with start_transaction(name="foo"):
-            pass
-
-        # Assert transactions get passed in via capture_envelope
-        assert not events
-        assert not envelopes
-
-    assert not envelopes
-    assert not events
-
-
 @pytest.mark.parametrize(
     "sdk_options, expected_breadcrumbs",
     [({}, DEFAULT_MAX_BREADCRUMBS), ({"max_breadcrumbs": 50}, 50)],
@@ -1124,7 +1094,10 @@ def test_multiple_positional_args(sentry_init):
     "sdk_options, expected_data_length",
     [
         ({}, DEFAULT_MAX_VALUE_LENGTH),
-        ({"max_value_length": 1800}, 1800),
+        (
+            {"max_value_length": DEFAULT_MAX_VALUE_LENGTH + 1000},
+            DEFAULT_MAX_VALUE_LENGTH + 1000,
+        ),
     ],
 )
 def test_max_value_length_option(
@@ -1133,6 +1106,480 @@ def test_max_value_length_option(
     sentry_init(sdk_options)
     events = capture_events()
 
-    capture_message("a" * 2000)
+    capture_message("a" * (DEFAULT_MAX_VALUE_LENGTH + 2000))
 
     assert len(events[0]["message"]) == expected_data_length
+
+
+@pytest.mark.parametrize(
+    "client_option,env_var_value,debug_output_expected",
+    [
+        (None, "", False),
+        (None, "t", True),
+        (None, "1", True),
+        (None, "True", True),
+        (None, "true", True),
+        (None, "f", False),
+        (None, "0", False),
+        (None, "False", False),
+        (None, "false", False),
+        (None, "xxx", False),
+        (True, "", True),
+        (True, "t", True),
+        (True, "1", True),
+        (True, "True", True),
+        (True, "true", True),
+        (True, "f", True),
+        (True, "0", True),
+        (True, "False", True),
+        (True, "false", True),
+        (True, "xxx", True),
+        (False, "", False),
+        (False, "t", False),
+        (False, "1", False),
+        (False, "True", False),
+        (False, "true", False),
+        (False, "f", False),
+        (False, "0", False),
+        (False, "False", False),
+        (False, "false", False),
+        (False, "xxx", False),
+    ],
+)
+@pytest.mark.tests_internal_exceptions
+def test_debug_option(
+    sentry_init,
+    monkeypatch,
+    caplog,
+    client_option,
+    env_var_value,
+    debug_output_expected,
+):
+    monkeypatch.setenv("SENTRY_DEBUG", env_var_value)
+
+    if client_option is None:
+        sentry_init()
+    else:
+        sentry_init(debug=client_option)
+
+    capture_internal_exception((ValueError, ValueError("something is wrong"), None))
+    if debug_output_expected:
+        assert "something is wrong" in caplog.text
+    else:
+        assert "something is wrong" not in caplog.text
+
+
+@pytest.mark.parametrize(
+    "client_option,env_var_value,spotlight_url_expected",
+    [
+        (None, None, None),
+        (None, "", None),
+        (None, "F", None),
+        (False, None, None),
+        (False, "", None),
+        (False, "t", None),
+        (None, "t", DEFAULT_SPOTLIGHT_URL),
+        (None, "1", DEFAULT_SPOTLIGHT_URL),
+        (True, None, DEFAULT_SPOTLIGHT_URL),
+        # Per spec: spotlight=True + env URL -> use env URL
+        (True, "http://localhost:8080/slurp", "http://localhost:8080/slurp"),
+        ("http://localhost:8080/slurp", "f", "http://localhost:8080/slurp"),
+        (None, "http://localhost:8080/slurp", "http://localhost:8080/slurp"),
+    ],
+)
+def test_spotlight_option(
+    sentry_init,
+    monkeypatch,
+    client_option,
+    env_var_value,
+    spotlight_url_expected,
+):
+    if env_var_value is None:
+        monkeypatch.delenv("SENTRY_SPOTLIGHT", raising=False)
+    else:
+        monkeypatch.setenv("SENTRY_SPOTLIGHT", env_var_value)
+
+    if client_option is None:
+        sentry_init()
+    else:
+        sentry_init(spotlight=client_option)
+
+    client = sentry_sdk.get_client()
+    url = client.spotlight.url if client.spotlight else None
+    assert url == spotlight_url_expected, (
+        f"With config {client_option} and env {env_var_value}"
+    )
+
+
+class IssuesSamplerTestConfig:
+    def __init__(
+        self,
+        expected_events: int,
+        sampler_function: "Optional[Callable[[Event], Union[float, bool]]]" = None,
+        sample_rate: "Optional[float]" = None,
+        exception_to_raise: "type[Exception]" = Exception,
+    ) -> None:
+        self.sampler_function_mock = (
+            None
+            if sampler_function is None
+            else mock.MagicMock(side_effect=sampler_function)
+        )
+        self.expected_events = expected_events
+        self.sample_rate = sample_rate
+        self.exception_to_raise = exception_to_raise
+
+    def init_sdk(self, sentry_init: "Callable[[*Any], None]") -> None:
+        sentry_init(
+            error_sampler=self.sampler_function_mock, sample_rate=self.sample_rate
+        )
+
+    def raise_exception(self) -> None:
+        raise self.exception_to_raise()
+
+
+@mock.patch("sentry_sdk.client.random.random", return_value=0.618)
+@pytest.mark.parametrize(
+    "test_config",
+    (
+        # Baseline test with error_sampler only, both floats and bools
+        IssuesSamplerTestConfig(sampler_function=lambda *_: 1.0, expected_events=1),
+        IssuesSamplerTestConfig(sampler_function=lambda *_: 0.7, expected_events=1),
+        IssuesSamplerTestConfig(sampler_function=lambda *_: 0.6, expected_events=0),
+        IssuesSamplerTestConfig(sampler_function=lambda *_: 0.0, expected_events=0),
+        IssuesSamplerTestConfig(sampler_function=lambda *_: True, expected_events=1),
+        IssuesSamplerTestConfig(sampler_function=lambda *_: False, expected_events=0),
+        # Baseline test with sample_rate only
+        IssuesSamplerTestConfig(sample_rate=1.0, expected_events=1),
+        IssuesSamplerTestConfig(sample_rate=0.7, expected_events=1),
+        IssuesSamplerTestConfig(sample_rate=0.6, expected_events=0),
+        IssuesSamplerTestConfig(sample_rate=0.0, expected_events=0),
+        # error_sampler takes precedence over sample_rate
+        IssuesSamplerTestConfig(
+            sampler_function=lambda *_: 1.0, sample_rate=0.0, expected_events=1
+        ),
+        IssuesSamplerTestConfig(
+            sampler_function=lambda *_: 0.0, sample_rate=1.0, expected_events=0
+        ),
+        # Different sample rates based on exception, retrieved both from event and hint
+        IssuesSamplerTestConfig(
+            sampler_function=lambda event, _: {
+                "ZeroDivisionError": 1.0,
+                "AttributeError": 0.0,
+            }[event["exception"]["values"][0]["type"]],
+            exception_to_raise=ZeroDivisionError,
+            expected_events=1,
+        ),
+        IssuesSamplerTestConfig(
+            sampler_function=lambda event, _: {
+                "ZeroDivisionError": 1.0,
+                "AttributeError": 0.0,
+            }[event["exception"]["values"][0]["type"]],
+            exception_to_raise=AttributeError,
+            expected_events=0,
+        ),
+        IssuesSamplerTestConfig(
+            sampler_function=lambda _, hint: {
+                ZeroDivisionError: 1.0,
+                AttributeError: 0.0,
+            }[hint["exc_info"][0]],
+            exception_to_raise=ZeroDivisionError,
+            expected_events=1,
+        ),
+        IssuesSamplerTestConfig(
+            sampler_function=lambda _, hint: {
+                ZeroDivisionError: 1.0,
+                AttributeError: 0.0,
+            }[hint["exc_info"][0]],
+            exception_to_raise=AttributeError,
+            expected_events=0,
+        ),
+        # If sampler returns invalid value, we should still send the event
+        IssuesSamplerTestConfig(
+            sampler_function=lambda *_: "This is an invalid return value for the sampler",
+            expected_events=1,
+        ),
+    ),
+)
+def test_error_sampler(_, sentry_init, capture_events, test_config):
+    test_config.init_sdk(sentry_init)
+
+    events = capture_events()
+
+    try:
+        test_config.raise_exception()
+    except Exception:
+        capture_exception()
+
+    assert len(events) == test_config.expected_events
+
+    if test_config.sampler_function_mock is not None:
+        assert test_config.sampler_function_mock.call_count == 1
+
+        # Ensure two arguments (the event and hint) were passed to the sampler function
+        assert len(test_config.sampler_function_mock.call_args[0]) == 2
+
+
+@pytest.mark.parametrize(
+    "opt,missing_flags",
+    [
+        # lazy mode with enable-threads, no warning
+        [{"enable-threads": True, "lazy-apps": True}, []],
+        [{"enable-threads": "true", "lazy-apps": b"1"}, []],
+        # preforking mode with enable-threads and py-call-uwsgi-fork-hooks, no warning
+        [{"enable-threads": True, "py-call-uwsgi-fork-hooks": True}, []],
+        [{"enable-threads": b"true", "py-call-uwsgi-fork-hooks": b"on"}, []],
+        # lazy mode, no enable-threads, warning
+        [{"lazy-apps": True}, ["--enable-threads"]],
+        [{"enable-threads": b"false", "lazy-apps": True}, ["--enable-threads"]],
+        [{"enable-threads": b"0", "lazy": True}, ["--enable-threads"]],
+        # preforking mode, no enable-threads or py-call-uwsgi-fork-hooks, warning
+        [{}, ["--enable-threads", "--py-call-uwsgi-fork-hooks"]],
+        [{"processes": b"2"}, ["--enable-threads", "--py-call-uwsgi-fork-hooks"]],
+        [{"enable-threads": True}, ["--py-call-uwsgi-fork-hooks"]],
+        [{"enable-threads": b"1"}, ["--py-call-uwsgi-fork-hooks"]],
+        [
+            {"enable-threads": b"false"},
+            ["--enable-threads", "--py-call-uwsgi-fork-hooks"],
+        ],
+        [{"py-call-uwsgi-fork-hooks": True}, ["--enable-threads"]],
+    ],
+)
+def test_uwsgi_warnings(sentry_init, recwarn, opt, missing_flags):
+    uwsgi = mock.MagicMock()
+    uwsgi.opt = opt
+    with mock.patch.dict("sys.modules", uwsgi=uwsgi):
+        sentry_init(profiles_sample_rate=1.0)
+        if missing_flags:
+            assert len(recwarn) == 1
+            record = recwarn.pop()
+            for flag in missing_flags:
+                assert flag in str(record.message)
+        else:
+            assert not recwarn
+
+
+class TestSpanClientReports:
+    """
+    Tests for client reports related to spans.
+    """
+
+    __test__ = False
+
+    @staticmethod
+    def span_dropper(spans_to_drop):
+        """
+        Returns a function that can be used to drop spans from an event.
+        """
+
+        def drop_spans(event, _):
+            event["spans"] = event["spans"][spans_to_drop:]
+            return event
+
+        return drop_spans
+
+    @staticmethod
+    def mock_transaction_event(span_count):
+        """
+        Returns a mock transaction event with the given number of spans.
+        """
+
+        return defaultdict(
+            mock.MagicMock,
+            type="transaction",
+            spans=[mock.MagicMock() for _ in range(span_count)],
+        )
+
+    def __init__(self, span_count):
+        """Configures a test case with the number of spans dropped and whether the transaction was dropped."""
+        self.span_count = span_count
+        self.expected_record_lost_event_calls = Counter()
+        self.before_send = lambda event, _: event
+        self.event_processor = lambda event, _: event
+
+    def _update_resulting_calls(self, reason, drops_transactions=0, drops_spans=0):
+        """
+        Updates the expected calls with the given resulting calls.
+        """
+        if drops_transactions > 0:
+            self.expected_record_lost_event_calls[
+                (reason, "transaction", None, drops_transactions)
+            ] += 1
+
+        if drops_spans > 0:
+            self.expected_record_lost_event_calls[
+                (reason, "span", None, drops_spans)
+            ] += 1
+
+    def with_before_send(
+        self,
+        before_send,
+        *,
+        drops_transactions=0,
+        drops_spans=0,
+    ):
+        self.before_send = before_send
+        self._update_resulting_calls(
+            "before_send",
+            drops_transactions,
+            drops_spans,
+        )
+
+        return self
+
+    def with_event_processor(
+        self,
+        event_processor,
+        *,
+        drops_transactions=0,
+        drops_spans=0,
+    ):
+        self.event_processor = event_processor
+        self._update_resulting_calls(
+            "event_processor",
+            drops_transactions,
+            drops_spans,
+        )
+
+        return self
+
+    def run(self, sentry_init, capture_record_lost_event_calls):
+        """Runs the test case with the configured parameters."""
+        sentry_init(before_send_transaction=self.before_send)
+        record_lost_event_calls = capture_record_lost_event_calls()
+
+        with sentry_sdk.isolation_scope() as scope:
+            scope.add_event_processor(self.event_processor)
+            event = self.mock_transaction_event(self.span_count)
+            sentry_sdk.get_client().capture_event(event, scope=scope)
+
+        # We use counters to ensure that the calls are made the expected number of times, disregarding order.
+        assert Counter(record_lost_event_calls) == self.expected_record_lost_event_calls
+
+
+@pytest.mark.parametrize(
+    "test_config",
+    (
+        TestSpanClientReports(span_count=10),  # No spans dropped
+        TestSpanClientReports(span_count=0).with_before_send(
+            lambda e, _: None,
+            drops_transactions=1,
+            drops_spans=1,
+        ),
+        TestSpanClientReports(span_count=10).with_before_send(
+            lambda e, _: None,
+            drops_transactions=1,
+            drops_spans=11,
+        ),
+        TestSpanClientReports(span_count=10).with_before_send(
+            TestSpanClientReports.span_dropper(3),
+            drops_spans=3,
+        ),
+        TestSpanClientReports(span_count=10).with_before_send(
+            TestSpanClientReports.span_dropper(10),
+            drops_spans=10,
+        ),
+        TestSpanClientReports(span_count=10).with_event_processor(
+            lambda e, _: None,
+            drops_transactions=1,
+            drops_spans=11,
+        ),
+        TestSpanClientReports(span_count=10).with_event_processor(
+            TestSpanClientReports.span_dropper(3),
+            drops_spans=3,
+        ),
+        TestSpanClientReports(span_count=10).with_event_processor(
+            TestSpanClientReports.span_dropper(10),
+            drops_spans=10,
+        ),
+        TestSpanClientReports(span_count=10)
+        .with_event_processor(
+            TestSpanClientReports.span_dropper(3),
+            drops_spans=3,
+        )
+        .with_before_send(
+            TestSpanClientReports.span_dropper(5),
+            drops_spans=5,
+        ),
+        TestSpanClientReports(10)
+        .with_event_processor(
+            TestSpanClientReports.span_dropper(3),
+            drops_spans=3,
+        )
+        .with_before_send(
+            lambda e, _: None,
+            drops_transactions=1,
+            drops_spans=8,  # 3 of the 11 (incl. transaction) spans already dropped
+        ),
+    ),
+)
+def test_dropped_transaction(sentry_init, capture_record_lost_event_calls, test_config):
+    test_config.run(sentry_init, capture_record_lost_event_calls)
+
+
+@pytest.mark.parametrize("enable_tracing", [True, False])
+def test_enable_tracing_deprecated(sentry_init, enable_tracing):
+    with pytest.warns(DeprecationWarning):
+        sentry_init(enable_tracing=enable_tracing)
+
+
+def make_options_transport_cls():
+    """Make an options transport class that captures the options passed to it."""
+    # We need a unique class for each test so that the options are not
+    # shared between tests.
+
+    class OptionsTransport(Transport):
+        """Transport that captures the options passed to it."""
+
+        def __init__(self, options):
+            super().__init__(options)
+            type(self).options = options
+
+        def capture_envelope(self, _):
+            pass
+
+    return OptionsTransport
+
+
+@contextlib.contextmanager
+def clear_env_var(name):
+    """Helper to clear the a given environment variable,
+    and restore it to its original value on exit."""
+    old_value = os.environ.pop(name, None)
+
+    try:
+        yield
+    finally:
+        if old_value is not None:
+            os.environ[name] = old_value
+        elif name in os.environ:
+            del os.environ[name]
+
+
+@pytest.mark.parametrize(
+    ("env_value", "arg_value", "expected_value"),
+    [
+        (None, None, False),  # default
+        ("0", None, False),  # env var false
+        ("1", None, True),  # env var true
+        (None, False, False),  # arg false
+        (None, True, True),  # arg true
+        # Argument overrides environment variable
+        ("0", True, True),  # env false, arg true
+        ("1", False, False),  # env true, arg false
+    ],
+)
+def test_keep_alive(env_value, arg_value, expected_value):
+    transport_cls = make_options_transport_cls()
+    keep_alive_kwarg = {} if arg_value is None else {"keep_alive": arg_value}
+
+    with clear_env_var("SENTRY_KEEP_ALIVE"):
+        if env_value is not None:
+            os.environ["SENTRY_KEEP_ALIVE"] = env_value
+
+        sentry_sdk.init(
+            dsn="http://foo@sentry.io/123",
+            transport=transport_cls,
+            **keep_alive_kwarg,
+        )
+
+    assert transport_cls.options["keep_alive"] is expected_value
diff --git a/tests/test_conftest.py b/tests/test_conftest.py
index 1b006ed12e..a36fe17894 100644
--- a/tests/test_conftest.py
+++ b/tests/test_conftest.py
@@ -22,7 +22,9 @@
     ],
 )
 def test_string_containing(
-    test_string, expected_result, StringContaining  # noqa: N803
+    test_string,
+    expected_result,
+    StringContaining,  # noqa: N803
 ):
     assert (test_string == StringContaining("dogs")) is expected_result
 
@@ -46,14 +48,16 @@ def test_string_containing(
     ],
 )
 def test_dictionary_containing(
-    test_dict, expected_result, DictionaryContaining  # noqa: N803
+    test_dict,
+    expected_result,
+    DictionaryContaining,  # noqa: N803
 ):
     assert (
         test_dict == DictionaryContaining({"dogs": "yes", "cats": "maybe"})
     ) is expected_result
 
 
-class Animal(object):  # noqa: B903
+class Animal:  # noqa: B903
     def __init__(self, name=None, age=None, description=None):
         self.name = name
         self.age = age
diff --git a/tests/test_crons.py b/tests/test_crons.py
index 9ea98df2ac..493cc44272 100644
--- a/tests/test_crons.py
+++ b/tests/test_crons.py
@@ -1,13 +1,11 @@
-import pytest
 import uuid
+from unittest import mock
+
+import pytest
 
 import sentry_sdk
-from sentry_sdk.crons import capture_checkin
 
-try:
-    from unittest import mock  # python 3.3 and above
-except ImportError:
-    import mock  # python < 3.3
+from sentry_sdk.crons import capture_checkin
 
 
 @sentry_sdk.monitor(monitor_slug="abc123")
@@ -32,27 +30,73 @@ def _break_world_contextmanager(name):
         return "Hello, {}".format(name)
 
 
+@sentry_sdk.monitor(monitor_slug="abc123")
+async def _hello_world_async(name):
+    return "Hello, {}".format(name)
+
+
+@sentry_sdk.monitor(monitor_slug="def456")
+async def _break_world_async(name):
+    1 / 0
+    return "Hello, {}".format(name)
+
+
+async def my_coroutine():
+    return
+
+
+async def _hello_world_contextmanager_async(name):
+    with sentry_sdk.monitor(monitor_slug="abc123"):
+        await my_coroutine()
+        return "Hello, {}".format(name)
+
+
+async def _break_world_contextmanager_async(name):
+    with sentry_sdk.monitor(monitor_slug="def456"):
+        await my_coroutine()
+        1 / 0
+        return "Hello, {}".format(name)
+
+
+@sentry_sdk.monitor(monitor_slug="ghi789", monitor_config=None)
+def _no_monitor_config():
+    return
+
+
+@sentry_sdk.monitor(
+    monitor_slug="ghi789",
+    monitor_config={
+        "schedule": {"type": "crontab", "value": "0 0 * * *"},
+        "failure_issue_threshold": 5,
+    },
+)
+def _with_monitor_config():
+    return
+
+
 def test_decorator(sentry_init):
     sentry_init()
 
     with mock.patch(
         "sentry_sdk.crons.decorator.capture_checkin"
-    ) as fake_capture_checking:
+    ) as fake_capture_checkin:
         result = _hello_world("Grace")
         assert result == "Hello, Grace"
 
         # Check for initial checkin
-        fake_capture_checking.assert_has_calls(
+        fake_capture_checkin.assert_has_calls(
             [
-                mock.call(monitor_slug="abc123", status="in_progress"),
+                mock.call(
+                    monitor_slug="abc123", status="in_progress", monitor_config=None
+                ),
             ]
         )
 
         # Check for final checkin
-        assert fake_capture_checking.call_args[1]["monitor_slug"] == "abc123"
-        assert fake_capture_checking.call_args[1]["status"] == "ok"
-        assert fake_capture_checking.call_args[1]["duration"]
-        assert fake_capture_checking.call_args[1]["check_in_id"]
+        assert fake_capture_checkin.call_args[1]["monitor_slug"] == "abc123"
+        assert fake_capture_checkin.call_args[1]["status"] == "ok"
+        assert fake_capture_checkin.call_args[1]["duration"]
+        assert fake_capture_checkin.call_args[1]["check_in_id"]
 
 
 def test_decorator_error(sentry_init):
@@ -60,24 +104,26 @@ def test_decorator_error(sentry_init):
 
     with mock.patch(
         "sentry_sdk.crons.decorator.capture_checkin"
-    ) as fake_capture_checking:
+    ) as fake_capture_checkin:
         with pytest.raises(ZeroDivisionError):
             result = _break_world("Grace")
 
         assert "result" not in locals()
 
         # Check for initial checkin
-        fake_capture_checking.assert_has_calls(
+        fake_capture_checkin.assert_has_calls(
             [
-                mock.call(monitor_slug="def456", status="in_progress"),
+                mock.call(
+                    monitor_slug="def456", status="in_progress", monitor_config=None
+                ),
             ]
         )
 
         # Check for final checkin
-        assert fake_capture_checking.call_args[1]["monitor_slug"] == "def456"
-        assert fake_capture_checking.call_args[1]["status"] == "error"
-        assert fake_capture_checking.call_args[1]["duration"]
-        assert fake_capture_checking.call_args[1]["check_in_id"]
+        assert fake_capture_checkin.call_args[1]["monitor_slug"] == "def456"
+        assert fake_capture_checkin.call_args[1]["status"] == "error"
+        assert fake_capture_checkin.call_args[1]["duration"]
+        assert fake_capture_checkin.call_args[1]["check_in_id"]
 
 
 def test_contextmanager(sentry_init):
@@ -85,22 +131,24 @@ def test_contextmanager(sentry_init):
 
     with mock.patch(
         "sentry_sdk.crons.decorator.capture_checkin"
-    ) as fake_capture_checking:
+    ) as fake_capture_checkin:
         result = _hello_world_contextmanager("Grace")
         assert result == "Hello, Grace"
 
         # Check for initial checkin
-        fake_capture_checking.assert_has_calls(
+        fake_capture_checkin.assert_has_calls(
             [
-                mock.call(monitor_slug="abc123", status="in_progress"),
+                mock.call(
+                    monitor_slug="abc123", status="in_progress", monitor_config=None
+                ),
             ]
         )
 
         # Check for final checkin
-        assert fake_capture_checking.call_args[1]["monitor_slug"] == "abc123"
-        assert fake_capture_checking.call_args[1]["status"] == "ok"
-        assert fake_capture_checking.call_args[1]["duration"]
-        assert fake_capture_checking.call_args[1]["check_in_id"]
+        assert fake_capture_checkin.call_args[1]["monitor_slug"] == "abc123"
+        assert fake_capture_checkin.call_args[1]["status"] == "ok"
+        assert fake_capture_checkin.call_args[1]["duration"]
+        assert fake_capture_checkin.call_args[1]["check_in_id"]
 
 
 def test_contextmanager_error(sentry_init):
@@ -108,24 +156,26 @@ def test_contextmanager_error(sentry_init):
 
     with mock.patch(
         "sentry_sdk.crons.decorator.capture_checkin"
-    ) as fake_capture_checking:
+    ) as fake_capture_checkin:
         with pytest.raises(ZeroDivisionError):
             result = _break_world_contextmanager("Grace")
 
         assert "result" not in locals()
 
         # Check for initial checkin
-        fake_capture_checking.assert_has_calls(
+        fake_capture_checkin.assert_has_calls(
             [
-                mock.call(monitor_slug="def456", status="in_progress"),
+                mock.call(
+                    monitor_slug="def456", status="in_progress", monitor_config=None
+                ),
             ]
         )
 
         # Check for final checkin
-        assert fake_capture_checking.call_args[1]["monitor_slug"] == "def456"
-        assert fake_capture_checking.call_args[1]["status"] == "error"
-        assert fake_capture_checking.call_args[1]["duration"]
-        assert fake_capture_checking.call_args[1]["check_in_id"]
+        assert fake_capture_checkin.call_args[1]["monitor_slug"] == "def456"
+        assert fake_capture_checkin.call_args[1]["status"] == "error"
+        assert fake_capture_checkin.call_args[1]["duration"]
+        assert fake_capture_checkin.call_args[1]["check_in_id"]
 
 
 def test_capture_checkin_simple(sentry_init):
@@ -193,6 +243,8 @@ def test_monitor_config(sentry_init, capture_envelopes):
 
     monitor_config = {
         "schedule": {"type": "crontab", "value": "0 0 * * *"},
+        "failure_issue_threshold": 5,
+        "recovery_threshold": 5,
     }
 
     capture_checkin(monitor_slug="abc123", monitor_config=monitor_config)
@@ -210,6 +262,41 @@ def test_monitor_config(sentry_init, capture_envelopes):
     assert "monitor_config" not in check_in
 
 
+def test_decorator_monitor_config(sentry_init, capture_envelopes):
+    sentry_init()
+    envelopes = capture_envelopes()
+
+    _with_monitor_config()
+
+    assert len(envelopes) == 2
+
+    for check_in_envelope in envelopes:
+        assert len(check_in_envelope.items) == 1
+        check_in = check_in_envelope.items[0].payload.json
+
+        assert check_in["monitor_slug"] == "ghi789"
+        assert check_in["monitor_config"] == {
+            "schedule": {"type": "crontab", "value": "0 0 * * *"},
+            "failure_issue_threshold": 5,
+        }
+
+
+def test_decorator_no_monitor_config(sentry_init, capture_envelopes):
+    sentry_init()
+    envelopes = capture_envelopes()
+
+    _no_monitor_config()
+
+    assert len(envelopes) == 2
+
+    for check_in_envelope in envelopes:
+        assert len(check_in_envelope.items) == 1
+        check_in = check_in_envelope.items[0].payload.json
+
+        assert check_in["monitor_slug"] == "ghi789"
+        assert "monitor_config" not in check_in
+
+
 def test_capture_checkin_sdk_not_initialized():
     # Tests that the capture_checkin does not raise an error when Sentry SDK is not initialized.
     # sentry_init() is intentionally omitted.
@@ -220,3 +307,163 @@ def test_capture_checkin_sdk_not_initialized():
         duration=None,
     )
     assert check_in_id == "112233"
+
+
+def test_scope_data_in_checkin(sentry_init, capture_envelopes):
+    sentry_init()
+    envelopes = capture_envelopes()
+
+    valid_keys = [
+        # Mandatory event keys
+        "type",
+        "event_id",
+        "timestamp",
+        "platform",
+        # Optional event keys
+        "release",
+        "environment",
+        "server_name",
+        "sdk",
+        # Mandatory check-in specific keys
+        "check_in_id",
+        "monitor_slug",
+        "status",
+        # Optional check-in specific keys
+        "duration",
+        "monitor_config",
+        "contexts",  # an event processor adds this
+    ]
+
+    # Add some data to the scope
+    sentry_sdk.add_breadcrumb(message="test breadcrumb")
+    sentry_sdk.set_context("test_context", {"test_key": "test_value"})
+    sentry_sdk.set_extra("test_extra", "test_value")
+    sentry_sdk.set_level("warning")
+    sentry_sdk.set_tag("test_tag", "test_value")
+
+    capture_checkin(
+        monitor_slug="abc123",
+        check_in_id="112233",
+        status="ok",
+        duration=123,
+    )
+
+    (envelope,) = envelopes
+    check_in_event = envelope.items[0].payload.json
+
+    invalid_keys = []
+    for key in check_in_event.keys():
+        if key not in valid_keys:
+            invalid_keys.append(key)
+
+    assert len(invalid_keys) == 0, "Unexpected keys found in checkin: {}".format(
+        invalid_keys
+    )
+
+
+@pytest.mark.asyncio
+async def test_decorator_async(sentry_init):
+    sentry_init()
+
+    with mock.patch(
+        "sentry_sdk.crons.decorator.capture_checkin"
+    ) as fake_capture_checkin:
+        result = await _hello_world_async("Grace")
+        assert result == "Hello, Grace"
+
+        # Check for initial checkin
+        fake_capture_checkin.assert_has_calls(
+            [
+                mock.call(
+                    monitor_slug="abc123", status="in_progress", monitor_config=None
+                ),
+            ]
+        )
+
+        # Check for final checkin
+        assert fake_capture_checkin.call_args[1]["monitor_slug"] == "abc123"
+        assert fake_capture_checkin.call_args[1]["status"] == "ok"
+        assert fake_capture_checkin.call_args[1]["duration"]
+        assert fake_capture_checkin.call_args[1]["check_in_id"]
+
+
+@pytest.mark.asyncio
+async def test_decorator_error_async(sentry_init):
+    sentry_init()
+
+    with mock.patch(
+        "sentry_sdk.crons.decorator.capture_checkin"
+    ) as fake_capture_checkin:
+        with pytest.raises(ZeroDivisionError):
+            result = await _break_world_async("Grace")
+
+        assert "result" not in locals()
+
+        # Check for initial checkin
+        fake_capture_checkin.assert_has_calls(
+            [
+                mock.call(
+                    monitor_slug="def456", status="in_progress", monitor_config=None
+                ),
+            ]
+        )
+
+        # Check for final checkin
+        assert fake_capture_checkin.call_args[1]["monitor_slug"] == "def456"
+        assert fake_capture_checkin.call_args[1]["status"] == "error"
+        assert fake_capture_checkin.call_args[1]["duration"]
+        assert fake_capture_checkin.call_args[1]["check_in_id"]
+
+
+@pytest.mark.asyncio
+async def test_contextmanager_async(sentry_init):
+    sentry_init()
+
+    with mock.patch(
+        "sentry_sdk.crons.decorator.capture_checkin"
+    ) as fake_capture_checkin:
+        result = await _hello_world_contextmanager_async("Grace")
+        assert result == "Hello, Grace"
+
+        # Check for initial checkin
+        fake_capture_checkin.assert_has_calls(
+            [
+                mock.call(
+                    monitor_slug="abc123", status="in_progress", monitor_config=None
+                ),
+            ]
+        )
+
+        # Check for final checkin
+        assert fake_capture_checkin.call_args[1]["monitor_slug"] == "abc123"
+        assert fake_capture_checkin.call_args[1]["status"] == "ok"
+        assert fake_capture_checkin.call_args[1]["duration"]
+        assert fake_capture_checkin.call_args[1]["check_in_id"]
+
+
+@pytest.mark.asyncio
+async def test_contextmanager_error_async(sentry_init):
+    sentry_init()
+
+    with mock.patch(
+        "sentry_sdk.crons.decorator.capture_checkin"
+    ) as fake_capture_checkin:
+        with pytest.raises(ZeroDivisionError):
+            result = await _break_world_contextmanager_async("Grace")
+
+        assert "result" not in locals()
+
+        # Check for initial checkin
+        fake_capture_checkin.assert_has_calls(
+            [
+                mock.call(
+                    monitor_slug="def456", status="in_progress", monitor_config=None
+                ),
+            ]
+        )
+
+        # Check for final checkin
+        assert fake_capture_checkin.call_args[1]["monitor_slug"] == "def456"
+        assert fake_capture_checkin.call_args[1]["status"] == "error"
+        assert fake_capture_checkin.call_args[1]["duration"]
+        assert fake_capture_checkin.call_args[1]["check_in_id"]
diff --git a/tests/test_dsc.py b/tests/test_dsc.py
new file mode 100644
index 0000000000..c233fa0c5b
--- /dev/null
+++ b/tests/test_dsc.py
@@ -0,0 +1,449 @@
+"""
+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.
+"""
+
+from unittest import mock
+
+import pytest
+
+import sentry_sdk
+from tests.conftest import TestTransportWithOptions
+
+
+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@o1234.ingest.sentry.io/12312012",
+        release="myapp@0.0.1",
+        environment="canary",
+        traces_sample_rate=1.0,
+        transport=TestTransportWithOptions,
+    )
+    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 "org_id" in envelope_trace_header
+    assert type(envelope_trace_header["org_id"]) == str
+    assert envelope_trace_header["org_id"] == "1234"
+
+    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_head_of_trace_uses_custom_org_id(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@o1234.ingest.sentry.io/12312012",
+        org_id="9999",
+        release="myapp@0.0.1",
+        environment="canary",
+        traces_sample_rate=1.0,
+        transport=TestTransportWithOptions,
+    )
+    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 "org_id" in envelope_trace_header
+    assert type(envelope_trace_header["org_id"]) == str
+    assert envelope_trace_header["org_id"] == "9999"
+
+
+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@o1234.ingest.sentry.io/12312012",
+        release="myapp@0.0.1",
+        environment="canary",
+        traces_sample_rate=1.0,
+        transport=TestTransportWithOptions,
+    )
+    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"] == "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"] == "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_continuation_of_trace_sample_rate_changed_in_traces_sampler(
+    sentry_init, capture_envelopes
+):
+    """
+    Another service calls our service and passes tracing information to us.
+    Our service is continuing the trace, but modifies the sample rate.
+    The DSC propagated further should contain the updated sample rate.
+    """
+
+    def my_traces_sampler(sampling_context):
+        return 0.25
+
+    sentry_init(
+        dsn="https://mysecret@o1234.ingest.sentry.io/12312012",
+        release="myapp@0.0.1",
+        environment="canary",
+        traces_sampler=my_traces_sampler,
+        transport=TestTransportWithOptions,
+    )
+    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=1.0, "
+        "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
+    with mock.patch("sentry_sdk.tracing_utils.Random.randrange", return_value=125000):
+        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.25"
+
+    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@o1234.ingest.sentry.io/12312012",
+        release="myapp@0.0.1",
+        environment="canary",
+        transport=TestTransportWithOptions,
+    )
+    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 "org_id" in envelope_trace_header
+    assert type(envelope_trace_header["org_id"]) == str
+    assert envelope_trace_header["org_id"] == "1234"
+
+    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@o1234.ingest.sentry.io/12312012",
+        release="myapp@0.0.1",
+        environment="canary",
+        traces_sample_rate=1.0,
+        transport=TestTransportWithOptions,
+    )
+    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 "org_id" in envelope_trace_header
+    assert type(envelope_trace_header["org_id"]) == str
+    assert envelope_trace_header["org_id"] == "1234"
+
+    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@o1234.ingest.sentry.io/12312012",
+        release="myapp@0.0.1",
+        environment="canary",
+        traces_sample_rate=traces_sample_rate,
+        transport=TestTransportWithOptions,
+    )
+    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"
diff --git a/tests/test_envelope.py b/tests/test_envelope.py
index a8b3ac11f4..d66cd9460a 100644
--- a/tests/test_envelope.py
+++ b/tests/test_envelope.py
@@ -1,4 +1,4 @@
-from sentry_sdk.envelope import Envelope
+from sentry_sdk.envelope import Envelope, Item, PayloadRef
 from sentry_sdk.session import Session
 from sentry_sdk import capture_event
 import sentry_sdk.client
@@ -24,7 +24,6 @@ def generate_transaction_item():
                     "environment": "dogpark",
                     "release": "off.leash.park",
                     "public_key": "dogsarebadatkeepingsecrets",
-                    "user_segment": "bigs",
                     "transaction": "/interactions/other-dogs/new-dog",
                 },
             }
@@ -105,7 +104,6 @@ def test_envelope_headers(sentry_init, capture_envelopes, monkeypatch):
             "environment": "dogpark",
             "release": "off.leash.park",
             "public_key": "dogsarebadatkeepingsecrets",
-            "user_segment": "bigs",
             "transaction": "/interactions/other-dogs/new-dog",
         },
     }
@@ -241,3 +239,23 @@ def test_envelope_without_headers():
 
     assert len(items) == 1
     assert items[0].payload.get_bytes() == b'{"started": "2020-02-07T14:16:00Z"}'
+
+
+def test_envelope_item_data_category_mapping():
+    """Test that envelope items map to correct data categories for rate limiting."""
+    test_cases = [
+        ("event", "error"),
+        ("transaction", "transaction"),
+        ("log", "log_item"),
+        ("session", "session"),
+        ("attachment", "attachment"),
+        ("client_report", "internal"),
+        ("profile", "profile"),
+        ("profile_chunk", "profile_chunk"),
+        ("check_in", "monitor"),
+        ("unknown_type", "default"),
+    ]
+
+    for item_type, expected_category in test_cases:
+        item = Item(payload=PayloadRef(json={"test": "data"}), type=item_type)
+        assert item.data_category == expected_category
diff --git a/tests/test_feature_flags.py b/tests/test_feature_flags.py
new file mode 100644
index 0000000000..e0ab1e254e
--- /dev/null
+++ b/tests/test_feature_flags.py
@@ -0,0 +1,318 @@
+import concurrent.futures as cf
+import sys
+import copy
+import threading
+
+import pytest
+
+import sentry_sdk
+from sentry_sdk.feature_flags import add_feature_flag, FlagBuffer
+from sentry_sdk import start_span, start_transaction
+from tests.conftest import ApproxDict
+
+
+def test_featureflags_integration(sentry_init, capture_events, uninstall_integration):
+    sentry_init()
+
+    add_feature_flag("hello", False)
+    add_feature_flag("world", True)
+    add_feature_flag("other", False)
+
+    events = capture_events()
+    sentry_sdk.capture_exception(Exception("something wrong!"))
+
+    assert len(events) == 1
+    assert events[0]["contexts"]["flags"] == {
+        "values": [
+            {"flag": "hello", "result": False},
+            {"flag": "world", "result": True},
+            {"flag": "other", "result": False},
+        ]
+    }
+
+
+@pytest.mark.asyncio
+async def test_featureflags_integration_spans_async(sentry_init, capture_events):
+    sentry_init(
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    add_feature_flag("hello", False)
+
+    try:
+        with sentry_sdk.start_span(name="test-span"):
+            with sentry_sdk.start_span(name="test-span-2"):
+                raise ValueError("something wrong!")
+    except ValueError as e:
+        sentry_sdk.capture_exception(e)
+
+    found = False
+    for event in events:
+        if "exception" in event.keys():
+            assert event["contexts"]["flags"] == {
+                "values": [
+                    {"flag": "hello", "result": False},
+                ]
+            }
+            found = True
+
+    assert found, "No event with exception found"
+
+
+def test_featureflags_integration_spans_sync(sentry_init, capture_events):
+    sentry_init(
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    add_feature_flag("hello", False)
+
+    try:
+        with sentry_sdk.start_span(name="test-span"):
+            with sentry_sdk.start_span(name="test-span-2"):
+                raise ValueError("something wrong!")
+    except ValueError as e:
+        sentry_sdk.capture_exception(e)
+
+    found = False
+    for event in events:
+        if "exception" in event.keys():
+            assert event["contexts"]["flags"] == {
+                "values": [
+                    {"flag": "hello", "result": False},
+                ]
+            }
+            found = True
+
+    assert found, "No event with exception found"
+
+
+def test_featureflags_integration_threaded(
+    sentry_init, capture_events, uninstall_integration
+):
+    sentry_init()
+    events = capture_events()
+
+    # Capture an eval before we split isolation scopes.
+    add_feature_flag("hello", False)
+
+    def task(flag_key):
+        # Creates a new isolation scope for the thread.
+        # This means the evaluations in each task are captured separately.
+        with sentry_sdk.isolation_scope():
+            add_feature_flag(flag_key, False)
+            # use a tag to identify to identify events later on
+            sentry_sdk.set_tag("task_id", flag_key)
+            sentry_sdk.capture_exception(Exception("something wrong!"))
+
+    # Run tasks in separate threads
+    with cf.ThreadPoolExecutor(max_workers=2) as pool:
+        pool.map(task, ["world", "other"])
+
+    # Capture error in original scope
+    sentry_sdk.set_tag("task_id", "0")
+    sentry_sdk.capture_exception(Exception("something wrong!"))
+
+    assert len(events) == 3
+    events.sort(key=lambda e: e["tags"]["task_id"])
+
+    assert events[0]["contexts"]["flags"] == {
+        "values": [
+            {"flag": "hello", "result": False},
+        ]
+    }
+    assert events[1]["contexts"]["flags"] == {
+        "values": [
+            {"flag": "hello", "result": False},
+            {"flag": "other", "result": False},
+        ]
+    }
+    assert events[2]["contexts"]["flags"] == {
+        "values": [
+            {"flag": "hello", "result": False},
+            {"flag": "world", "result": False},
+        ]
+    }
+
+
+@pytest.mark.skipif(sys.version_info < (3, 7), reason="requires python3.7 or higher")
+def test_featureflags_integration_asyncio(
+    sentry_init, capture_events, uninstall_integration
+):
+    asyncio = pytest.importorskip("asyncio")
+
+    sentry_init()
+    events = capture_events()
+
+    # Capture an eval before we split isolation scopes.
+    add_feature_flag("hello", False)
+
+    async def task(flag_key):
+        # Creates a new isolation scope for the thread.
+        # This means the evaluations in each task are captured separately.
+        with sentry_sdk.isolation_scope():
+            add_feature_flag(flag_key, False)
+            # use a tag to identify to identify events later on
+            sentry_sdk.set_tag("task_id", flag_key)
+            sentry_sdk.capture_exception(Exception("something wrong!"))
+
+    async def runner():
+        return asyncio.gather(task("world"), task("other"))
+
+    asyncio.run(runner())
+
+    # Capture error in original scope
+    sentry_sdk.set_tag("task_id", "0")
+    sentry_sdk.capture_exception(Exception("something wrong!"))
+
+    assert len(events) == 3
+    events.sort(key=lambda e: e["tags"]["task_id"])
+
+    assert events[0]["contexts"]["flags"] == {
+        "values": [
+            {"flag": "hello", "result": False},
+        ]
+    }
+    assert events[1]["contexts"]["flags"] == {
+        "values": [
+            {"flag": "hello", "result": False},
+            {"flag": "other", "result": False},
+        ]
+    }
+    assert events[2]["contexts"]["flags"] == {
+        "values": [
+            {"flag": "hello", "result": False},
+            {"flag": "world", "result": False},
+        ]
+    }
+
+
+def test_flag_tracking():
+    """Assert the ring buffer works."""
+    buffer = FlagBuffer(capacity=3)
+    buffer.set("a", True)
+    flags = buffer.get()
+    assert len(flags) == 1
+    assert flags == [{"flag": "a", "result": True}]
+
+    buffer.set("b", True)
+    flags = buffer.get()
+    assert len(flags) == 2
+    assert flags == [{"flag": "a", "result": True}, {"flag": "b", "result": True}]
+
+    buffer.set("c", True)
+    flags = buffer.get()
+    assert len(flags) == 3
+    assert flags == [
+        {"flag": "a", "result": True},
+        {"flag": "b", "result": True},
+        {"flag": "c", "result": True},
+    ]
+
+    buffer.set("d", False)
+    flags = buffer.get()
+    assert len(flags) == 3
+    assert flags == [
+        {"flag": "b", "result": True},
+        {"flag": "c", "result": True},
+        {"flag": "d", "result": False},
+    ]
+
+    buffer.set("e", False)
+    buffer.set("f", False)
+    flags = buffer.get()
+    assert len(flags) == 3
+    assert flags == [
+        {"flag": "d", "result": False},
+        {"flag": "e", "result": False},
+        {"flag": "f", "result": False},
+    ]
+
+    # Test updates
+    buffer.set("e", True)
+    buffer.set("e", False)
+    buffer.set("e", True)
+    flags = buffer.get()
+    assert flags == [
+        {"flag": "d", "result": False},
+        {"flag": "f", "result": False},
+        {"flag": "e", "result": True},
+    ]
+
+    buffer.set("d", True)
+    flags = buffer.get()
+    assert flags == [
+        {"flag": "f", "result": False},
+        {"flag": "e", "result": True},
+        {"flag": "d", "result": True},
+    ]
+
+
+def test_flag_buffer_concurrent_access():
+    buffer = FlagBuffer(capacity=100)
+    error_occurred = False
+
+    def writer():
+        for i in range(1_000_000):
+            buffer.set(f"key_{i}", True)
+
+    def reader():
+        nonlocal error_occurred
+
+        try:
+            for _ in range(1000):
+                copy.deepcopy(buffer)
+        except RuntimeError:
+            error_occurred = True
+
+    writer_thread = threading.Thread(target=writer)
+    reader_thread = threading.Thread(target=reader)
+
+    writer_thread.start()
+    reader_thread.start()
+
+    writer_thread.join(timeout=5)
+    reader_thread.join(timeout=5)
+
+    # This should always be false. If this ever fails we know we have concurrent access to a
+    # shared resource. When deepcopying we should have exclusive access to the underlying
+    # memory.
+    assert error_occurred is False
+
+
+def test_flag_limit(sentry_init, capture_events):
+    sentry_init(traces_sample_rate=1.0)
+
+    events = capture_events()
+
+    with start_transaction(name="hi"):
+        with start_span(op="foo", name="bar"):
+            add_feature_flag("0", True)
+            add_feature_flag("1", True)
+            add_feature_flag("2", True)
+            add_feature_flag("3", True)
+            add_feature_flag("4", True)
+            add_feature_flag("5", True)
+            add_feature_flag("6", True)
+            add_feature_flag("7", True)
+            add_feature_flag("8", True)
+            add_feature_flag("9", True)
+            add_feature_flag("10", True)
+
+    (event,) = events
+    assert event["spans"][0]["data"] == ApproxDict(
+        {
+            "flag.evaluation.0": True,
+            "flag.evaluation.1": True,
+            "flag.evaluation.2": True,
+            "flag.evaluation.3": True,
+            "flag.evaluation.4": True,
+            "flag.evaluation.5": True,
+            "flag.evaluation.6": True,
+            "flag.evaluation.7": True,
+            "flag.evaluation.8": True,
+            "flag.evaluation.9": True,
+        }
+    )
+    assert "flag.evaluation.10" not in event["spans"][0]["data"]
diff --git a/tests/test_full_stack_frames.py b/tests/test_full_stack_frames.py
new file mode 100644
index 0000000000..ad0826cd10
--- /dev/null
+++ b/tests/test_full_stack_frames.py
@@ -0,0 +1,103 @@
+import sentry_sdk
+
+
+def test_full_stack_frames_default(sentry_init, capture_events):
+    sentry_init()
+    events = capture_events()
+
+    def foo():
+        try:
+            bar()
+        except Exception as e:
+            sentry_sdk.capture_exception(e)
+
+    def bar():
+        raise Exception("This is a test exception")
+
+    foo()
+
+    (event,) = events
+    frames = event["exception"]["values"][0]["stacktrace"]["frames"]
+
+    assert len(frames) == 2
+    assert frames[-1]["function"] == "bar"
+    assert frames[-2]["function"] == "foo"
+
+
+def test_full_stack_frames_enabled(sentry_init, capture_events):
+    sentry_init(
+        add_full_stack=True,
+    )
+    events = capture_events()
+
+    def foo():
+        try:
+            bar()
+        except Exception as e:
+            sentry_sdk.capture_exception(e)
+
+    def bar():
+        raise Exception("This is a test exception")
+
+    foo()
+
+    (event,) = events
+    frames = event["exception"]["values"][0]["stacktrace"]["frames"]
+
+    assert len(frames) > 2
+    assert frames[-1]["function"] == "bar"
+    assert frames[-2]["function"] == "foo"
+    assert frames[-3]["function"] == "foo"
+    assert frames[-4]["function"] == "test_full_stack_frames_enabled"
+
+
+def test_full_stack_frames_enabled_truncated(sentry_init, capture_events):
+    sentry_init(
+        add_full_stack=True,
+        max_stack_frames=3,
+    )
+    events = capture_events()
+
+    def foo():
+        try:
+            bar()
+        except Exception as e:
+            sentry_sdk.capture_exception(e)
+
+    def bar():
+        raise Exception("This is a test exception")
+
+    foo()
+
+    (event,) = events
+    frames = event["exception"]["values"][0]["stacktrace"]["frames"]
+
+    assert len(frames) == 3
+    assert frames[-1]["function"] == "bar"
+    assert frames[-2]["function"] == "foo"
+    assert frames[-3]["function"] == "foo"
+
+
+def test_full_stack_frames_default_no_truncation_happening(sentry_init, capture_events):
+    sentry_init(
+        max_stack_frames=1,  # this is ignored if add_full_stack=False (which is the default)
+    )
+    events = capture_events()
+
+    def foo():
+        try:
+            bar()
+        except Exception as e:
+            sentry_sdk.capture_exception(e)
+
+    def bar():
+        raise Exception("This is a test exception")
+
+    foo()
+
+    (event,) = events
+    frames = event["exception"]["values"][0]["stacktrace"]["frames"]
+
+    assert len(frames) == 2
+    assert frames[-1]["function"] == "bar"
+    assert frames[-2]["function"] == "foo"
diff --git a/tests/test_gevent.py b/tests/test_gevent.py
new file mode 100644
index 0000000000..05fa6ed2e8
--- /dev/null
+++ b/tests/test_gevent.py
@@ -0,0 +1,116 @@
+import logging
+import pickle
+from datetime import datetime, timezone
+
+import sentry_sdk
+from sentry_sdk._compat import PY37, PY38
+
+import pytest
+from tests.conftest import CapturingServer
+
+pytest.importorskip("gevent")
+
+
+@pytest.fixture(scope="module")
+def monkeypatched_gevent():
+    try:
+        import gevent
+
+        gevent.monkey.patch_all()
+    except Exception as e:
+        if "_RLock__owner" in str(e):
+            pytest.skip("https://github.com/gevent/gevent/issues/1380")
+        else:
+            raise
+
+
+@pytest.fixture
+def capturing_server(request):
+    server = CapturingServer()
+    server.start()
+    request.addfinalizer(server.stop)
+    return server
+
+
+@pytest.fixture
+def make_client(request, capturing_server):
+    def inner(**kwargs):
+        return sentry_sdk.Client(
+            "http://foobar@{}/132".format(capturing_server.url[len("http://") :]),
+            **kwargs,
+        )
+
+    return inner
+
+
+@pytest.mark.forked
+@pytest.mark.parametrize("debug", (True, False))
+@pytest.mark.parametrize("client_flush_method", ["close", "flush"])
+@pytest.mark.parametrize("use_pickle", (True, False))
+@pytest.mark.parametrize("compression_level", (0, 9, None))
+@pytest.mark.parametrize(
+    "compression_algo",
+    (("gzip", "br", "", None) if PY37 else ("gzip", "", None)),
+)
+@pytest.mark.parametrize("http2", [True, False] if PY38 else [False])
+def test_transport_works_gevent(
+    capturing_server,
+    request,
+    capsys,
+    caplog,
+    debug,
+    make_client,
+    client_flush_method,
+    use_pickle,
+    compression_level,
+    compression_algo,
+    http2,
+):
+    caplog.set_level(logging.DEBUG)
+
+    experiments = {}
+    if compression_level is not None:
+        experiments["transport_compression_level"] = compression_level
+
+    if compression_algo is not None:
+        experiments["transport_compression_algo"] = compression_algo
+
+    if http2:
+        experiments["transport_http2"] = True
+
+    client = make_client(
+        debug=debug,
+        _experiments=experiments,
+    )
+
+    if use_pickle:
+        client = pickle.loads(pickle.dumps(client))
+
+    sentry_sdk.get_global_scope().set_client(client)
+    request.addfinalizer(lambda: sentry_sdk.get_global_scope().set_client(None))
+
+    sentry_sdk.add_breadcrumb(
+        level="info", message="i like bread", timestamp=datetime.now(timezone.utc)
+    )
+    sentry_sdk.capture_message("löl")
+
+    getattr(client, client_flush_method)()
+
+    out, err = capsys.readouterr()
+    assert not err and not out
+    assert capturing_server.captured
+    should_compress = (
+        # default is to compress with brotli if available, gzip otherwise
+        (compression_level is None)
+        or (
+            # setting compression level to 0 means don't compress
+            compression_level > 0
+        )
+    ) and (
+        # if we couldn't resolve to a known algo, we don't compress
+        compression_algo != ""
+    )
+
+    assert capturing_server.captured[0].compressed == should_compress
+
+    assert any("Sending envelope" in record.msg for record in caplog.records) == debug
diff --git a/tests/test_import.py b/tests/test_import.py
new file mode 100644
index 0000000000..e5b07817cb
--- /dev/null
+++ b/tests/test_import.py
@@ -0,0 +1,7 @@
+# As long as this file can be imported, we are good.
+from sentry_sdk import *  # noqa: F403, F401
+
+
+def test_import():
+    # As long as this file can be imported, we are good.
+    assert True
diff --git a/tests/test_logs.py b/tests/test_logs.py
new file mode 100644
index 0000000000..11c7b8e4d5
--- /dev/null
+++ b/tests/test_logs.py
@@ -0,0 +1,635 @@
+import json
+import logging
+import sys
+import time
+from typing import List, Any, Mapping, Union
+import pytest
+from unittest import mock
+
+import sentry_sdk
+import sentry_sdk.logger
+from sentry_sdk import get_client
+from sentry_sdk.envelope import Envelope, Item, PayloadRef
+from sentry_sdk.types import Log
+from sentry_sdk.consts import SPANDATA, VERSION
+
+minimum_python_37 = pytest.mark.skipif(
+    sys.version_info < (3, 7), reason="Asyncio tests need Python >= 3.7"
+)
+
+
+def otel_attributes_to_dict(otel_attrs: "Mapping[str, Any]") -> "Mapping[str, Any]":
+    def _convert_attr(attr):
+        # type: (Mapping[str, Union[str, float, bool]]) -> Any
+        if attr["type"] == "boolean":
+            return attr["value"]
+        if attr["type"] == "double":
+            return attr["value"]
+        if attr["type"] == "integer":
+            return attr["value"]
+        if attr["value"].startswith("{"):
+            try:
+                return json.loads(attr["value"])
+            except ValueError:
+                pass
+        return str(attr["value"])
+
+    return {k: _convert_attr(v) for (k, v) in otel_attrs.items()}
+
+
+def envelopes_to_logs(envelopes: List[Envelope]) -> List[Log]:
+    res: "List[Log]" = []
+    for envelope in envelopes:
+        for item in envelope.items:
+            if item.type == "log":
+                for log_json in item.payload.json["items"]:
+                    log: "Log" = {
+                        "severity_text": log_json["attributes"]["sentry.severity_text"][
+                            "value"
+                        ],
+                        "severity_number": int(
+                            log_json["attributes"]["sentry.severity_number"]["value"]
+                        ),
+                        "body": log_json["body"],
+                        "attributes": otel_attributes_to_dict(log_json["attributes"]),
+                        "time_unix_nano": int(float(log_json["timestamp"]) * 1e9),
+                        "trace_id": log_json["trace_id"],
+                        "span_id": log_json["span_id"],
+                    }
+                    res.append(log)
+    return res
+
+
+@minimum_python_37
+def test_logs_disabled_by_default(sentry_init, capture_envelopes):
+    sentry_init()
+
+    python_logger = logging.Logger("some-logger")
+
+    envelopes = capture_envelopes()
+
+    sentry_sdk.logger.trace("This is a 'trace' log.")
+    sentry_sdk.logger.debug("This is a 'debug' log...")
+    sentry_sdk.logger.info("This is a 'info' log...")
+    sentry_sdk.logger.warning("This is a 'warning' log...")
+    sentry_sdk.logger.error("This is a 'error' log...")
+    sentry_sdk.logger.fatal("This is a 'fatal' log...")
+    python_logger.warning("sad")
+
+    assert len(envelopes) == 0
+
+
+@minimum_python_37
+def test_logs_basics(sentry_init, capture_envelopes):
+    sentry_init(enable_logs=True)
+    envelopes = capture_envelopes()
+
+    sentry_sdk.logger.trace("This is a 'trace' log...")
+    sentry_sdk.logger.debug("This is a 'debug' log...")
+    sentry_sdk.logger.info("This is a 'info' log...")
+    sentry_sdk.logger.warning("This is a 'warn' log...")
+    sentry_sdk.logger.error("This is a 'error' log...")
+    sentry_sdk.logger.fatal("This is a 'fatal' log...")
+
+    get_client().flush()
+    logs = envelopes_to_logs(envelopes)
+    assert logs[0].get("severity_text") == "trace"
+    assert logs[0].get("severity_number") == 1
+
+    assert logs[1].get("severity_text") == "debug"
+    assert logs[1].get("severity_number") == 5
+
+    assert logs[2].get("severity_text") == "info"
+    assert logs[2].get("severity_number") == 9
+
+    assert logs[3].get("severity_text") == "warn"
+    assert logs[3].get("severity_number") == 13
+
+    assert logs[4].get("severity_text") == "error"
+    assert logs[4].get("severity_number") == 17
+
+    assert logs[5].get("severity_text") == "fatal"
+    assert logs[5].get("severity_number") == 21
+
+
+@minimum_python_37
+def test_logs_experimental_option_still_works(sentry_init, capture_envelopes):
+    sentry_init(_experiments={"enable_logs": True})
+    envelopes = capture_envelopes()
+
+    sentry_sdk.logger.error("This is an error log...")
+
+    get_client().flush()
+
+    logs = envelopes_to_logs(envelopes)
+    assert len(logs) == 1
+
+    assert logs[0].get("severity_text") == "error"
+    assert logs[0].get("severity_number") == 17
+
+
+@minimum_python_37
+def test_logs_before_send_log(sentry_init, capture_envelopes):
+    before_log_called = False
+
+    def _before_log(record, hint):
+        nonlocal before_log_called
+
+        assert set(record.keys()) == {
+            "severity_text",
+            "severity_number",
+            "body",
+            "attributes",
+            "time_unix_nano",
+            "trace_id",
+            "span_id",
+        }
+
+        if record["severity_text"] in ["fatal", "error"]:
+            return None
+
+        before_log_called = True
+
+        return record
+
+    sentry_init(
+        enable_logs=True,
+        before_send_log=_before_log,
+    )
+    envelopes = capture_envelopes()
+
+    sentry_sdk.logger.trace("This is a 'trace' log...")
+    sentry_sdk.logger.debug("This is a 'debug' log...")
+    sentry_sdk.logger.info("This is a 'info' log...")
+    sentry_sdk.logger.warning("This is a 'warning' log...")
+    sentry_sdk.logger.error("This is a 'error' log...")
+    sentry_sdk.logger.fatal("This is a 'fatal' log...")
+
+    get_client().flush()
+    logs = envelopes_to_logs(envelopes)
+    assert len(logs) == 4
+
+    assert logs[0]["severity_text"] == "trace"
+    assert logs[1]["severity_text"] == "debug"
+    assert logs[2]["severity_text"] == "info"
+    assert logs[3]["severity_text"] == "warn"
+    assert before_log_called is True
+
+
+@minimum_python_37
+def test_logs_before_send_log_experimental_option_still_works(
+    sentry_init, capture_envelopes
+):
+    before_log_called = False
+
+    def _before_log(record, hint):
+        nonlocal before_log_called
+        before_log_called = True
+
+        return record
+
+    sentry_init(
+        enable_logs=True,
+        _experiments={
+            "before_send_log": _before_log,
+        },
+    )
+    envelopes = capture_envelopes()
+
+    sentry_sdk.logger.error("This is an error log...")
+
+    get_client().flush()
+    logs = envelopes_to_logs(envelopes)
+    assert len(logs) == 1
+
+    assert logs[0]["severity_text"] == "error"
+    assert before_log_called is True
+
+
+@minimum_python_37
+def test_logs_attributes(sentry_init, capture_envelopes):
+    """
+    Passing arbitrary attributes to log messages.
+    """
+    sentry_init(enable_logs=True, server_name="test-server")
+    envelopes = capture_envelopes()
+
+    attrs = {
+        "attr_int": 1,
+        "attr_float": 2.0,
+        "attr_bool": True,
+        "attr_string": "string attribute",
+    }
+
+    sentry_sdk.logger.warning(
+        "The recorded value was '{my_var}'", my_var="some value", attributes=attrs
+    )
+
+    get_client().flush()
+    logs = envelopes_to_logs(envelopes)
+    assert logs[0]["body"] == "The recorded value was 'some value'"
+
+    for k, v in attrs.items():
+        assert logs[0]["attributes"][k] == v
+    assert logs[0]["attributes"]["sentry.environment"] == "production"
+    if sentry_sdk.get_client().options.get("release") is not None:
+        assert "sentry.release" in logs[0]["attributes"]
+    assert logs[0]["attributes"]["sentry.message.parameter.my_var"] == "some value"
+    assert logs[0]["attributes"][SPANDATA.SERVER_ADDRESS] == "test-server"
+    assert logs[0]["attributes"]["sentry.sdk.name"].startswith("sentry.python")
+    assert logs[0]["attributes"]["sentry.sdk.version"] == VERSION
+
+
+@minimum_python_37
+def test_logs_message_params(sentry_init, capture_envelopes):
+    """
+    This is the official way of how to pass vars to log messages.
+    """
+    sentry_init(enable_logs=True)
+    envelopes = capture_envelopes()
+
+    sentry_sdk.logger.warning("The recorded value was '{int_var}'", int_var=1)
+    sentry_sdk.logger.warning("The recorded value was '{float_var}'", float_var=2.0)
+    sentry_sdk.logger.warning("The recorded value was '{bool_var}'", bool_var=False)
+    sentry_sdk.logger.warning(
+        "The recorded value was '{string_var}'", string_var="some string value"
+    )
+    sentry_sdk.logger.error(
+        "The recorded error was '{error}'", error=Exception("some error")
+    )
+    sentry_sdk.logger.warning("The recorded value was hardcoded.")
+
+    get_client().flush()
+    logs = envelopes_to_logs(envelopes)
+
+    assert logs[0]["body"] == "The recorded value was '1'"
+    assert logs[0]["attributes"]["sentry.message.parameter.int_var"] == 1
+    assert (
+        logs[0]["attributes"]["sentry.message.template"]
+        == "The recorded value was '{int_var}'"
+    )
+
+    assert logs[1]["body"] == "The recorded value was '2.0'"
+    assert logs[1]["attributes"]["sentry.message.parameter.float_var"] == 2.0
+    assert (
+        logs[1]["attributes"]["sentry.message.template"]
+        == "The recorded value was '{float_var}'"
+    )
+
+    assert logs[2]["body"] == "The recorded value was 'False'"
+    assert logs[2]["attributes"]["sentry.message.parameter.bool_var"] is False
+    assert (
+        logs[2]["attributes"]["sentry.message.template"]
+        == "The recorded value was '{bool_var}'"
+    )
+
+    assert logs[3]["body"] == "The recorded value was 'some string value'"
+    assert (
+        logs[3]["attributes"]["sentry.message.parameter.string_var"]
+        == "some string value"
+    )
+    assert (
+        logs[3]["attributes"]["sentry.message.template"]
+        == "The recorded value was '{string_var}'"
+    )
+
+    assert logs[4]["body"] == "The recorded error was 'some error'"
+    assert (
+        logs[4]["attributes"]["sentry.message.parameter.error"]
+        == "Exception('some error')"
+    )
+    assert (
+        logs[4]["attributes"]["sentry.message.template"]
+        == "The recorded error was '{error}'"
+    )
+
+    assert logs[5]["body"] == "The recorded value was hardcoded."
+    assert "sentry.message.template" not in logs[5]["attributes"]
+
+
+@minimum_python_37
+def test_logs_tied_to_transactions(sentry_init, capture_envelopes):
+    """
+    Log messages are also tied to transactions.
+    """
+    sentry_init(enable_logs=True, traces_sample_rate=1.0)
+    envelopes = capture_envelopes()
+
+    with sentry_sdk.start_transaction(name="test-transaction") as trx:
+        sentry_sdk.logger.warning("This is a log tied to a transaction")
+
+    get_client().flush()
+    logs = envelopes_to_logs(envelopes)
+
+    assert "span_id" in logs[0]
+    assert logs[0]["span_id"] == trx.span_id
+
+
+@minimum_python_37
+def test_logs_tied_to_spans(sentry_init, capture_envelopes):
+    """
+    Log messages are also tied to spans.
+    """
+    sentry_init(enable_logs=True, traces_sample_rate=1.0)
+    envelopes = capture_envelopes()
+
+    with sentry_sdk.start_transaction(name="test-transaction"):
+        with sentry_sdk.start_span(name="test-span") as span:
+            sentry_sdk.logger.warning("This is a log tied to a span")
+
+    get_client().flush()
+    logs = envelopes_to_logs(envelopes)
+    assert logs[0]["span_id"] == span.span_id
+
+
+@minimum_python_37
+def test_auto_flush_logs_after_100(sentry_init, capture_envelopes):
+    """
+    If you log >100 logs, it should automatically trigger a flush.
+    """
+    sentry_init(enable_logs=True)
+    envelopes = capture_envelopes()
+
+    for i in range(200):
+        sentry_sdk.logger.warning("log")
+
+    for _ in range(500):
+        time.sleep(1.0 / 100.0)
+        if len(envelopes) > 0:
+            return
+
+    raise AssertionError("200 logs were never flushed after five seconds")
+
+
+@minimum_python_37
+def test_log_user_attributes(sentry_init, capture_envelopes):
+    """User attributes are sent if enable_logs is True and send_default_pii is True."""
+    sentry_init(enable_logs=True, send_default_pii=True)
+
+    sentry_sdk.set_user({"id": "1", "email": "test@example.com", "username": "test"})
+    envelopes = capture_envelopes()
+
+    sentry_sdk.logger.warning("Hello, world!")
+
+    get_client().flush()
+
+    logs = envelopes_to_logs(envelopes)
+    (log,) = logs
+
+    # Check that all expected user attributes are present.
+    assert log["attributes"].items() >= {
+        ("user.id", "1"),
+        ("user.email", "test@example.com"),
+        ("user.name", "test"),
+    }
+
+
+@minimum_python_37
+def test_log_no_user_attributes_if_no_pii(sentry_init, capture_envelopes):
+    """User attributes are not if PII sending is off."""
+    sentry_init(enable_logs=True, send_default_pii=False)
+
+    sentry_sdk.set_user({"id": "1", "email": "test@example.com", "username": "test"})
+    envelopes = capture_envelopes()
+
+    sentry_sdk.logger.warning("Hello, world!")
+
+    get_client().flush()
+
+    logs = envelopes_to_logs(envelopes)
+    (log,) = logs
+
+    assert "user.id" not in log["attributes"]
+    assert "user.email" not in log["attributes"]
+    assert "user.name" not in log["attributes"]
+
+
+@minimum_python_37
+def test_auto_flush_logs_after_5s(sentry_init, capture_envelopes):
+    """
+    If you log a single log, it should automatically flush after 5 seconds, at most 10 seconds.
+    """
+    sentry_init(enable_logs=True)
+    envelopes = capture_envelopes()
+
+    sentry_sdk.logger.warning("log")
+
+    for _ in range(100):
+        time.sleep(1.0 / 10.0)
+        if len(envelopes) > 0:
+            return
+
+    raise AssertionError("1 logs was never flushed after 10 seconds")
+
+
+@minimum_python_37
+@pytest.mark.parametrize(
+    "message,expected_body,params",
+    [
+        ("any text with {braces} in it", "any text with {braces} in it", None),
+        (
+            'JSON data: {"key": "value", "number": 42}',
+            'JSON data: {"key": "value", "number": 42}',
+            None,
+        ),
+        ("Multiple {braces} {in} {message}", "Multiple {braces} {in} {message}", None),
+        ("Nested {{braces}}", "Nested {{braces}}", None),
+        ("Empty braces: {}", "Empty braces: {}", None),
+        ("Braces with params: {user}", "Braces with params: alice", {"user": "alice"}),
+        (
+            "Braces with partial params: {user1} {user2}",
+            "Braces with partial params: alice {user2}",
+            {"user1": "alice"},
+        ),
+    ],
+)
+def test_logs_with_literal_braces(
+    sentry_init, capture_envelopes, message, expected_body, params
+):
+    """
+    Test that log messages with literal braces (like JSON) work without crashing.
+    This is a regression test for issue #4975.
+    """
+    sentry_init(enable_logs=True)
+    envelopes = capture_envelopes()
+
+    if params:
+        sentry_sdk.logger.info(message, **params)
+    else:
+        sentry_sdk.logger.info(message)
+
+    get_client().flush()
+    logs = envelopes_to_logs(envelopes)
+
+    assert len(logs) == 1
+    assert logs[0]["body"] == expected_body
+
+    # Verify template is only stored when there are parameters
+    if params:
+        assert logs[0]["attributes"]["sentry.message.template"] == message
+    else:
+        assert "sentry.message.template" not in logs[0]["attributes"]
+
+
+@minimum_python_37
+def test_batcher_drops_logs(sentry_init, monkeypatch):
+    sentry_init(enable_logs=True, server_name="test-server", release="1.0.0")
+    client = sentry_sdk.get_client()
+
+    def no_op_flush():
+        pass
+
+    monkeypatch.setattr(client.log_batcher, "_flush", no_op_flush)
+
+    lost_event_calls = []
+
+    def record_lost_event(reason, data_category=None, item=None, *, quantity=1):
+        lost_event_calls.append((reason, data_category, item, quantity))
+
+    monkeypatch.setattr(client.log_batcher, "_record_lost_func", record_lost_event)
+
+    for i in range(1_005):  # 5 logs over the hard limit
+        sentry_sdk.logger.info("This is a 'info' log...")
+
+    assert len(lost_event_calls) == 5
+
+    for lost_event_call in lost_event_calls:
+        reason, data_category, item, quantity = lost_event_call
+
+        assert reason == "queue_overflow"
+        assert data_category == "log_item"
+        assert quantity == 1
+
+        assert item.type == "log"
+        assert item.headers == {
+            "type": "log",
+            "item_count": 1,
+            "content_type": "application/vnd.sentry.items.log+json",
+        }
+        assert item.payload.json == {
+            "items": [
+                {
+                    "body": "This is a 'info' log...",
+                    "level": "info",
+                    "timestamp": mock.ANY,
+                    "trace_id": mock.ANY,
+                    "span_id": mock.ANY,
+                    "attributes": {
+                        "sentry.environment": {
+                            "type": "string",
+                            "value": "production",
+                        },
+                        "sentry.release": {
+                            "type": "string",
+                            "value": "1.0.0",
+                        },
+                        "sentry.sdk.name": {
+                            "type": "string",
+                            "value": mock.ANY,
+                        },
+                        "sentry.sdk.version": {
+                            "type": "string",
+                            "value": VERSION,
+                        },
+                        "sentry.severity_number": {
+                            "type": "integer",
+                            "value": 9,
+                        },
+                        "sentry.severity_text": {
+                            "type": "string",
+                            "value": "info",
+                        },
+                        "server.address": {
+                            "type": "string",
+                            "value": "test-server",
+                        },
+                    },
+                }
+            ]
+        }
+
+
+@minimum_python_37
+def test_log_gets_attributes_from_scopes(sentry_init, capture_envelopes):
+    sentry_init(enable_logs=True)
+
+    envelopes = capture_envelopes()
+
+    global_scope = sentry_sdk.get_global_scope()
+    global_scope.set_attribute("global.attribute", "value")
+
+    with sentry_sdk.new_scope() as scope:
+        scope.set_attribute("current.attribute", "value")
+        sentry_sdk.logger.warning("Hello, world!")
+
+    sentry_sdk.logger.warning("Hello again!")
+
+    get_client().flush()
+
+    logs = envelopes_to_logs(envelopes)
+    (log1, log2) = logs
+
+    assert log1["attributes"]["global.attribute"] == "value"
+    assert log1["attributes"]["current.attribute"] == "value"
+
+    assert log2["attributes"]["global.attribute"] == "value"
+    assert "current.attribute" not in log2["attributes"]
+
+
+@minimum_python_37
+def test_log_attributes_override_scope_attributes(sentry_init, capture_envelopes):
+    sentry_init(enable_logs=True)
+
+    envelopes = capture_envelopes()
+
+    with sentry_sdk.new_scope() as scope:
+        scope.set_attribute("durable.attribute", "value1")
+        scope.set_attribute("temp.attribute", "value1")
+        sentry_sdk.logger.warning(
+            "Hello, world!", attributes={"temp.attribute": "value2"}
+        )
+
+    get_client().flush()
+
+    logs = envelopes_to_logs(envelopes)
+    (log,) = logs
+
+    assert log["attributes"]["durable.attribute"] == "value1"
+    assert log["attributes"]["temp.attribute"] == "value2"
+
+
+@minimum_python_37
+def test_attributes_preserialized_in_before_send(sentry_init, capture_envelopes):
+    """We don't surface references to objects in attributes."""
+
+    def before_send_log(log, _):
+        assert isinstance(log["attributes"]["instance"], str)
+        assert isinstance(log["attributes"]["dictionary"], str)
+
+        return log
+
+    sentry_init(enable_logs=True, before_send_log=before_send_log)
+
+    envelopes = capture_envelopes()
+
+    class Cat:
+        pass
+
+    instance = Cat()
+    dictionary = {"color": "tortoiseshell"}
+
+    sentry_sdk.logger.warning(
+        "Hello world!",
+        attributes={
+            "instance": instance,
+            "dictionary": dictionary,
+        },
+    )
+
+    get_client().flush()
+
+    logs = envelopes_to_logs(envelopes)
+    (log,) = logs
+
+    assert isinstance(log["attributes"]["instance"], str)
+    assert isinstance(log["attributes"]["dictionary"], str)
diff --git a/tests/test_lru_cache.py b/tests/test_lru_cache.py
index 5343e76169..3e9c0ac964 100644
--- a/tests/test_lru_cache.py
+++ b/tests/test_lru_cache.py
@@ -35,3 +35,26 @@ def test_cache_eviction():
     cache.set(4, 4)
     assert cache.get(3) is None
     assert cache.get(4) == 4
+
+
+def test_cache_miss():
+    cache = LRUCache(1)
+    assert cache.get(0) is None
+
+
+def test_cache_set_overwrite():
+    cache = LRUCache(3)
+    cache.set(0, 0)
+    cache.set(0, 1)
+    assert cache.get(0) == 1
+
+
+def test_cache_get_all():
+    cache = LRUCache(3)
+    cache.set(0, 0)
+    cache.set(1, 1)
+    cache.set(2, 2)
+    cache.set(3, 3)
+    assert cache.get_all() == [(1, 1), (2, 2), (3, 3)]
+    cache.get(1)
+    assert cache.get_all() == [(2, 2), (3, 3), (1, 1)]
diff --git a/tests/test_metrics.py b/tests/test_metrics.py
index 7211881c32..d64ce748e4 100644
--- a/tests/test_metrics.py
+++ b/tests/test_metrics.py
@@ -1,566 +1,374 @@
-# coding: utf-8
-
-import time
-
-from sentry_sdk import Hub, metrics, push_scope
-
-
-def parse_metrics(bytes):
-    rv = []
-    for line in bytes.splitlines():
-        pieces = line.decode("utf-8").split("|")
-        payload = pieces[0].split(":")
-        name = payload[0]
-        values = payload[1:]
-        ty = pieces[1]
-        ts = None
-        tags = {}
-        for piece in pieces[2:]:
-            if piece[0] == "#":
-                for pair in piece[1:].split(","):
-                    k, v = pair.split(":", 1)
-                    old = tags.get(k)
-                    if old is not None:
-                        if isinstance(old, list):
-                            old.append(v)
-                        else:
-                            tags[k] = [old, v]
-                    else:
-                        tags[k] = v
-            elif piece[0] == "T":
-                ts = int(piece[1:])
-            else:
-                raise ValueError("unknown piece %r" % (piece,))
-        rv.append((ts, name, ty, values, tags))
-    rv.sort(key=lambda x: (x[0], x[1], tuple(sorted(tags.items()))))
-    return rv
-
-
-def test_incr(sentry_init, capture_envelopes):
-    sentry_init(
-        release="fun-release",
-        environment="not-fun-env",
-        _experiments={"enable_metrics": True},
-    )
-    ts = time.time()
+import json
+import sys
+from typing import List, Any, Mapping
+import pytest
+
+import sentry_sdk
+from sentry_sdk import get_client
+from sentry_sdk.envelope import Envelope
+from sentry_sdk.types import Metric
+from sentry_sdk.consts import SPANDATA, VERSION
+
+
+def envelopes_to_metrics(envelopes: "List[Envelope]") -> "List[Metric]":
+    res = []  # type: List[Metric]
+    for envelope in envelopes:
+        for item in envelope.items:
+            if item.type == "trace_metric":
+                for metric_json in item.payload.json["items"]:
+                    metric: "Metric" = {
+                        "timestamp": metric_json["timestamp"],
+                        "trace_id": metric_json["trace_id"],
+                        "span_id": metric_json.get("span_id"),
+                        "name": metric_json["name"],
+                        "type": metric_json["type"],
+                        "value": metric_json["value"],
+                        "unit": metric_json.get("unit"),
+                        "attributes": {
+                            k: v["value"]
+                            for (k, v) in metric_json["attributes"].items()
+                        },
+                    }
+                    res.append(metric)
+    return res
+
+
+def test_metrics_disabled(sentry_init, capture_envelopes):
+    sentry_init(enable_metrics=False)
+
     envelopes = capture_envelopes()
 
-    metrics.incr("foobar", 1.0, tags={"foo": "bar", "blub": "blah"}, timestamp=ts)
-    metrics.incr("foobar", 2.0, tags={"foo": "bar", "blub": "blah"}, timestamp=ts)
-    Hub.current.flush()
+    sentry_sdk.metrics.count("test.counter", 1)
+    sentry_sdk.metrics.gauge("test.gauge", 42)
+    sentry_sdk.metrics.distribution("test.distribution", 200)
 
-    (envelope,) = envelopes
+    assert len(envelopes) == 0
 
-    assert len(envelope.items) == 1
-    assert envelope.items[0].headers["type"] == "statsd"
-    m = parse_metrics(envelope.items[0].payload.get_bytes())
 
-    assert len(m) == 1
-    assert m[0][1] == "foobar@none"
-    assert m[0][2] == "c"
-    assert m[0][3] == ["3.0"]
-    assert m[0][4] == {
-        "blub": "blah",
-        "foo": "bar",
-        "release": "fun-release",
-        "environment": "not-fun-env",
-    }
+def test_metrics_basics(sentry_init, capture_envelopes):
+    sentry_init()
+    envelopes = capture_envelopes()
 
+    sentry_sdk.metrics.count("test.counter", 1)
+    sentry_sdk.metrics.gauge("test.gauge", 42, unit="millisecond")
+    sentry_sdk.metrics.distribution("test.distribution", 200, unit="second")
 
-def test_timing(sentry_init, capture_envelopes):
-    sentry_init(
-        release="fun-release@1.0.0",
-        environment="not-fun-env",
-        _experiments={"enable_metrics": True},
-    )
-    ts = time.time()
+    get_client().flush()
+    metrics = envelopes_to_metrics(envelopes)
+
+    assert len(metrics) == 3
+
+    assert metrics[0]["name"] == "test.counter"
+    assert metrics[0]["type"] == "counter"
+    assert metrics[0]["value"] == 1.0
+    assert metrics[0]["unit"] is None
+    assert "sentry.sdk.name" in metrics[0]["attributes"]
+    assert "sentry.sdk.version" in metrics[0]["attributes"]
+
+    assert metrics[1]["name"] == "test.gauge"
+    assert metrics[1]["type"] == "gauge"
+    assert metrics[1]["value"] == 42.0
+    assert metrics[1]["unit"] == "millisecond"
+
+    assert metrics[2]["name"] == "test.distribution"
+    assert metrics[2]["type"] == "distribution"
+    assert metrics[2]["value"] == 200.0
+    assert metrics[2]["unit"] == "second"
+
+
+def test_metrics_experimental_option(sentry_init, capture_envelopes):
+    sentry_init()
     envelopes = capture_envelopes()
 
-    with metrics.timing("whatever", tags={"blub": "blah"}, timestamp=ts):
-        time.sleep(0.1)
-    Hub.current.flush()
+    sentry_sdk.metrics.count("test.counter", 5)
 
-    (envelope,) = envelopes
+    get_client().flush()
 
-    assert len(envelope.items) == 1
-    assert envelope.items[0].headers["type"] == "statsd"
-    m = parse_metrics(envelope.items[0].payload.get_bytes())
+    metrics = envelopes_to_metrics(envelopes)
+    assert len(metrics) == 1
 
-    assert len(m) == 1
-    assert m[0][1] == "whatever@second"
-    assert m[0][2] == "d"
-    assert len(m[0][3]) == 1
-    assert float(m[0][3][0]) >= 0.1
-    assert m[0][4] == {
-        "blub": "blah",
-        "release": "fun-release@1.0.0",
-        "environment": "not-fun-env",
-    }
+    assert metrics[0]["name"] == "test.counter"
+    assert metrics[0]["type"] == "counter"
+    assert metrics[0]["value"] == 5.0
 
 
-def test_timing_decorator(sentry_init, capture_envelopes):
-    sentry_init(
-        release="fun-release@1.0.0",
-        environment="not-fun-env",
-        _experiments={"enable_metrics": True},
-    )
+def test_metrics_with_attributes(sentry_init, capture_envelopes):
+    sentry_init(release="1.0.0", environment="test", server_name="test-server")
     envelopes = capture_envelopes()
 
-    @metrics.timing("whatever-1", tags={"x": "y"})
-    def amazing():
-        time.sleep(0.1)
-        return 42
-
-    @metrics.timing("whatever-2", tags={"x": "y"}, unit="nanosecond")
-    def amazing_nano():
-        time.sleep(0.01)
-        return 23
-
-    assert amazing() == 42
-    assert amazing_nano() == 23
-    Hub.current.flush()
-
-    (envelope,) = envelopes
-
-    assert len(envelope.items) == 1
-    assert envelope.items[0].headers["type"] == "statsd"
-    m = parse_metrics(envelope.items[0].payload.get_bytes())
-
-    assert len(m) == 2
-    assert m[0][1] == "whatever-1@second"
-    assert m[0][2] == "d"
-    assert len(m[0][3]) == 1
-    assert float(m[0][3][0]) >= 0.1
-    assert m[0][4] == {
-        "x": "y",
-        "release": "fun-release@1.0.0",
-        "environment": "not-fun-env",
-    }
-
-    assert m[1][1] == "whatever-2@nanosecond"
-    assert m[1][2] == "d"
-    assert len(m[1][3]) == 1
-    assert float(m[1][3][0]) >= 10000000.0
-    assert m[1][4] == {
-        "x": "y",
-        "release": "fun-release@1.0.0",
-        "environment": "not-fun-env",
-    }
-
-
-def test_timing_basic(sentry_init, capture_envelopes):
-    sentry_init(
-        release="fun-release@1.0.0",
-        environment="not-fun-env",
-        _experiments={"enable_metrics": True},
+    sentry_sdk.metrics.count(
+        "test.counter", 1, attributes={"endpoint": "/api/test", "status": "success"}
     )
-    ts = time.time()
-    envelopes = capture_envelopes()
 
-    metrics.timing("timing", 1.0, tags={"a": "b"}, timestamp=ts)
-    metrics.timing("timing", 2.0, tags={"a": "b"}, timestamp=ts)
-    metrics.timing("timing", 2.0, tags={"a": "b"}, timestamp=ts)
-    metrics.timing("timing", 3.0, tags={"a": "b"}, timestamp=ts)
-    Hub.current.flush()
+    get_client().flush()
 
-    (envelope,) = envelopes
+    metrics = envelopes_to_metrics(envelopes)
+    assert len(metrics) == 1
 
-    assert len(envelope.items) == 1
-    assert envelope.items[0].headers["type"] == "statsd"
-    m = parse_metrics(envelope.items[0].payload.get_bytes())
+    assert metrics[0]["attributes"]["endpoint"] == "/api/test"
+    assert metrics[0]["attributes"]["status"] == "success"
+    assert metrics[0]["attributes"]["sentry.release"] == "1.0.0"
+    assert metrics[0]["attributes"]["sentry.environment"] == "test"
 
-    assert len(m) == 1
-    assert m[0][1] == "timing@second"
-    assert m[0][2] == "d"
-    assert len(m[0][3]) == 4
-    assert sorted(map(float, m[0][3])) == [1.0, 2.0, 2.0, 3.0]
-    assert m[0][4] == {
-        "a": "b",
-        "release": "fun-release@1.0.0",
-        "environment": "not-fun-env",
-    }
+    assert metrics[0]["attributes"][SPANDATA.SERVER_ADDRESS] == "test-server"
+    assert metrics[0]["attributes"]["sentry.sdk.name"].startswith("sentry.python")
+    assert metrics[0]["attributes"]["sentry.sdk.version"] == VERSION
 
 
-def test_distribution(sentry_init, capture_envelopes):
-    sentry_init(
-        release="fun-release@1.0.0",
-        environment="not-fun-env",
-        _experiments={"enable_metrics": True},
-    )
-    ts = time.time()
+def test_metrics_with_user(sentry_init, capture_envelopes):
+    sentry_init(send_default_pii=True)
     envelopes = capture_envelopes()
 
-    metrics.distribution("dist", 1.0, tags={"a": "b"}, timestamp=ts)
-    metrics.distribution("dist", 2.0, tags={"a": "b"}, timestamp=ts)
-    metrics.distribution("dist", 2.0, tags={"a": "b"}, timestamp=ts)
-    metrics.distribution("dist", 3.0, tags={"a": "b"}, timestamp=ts)
-    Hub.current.flush()
+    sentry_sdk.set_user(
+        {"id": "user-123", "email": "test@example.com", "username": "testuser"}
+    )
+    sentry_sdk.metrics.count("test.user.counter", 1)
 
-    (envelope,) = envelopes
+    get_client().flush()
 
-    assert len(envelope.items) == 1
-    assert envelope.items[0].headers["type"] == "statsd"
-    m = parse_metrics(envelope.items[0].payload.get_bytes())
+    metrics = envelopes_to_metrics(envelopes)
+    assert len(metrics) == 1
 
-    assert len(m) == 1
-    assert m[0][1] == "dist@none"
-    assert m[0][2] == "d"
-    assert len(m[0][3]) == 4
-    assert sorted(map(float, m[0][3])) == [1.0, 2.0, 2.0, 3.0]
-    assert m[0][4] == {
-        "a": "b",
-        "release": "fun-release@1.0.0",
-        "environment": "not-fun-env",
-    }
+    assert metrics[0]["attributes"]["user.id"] == "user-123"
+    assert metrics[0]["attributes"]["user.email"] == "test@example.com"
+    assert metrics[0]["attributes"]["user.name"] == "testuser"
 
 
-def test_set(sentry_init, capture_envelopes):
-    sentry_init(
-        release="fun-release@1.0.0",
-        environment="not-fun-env",
-        _experiments={"enable_metrics": True},
-    )
-    ts = time.time()
+def test_metrics_no_user_if_pii_off(sentry_init, capture_envelopes):
+    sentry_init(send_default_pii=False)
     envelopes = capture_envelopes()
 
-    metrics.set("my-set", "peter", tags={"magic": "puff"}, timestamp=ts)
-    metrics.set("my-set", "paul", tags={"magic": "puff"}, timestamp=ts)
-    metrics.set("my-set", "mary", tags={"magic": "puff"}, timestamp=ts)
-    Hub.current.flush()
+    sentry_sdk.set_user(
+        {"id": "user-123", "email": "test@example.com", "username": "testuser"}
+    )
+    sentry_sdk.metrics.count("test.user.counter", 1)
 
-    (envelope,) = envelopes
+    get_client().flush()
 
-    assert len(envelope.items) == 1
-    assert envelope.items[0].headers["type"] == "statsd"
-    m = parse_metrics(envelope.items[0].payload.get_bytes())
+    metrics = envelopes_to_metrics(envelopes)
+    assert len(metrics) == 1
 
-    assert len(m) == 1
-    assert m[0][1] == "my-set@none"
-    assert m[0][2] == "s"
-    assert len(m[0][3]) == 3
-    assert sorted(map(int, m[0][3])) == [354582103, 2513273657, 3329318813]
-    assert m[0][4] == {
-        "magic": "puff",
-        "release": "fun-release@1.0.0",
-        "environment": "not-fun-env",
-    }
+    assert "user.id" not in metrics[0]["attributes"]
+    assert "user.email" not in metrics[0]["attributes"]
+    assert "user.name" not in metrics[0]["attributes"]
 
 
-def test_gauge(sentry_init, capture_envelopes):
-    sentry_init(
-        release="fun-release@1.0.0",
-        environment="not-fun-env",
-        _experiments={"enable_metrics": True},
-    )
-    ts = time.time()
+def test_metrics_with_span(sentry_init, capture_envelopes):
+    sentry_init(traces_sample_rate=1.0)
     envelopes = capture_envelopes()
 
-    metrics.gauge("my-gauge", 10.0, tags={"x": "y"}, timestamp=ts)
-    metrics.gauge("my-gauge", 20.0, tags={"x": "y"}, timestamp=ts)
-    metrics.gauge("my-gauge", 30.0, tags={"x": "y"}, timestamp=ts)
-    Hub.current.flush()
+    with sentry_sdk.start_transaction(op="test", name="test-span") as transaction:
+        sentry_sdk.metrics.count("test.span.counter", 1)
 
-    (envelope,) = envelopes
+    get_client().flush()
 
-    assert len(envelope.items) == 1
-    assert envelope.items[0].headers["type"] == "statsd"
-    m = parse_metrics(envelope.items[0].payload.get_bytes())
+    metrics = envelopes_to_metrics(envelopes)
+    assert len(metrics) == 1
 
-    assert len(m) == 1
-    assert m[0][1] == "my-gauge@none"
-    assert m[0][2] == "g"
-    assert len(m[0][3]) == 5
-    assert list(map(float, m[0][3])) == [30.0, 10.0, 30.0, 60.0, 3.0]
-    assert m[0][4] == {
-        "x": "y",
-        "release": "fun-release@1.0.0",
-        "environment": "not-fun-env",
-    }
+    assert metrics[0]["trace_id"] is not None
+    assert metrics[0]["trace_id"] == transaction.trace_id
+    assert metrics[0]["span_id"] == transaction.span_id
 
 
-def test_multiple(sentry_init, capture_envelopes):
-    sentry_init(
-        release="fun-release@1.0.0",
-        environment="not-fun-env",
-        _experiments={"enable_metrics": True},
-    )
-    ts = time.time()
+def test_metrics_tracing_without_performance(sentry_init, capture_envelopes):
+    sentry_init()
     envelopes = capture_envelopes()
 
-    metrics.gauge("my-gauge", 10.0, tags={"x": "y"}, timestamp=ts)
-    metrics.gauge("my-gauge", 20.0, tags={"x": "y"}, timestamp=ts)
-    metrics.gauge("my-gauge", 30.0, tags={"x": "y"}, timestamp=ts)
-    for _ in range(10):
-        metrics.incr("counter-1", 1.0, timestamp=ts)
-    metrics.incr("counter-2", 1.0, timestamp=ts)
-
-    Hub.current.flush()
-
-    (envelope,) = envelopes
-
-    assert len(envelope.items) == 1
-    assert envelope.items[0].headers["type"] == "statsd"
-    m = parse_metrics(envelope.items[0].payload.get_bytes())
-
-    assert len(m) == 3
-
-    assert m[0][1] == "counter-1@none"
-    assert m[0][2] == "c"
-    assert list(map(float, m[0][3])) == [10.0]
-    assert m[0][4] == {
-        "release": "fun-release@1.0.0",
-        "environment": "not-fun-env",
-    }
-
-    assert m[1][1] == "counter-2@none"
-    assert m[1][2] == "c"
-    assert list(map(float, m[1][3])) == [1.0]
-    assert m[1][4] == {
-        "release": "fun-release@1.0.0",
-        "environment": "not-fun-env",
-    }
-
-    assert m[2][1] == "my-gauge@none"
-    assert m[2][2] == "g"
-    assert len(m[2][3]) == 5
-    assert list(map(float, m[2][3])) == [30.0, 10.0, 30.0, 60.0, 3.0]
-    assert m[2][4] == {
-        "x": "y",
-        "release": "fun-release@1.0.0",
-        "environment": "not-fun-env",
-    }
-
-
-def test_transaction_name(sentry_init, capture_envelopes):
-    sentry_init(
-        release="fun-release@1.0.0",
-        environment="not-fun-env",
-        _experiments={"enable_metrics": True},
-    )
-    ts = time.time()
-    envelopes = capture_envelopes()
+    with sentry_sdk.isolation_scope() as isolation_scope:
+        sentry_sdk.metrics.count("test.span.counter", 1)
 
-    with push_scope() as scope:
-        scope.set_transaction_name("/user/{user_id}", source="route")
-        metrics.distribution("dist", 1.0, tags={"a": "b"}, timestamp=ts)
-        metrics.distribution("dist", 2.0, tags={"a": "b"}, timestamp=ts)
-        metrics.distribution("dist", 2.0, tags={"a": "b"}, timestamp=ts)
-        metrics.distribution("dist", 3.0, tags={"a": "b"}, timestamp=ts)
+    get_client().flush()
 
-    Hub.current.flush()
+    metrics = envelopes_to_metrics(envelopes)
+    assert len(metrics) == 1
 
-    (envelope,) = envelopes
+    propagation_context = isolation_scope._propagation_context
+    assert propagation_context is not None
+    assert metrics[0]["trace_id"] == propagation_context.trace_id
+    assert metrics[0]["span_id"] == propagation_context.span_id
 
-    assert len(envelope.items) == 1
-    assert envelope.items[0].headers["type"] == "statsd"
-    m = parse_metrics(envelope.items[0].payload.get_bytes())
 
-    assert len(m) == 1
-    assert m[0][1] == "dist@none"
-    assert m[0][2] == "d"
-    assert len(m[0][3]) == 4
-    assert sorted(map(float, m[0][3])) == [1.0, 2.0, 2.0, 3.0]
-    assert m[0][4] == {
-        "a": "b",
-        "transaction": "/user/{user_id}",
-        "release": "fun-release@1.0.0",
-        "environment": "not-fun-env",
-    }
+def test_metrics_before_send(sentry_init, capture_envelopes):
+    before_metric_called = False
 
+    def _before_metric(record, hint):
+        nonlocal before_metric_called
 
-def test_tag_normalization(sentry_init, capture_envelopes):
-    sentry_init(
-        release="fun-release@1.0.0",
-        environment="not-fun-env",
-        _experiments={"enable_metrics": True},
-    )
-    ts = time.time()
-    envelopes = capture_envelopes()
+        assert set(record.keys()) == {
+            "timestamp",
+            "trace_id",
+            "span_id",
+            "name",
+            "type",
+            "value",
+            "unit",
+            "attributes",
+        }
+
+        if record["name"] == "test.skip":
+            return None
 
-    # fmt: off
-    metrics.distribution("a", 1.0, tags={"foo-bar": "%$foo"}, timestamp=ts)
-    metrics.distribution("b", 1.0, tags={"foo$$$bar": "blah{}"}, timestamp=ts)
-    metrics.distribution("c", 1.0, tags={u"foö-bar": u"snöwmän"}, timestamp=ts)
-    # fmt: on
-    Hub.current.flush()
-
-    (envelope,) = envelopes
-
-    assert len(envelope.items) == 1
-    assert envelope.items[0].headers["type"] == "statsd"
-    m = parse_metrics(envelope.items[0].payload.get_bytes())
-
-    assert len(m) == 3
-    assert m[0][4] == {
-        "foo-bar": "_$foo",
-        "release": "fun-release@1.0.0",
-        "environment": "not-fun-env",
-    }
-
-    assert m[1][4] == {
-        "foo_bar": "blah{}",
-        "release": "fun-release@1.0.0",
-        "environment": "not-fun-env",
-    }
-
-    # fmt: off
-    assert m[2][4] == {
-        "fo_-bar": u"snöwmän",
-        "release": "fun-release@1.0.0",
-        "environment": "not-fun-env",
-    }
-    # fmt: on
-
-
-def test_before_emit_metric(sentry_init, capture_envelopes):
-    def before_emit(key, tags):
-        if key == "removed-metric":
-            return False
-        tags["extra"] = "foo"
-        del tags["release"]
-        # this better be a noop!
-        metrics.incr("shitty-recursion")
-        return True
+        before_metric_called = True
+        return record
 
     sentry_init(
-        release="fun-release@1.0.0",
-        environment="not-fun-env",
-        _experiments={
-            "enable_metrics": True,
-            "before_emit_metric": before_emit,
-        },
+        before_send_metric=_before_metric,
     )
     envelopes = capture_envelopes()
 
-    metrics.incr("removed-metric", 1.0)
-    metrics.incr("actual-metric", 1.0)
-    Hub.current.flush()
+    sentry_sdk.metrics.count("test.skip", 1)
+    sentry_sdk.metrics.count("test.keep", 1)
+
+    get_client().flush()
+
+    metrics = envelopes_to_metrics(envelopes)
+    assert len(metrics) == 1
+    assert metrics[0]["name"] == "test.keep"
+    assert before_metric_called
+
 
-    (envelope,) = envelopes
+def test_metrics_experimental_before_send(sentry_init, capture_envelopes):
+    before_metric_called = False
 
-    assert len(envelope.items) == 1
-    assert envelope.items[0].headers["type"] == "statsd"
-    m = parse_metrics(envelope.items[0].payload.get_bytes())
+    def _before_metric(record, hint):
+        nonlocal before_metric_called
 
-    assert len(m) == 1
-    assert m[0][1] == "actual-metric@none"
-    assert m[0][3] == ["1.0"]
-    assert m[0][4] == {
-        "extra": "foo",
-        "environment": "not-fun-env",
-    }
+        assert set(record.keys()) == {
+            "timestamp",
+            "trace_id",
+            "span_id",
+            "name",
+            "type",
+            "value",
+            "unit",
+            "attributes",
+        }
 
+        if record["name"] == "test.skip":
+            return None
+
+        before_metric_called = True
+        return record
 
-def test_aggregator_flush(sentry_init, capture_envelopes):
     sentry_init(
-        release="fun-release@1.0.0",
-        environment="not-fun-env",
         _experiments={
-            "enable_metrics": True,
+            "before_send_metric": _before_metric,
         },
     )
     envelopes = capture_envelopes()
 
-    metrics.incr("a-metric", 1.0)
-    Hub.current.flush()
+    sentry_sdk.metrics.count("test.skip", 1)
+    sentry_sdk.metrics.count("test.keep", 1)
 
-    assert len(envelopes) == 1
-    assert Hub.current.client.metrics_aggregator.buckets == {}
+    get_client().flush()
 
+    metrics = envelopes_to_metrics(envelopes)
+    assert len(metrics) == 1
+    assert metrics[0]["name"] == "test.keep"
+    assert before_metric_called
+
+
+def test_batcher_drops_metrics(sentry_init, monkeypatch):
+    sentry_init()
+    client = sentry_sdk.get_client()
+
+    def no_op_flush():
+        pass
+
+    monkeypatch.setattr(client.metrics_batcher, "_flush", no_op_flush)
+
+    lost_event_calls = []
+
+    def record_lost_event(reason, data_category, quantity):
+        lost_event_calls.append((reason, data_category, quantity))
+
+    monkeypatch.setattr(client.metrics_batcher, "_record_lost_func", record_lost_event)
+
+    for i in range(10_005):  # 5 metrics over the hard limit
+        sentry_sdk.metrics.count("test.counter", 1)
+
+    assert len(lost_event_calls) == 5
+    for lost_event_call in lost_event_calls:
+        assert lost_event_call == ("queue_overflow", "trace_metric", 1)
+
+
+def test_metric_gets_attributes_from_scopes(sentry_init, capture_envelopes):
+    sentry_init()
 
-def test_tag_serialization(sentry_init, capture_envelopes):
-    sentry_init(
-        release="fun-release",
-        environment="not-fun-env",
-        _experiments={"enable_metrics": True},
-    )
     envelopes = capture_envelopes()
 
-    metrics.incr(
-        "counter",
-        tags={
-            "no-value": None,
-            "an-int": 42,
-            "a-float": 23.0,
-            "a-string": "blah",
-            "more-than-one": [1, "zwei", "3.0", None],
-        },
-    )
-    Hub.current.flush()
+    global_scope = sentry_sdk.get_global_scope()
+    global_scope.set_attribute("global.attribute", "value")
 
-    (envelope,) = envelopes
+    with sentry_sdk.new_scope() as scope:
+        scope.set_attribute("current.attribute", "value")
+        sentry_sdk.metrics.count("test", 1)
 
-    assert len(envelope.items) == 1
-    assert envelope.items[0].headers["type"] == "statsd"
-    m = parse_metrics(envelope.items[0].payload.get_bytes())
+    sentry_sdk.metrics.count("test", 1)
 
-    assert len(m) == 1
-    assert m[0][4] == {
-        "an-int": "42",
-        "a-float": "23.0",
-        "a-string": "blah",
-        "more-than-one": ["1", "3.0", "zwei"],
-        "release": "fun-release",
-        "environment": "not-fun-env",
-    }
+    get_client().flush()
 
+    metrics = envelopes_to_metrics(envelopes)
+    (metric1, metric2) = metrics
+
+    assert metric1["attributes"]["global.attribute"] == "value"
+    assert metric1["attributes"]["current.attribute"] == "value"
+
+    assert metric2["attributes"]["global.attribute"] == "value"
+    assert "current.attribute" not in metric2["attributes"]
+
+
+def test_metric_attributes_override_scope_attributes(sentry_init, capture_envelopes):
+    sentry_init()
 
-def test_flush_recursion_protection(sentry_init, capture_envelopes, monkeypatch):
-    sentry_init(
-        release="fun-release",
-        environment="not-fun-env",
-        _experiments={"enable_metrics": True},
-    )
     envelopes = capture_envelopes()
-    test_client = Hub.current.client
 
-    real_capture_envelope = test_client.transport.capture_envelope
+    with sentry_sdk.new_scope() as scope:
+        scope.set_attribute("durable.attribute", "value1")
+        scope.set_attribute("temp.attribute", "value1")
+        sentry_sdk.metrics.count("test", 1, attributes={"temp.attribute": "value2"})
 
-    def bad_capture_envelope(*args, **kwargs):
-        metrics.incr("bad-metric")
-        return real_capture_envelope(*args, **kwargs)
+    get_client().flush()
 
-    monkeypatch.setattr(test_client.transport, "capture_envelope", bad_capture_envelope)
+    metrics = envelopes_to_metrics(envelopes)
+    (metric,) = metrics
 
-    metrics.incr("counter")
+    assert metric["attributes"]["durable.attribute"] == "value1"
+    assert metric["attributes"]["temp.attribute"] == "value2"
 
-    # flush twice to see the inner metric
-    Hub.current.flush()
-    Hub.current.flush()
 
-    (envelope,) = envelopes
-    m = parse_metrics(envelope.items[0].payload.get_bytes())
-    assert len(m) == 1
-    assert m[0][1] == "counter@none"
+def test_attributes_preserialized_in_before_send(sentry_init, capture_envelopes):
+    """We don't surface references to objects in attributes."""
 
+    def before_send_metric(metric, _):
+        assert isinstance(metric["attributes"]["instance"], str)
+        assert isinstance(metric["attributes"]["dictionary"], str)
+
+        return metric
+
+    sentry_init(before_send_metric=before_send_metric)
 
-def test_flush_recursion_protection_background_flush(
-    sentry_init, capture_envelopes, monkeypatch
-):
-    monkeypatch.setattr(metrics.MetricsAggregator, "FLUSHER_SLEEP_TIME", 0.1)
-    sentry_init(
-        release="fun-release",
-        environment="not-fun-env",
-        _experiments={"enable_metrics": True},
-    )
     envelopes = capture_envelopes()
-    test_client = Hub.current.client
 
-    real_capture_envelope = test_client.transport.capture_envelope
+    class Cat:
+        pass
 
-    def bad_capture_envelope(*args, **kwargs):
-        metrics.incr("bad-metric")
-        return real_capture_envelope(*args, **kwargs)
+    instance = Cat()
+    dictionary = {"color": "tortoiseshell"}
 
-    monkeypatch.setattr(test_client.transport, "capture_envelope", bad_capture_envelope)
+    sentry_sdk.metrics.count(
+        "test.counter",
+        1,
+        attributes={
+            "instance": instance,
+            "dictionary": dictionary,
+        },
+    )
 
-    metrics.incr("counter")
+    get_client().flush()
 
-    # flush via sleep and flag
-    Hub.current.client.metrics_aggregator._force_flush = True
-    time.sleep(0.5)
+    metrics = envelopes_to_metrics(envelopes)
+    (metric,) = metrics
 
-    (envelope,) = envelopes
-    m = parse_metrics(envelope.items[0].payload.get_bytes())
-    assert len(m) == 1
-    assert m[0][1] == "counter@none"
+    assert isinstance(metric["attributes"]["instance"], str)
+    assert isinstance(metric["attributes"]["dictionary"], str)
diff --git a/tests/test_monitor.py b/tests/test_monitor.py
index ec804ba513..9ffc943bed 100644
--- a/tests/test_monitor.py
+++ b/tests/test_monitor.py
@@ -1,14 +1,12 @@
-import random
+from collections import Counter
+from unittest import mock
 
-from sentry_sdk import Hub, start_transaction
+import sentry_sdk
 from sentry_sdk.transport import Transport
 
 
 class HealthyTestTransport(Transport):
-    def _send_event(self, event):
-        pass
-
-    def _send_envelope(self, envelope):
+    def capture_envelope(self, _):
         pass
 
     def is_healthy(self):
@@ -26,13 +24,13 @@ def test_no_monitor_if_disabled(sentry_init):
         enable_backpressure_handling=False,
     )
 
-    assert Hub.current.client.monitor is None
+    assert sentry_sdk.get_client().monitor is None
 
 
 def test_monitor_if_enabled(sentry_init):
     sentry_init(transport=HealthyTestTransport())
 
-    monitor = Hub.current.client.monitor
+    monitor = sentry_sdk.get_client().monitor
     assert monitor is not None
     assert monitor._thread is None
 
@@ -45,7 +43,7 @@ def test_monitor_if_enabled(sentry_init):
 def test_monitor_unhealthy(sentry_init):
     sentry_init(transport=UnhealthyTestTransport())
 
-    monitor = Hub.current.client.monitor
+    monitor = sentry_sdk.get_client().monitor
     monitor.interval = 0.1
 
     assert monitor.is_healthy() is True
@@ -57,28 +55,47 @@ def test_monitor_unhealthy(sentry_init):
 
 
 def test_transaction_uses_downsampled_rate(
-    sentry_init, capture_client_reports, monkeypatch
+    sentry_init, capture_record_lost_event_calls, monkeypatch
 ):
     sentry_init(
         traces_sample_rate=1.0,
         transport=UnhealthyTestTransport(),
     )
 
-    reports = capture_client_reports()
+    record_lost_event_calls = capture_record_lost_event_calls()
 
-    monitor = Hub.current.client.monitor
+    monitor = sentry_sdk.get_client().monitor
     monitor.interval = 0.1
 
-    # make sure rng doesn't sample
-    monkeypatch.setattr(random, "random", lambda: 0.9)
-
     assert monitor.is_healthy() is True
     monitor.run()
     assert monitor.is_healthy() is False
     assert monitor.downsample_factor == 1
 
-    with start_transaction(name="foobar") as transaction:
-        assert transaction.sampled is False
-        assert transaction.sample_rate == 0.5
+    # make sure we don't sample the transaction
+    with mock.patch("sentry_sdk.tracing_utils.Random.randrange", return_value=750000):
+        with sentry_sdk.start_transaction(name="foobar") as transaction:
+            assert transaction.sampled is False
+            assert transaction.sample_rate == 0.5
+
+    assert Counter(record_lost_event_calls) == Counter(
+        [
+            ("backpressure", "transaction", None, 1),
+            ("backpressure", "span", None, 1),
+        ]
+    )
+
+
+def test_monitor_no_thread_on_shutdown_no_errors(sentry_init):
+    sentry_init(transport=HealthyTestTransport())
 
-    assert reports == [("backpressure", "transaction")]
+    # make it seem like the interpreter is shutting down
+    with mock.patch(
+        "threading.Thread.start",
+        side_effect=RuntimeError("can't create new thread at interpreter shutdown"),
+    ):
+        monitor = sentry_sdk.get_client().monitor
+        assert monitor is not None
+        assert monitor._thread is None
+        monitor.run()
+        assert monitor._thread is None
diff --git a/tests/test_propagationcontext.py b/tests/test_propagationcontext.py
new file mode 100644
index 0000000000..6c14aa2952
--- /dev/null
+++ b/tests/test_propagationcontext.py
@@ -0,0 +1,184 @@
+from unittest import mock
+from unittest.mock import Mock
+
+import pytest
+
+from sentry_sdk.tracing_utils import PropagationContext
+
+
+SAMPLED_FLAG = {
+    None: "",
+    False: "-0",
+    True: "-1",
+}
+"""Maps the `sampled` value to the flag appended to the sentry-trace header."""
+
+
+def test_empty_context():
+    ctx = PropagationContext()
+
+    assert ctx.trace_id is not None
+    assert len(ctx.trace_id) == 32
+
+    assert ctx.span_id is not None
+    assert len(ctx.span_id) == 16
+
+    assert ctx.parent_span_id is None
+    assert ctx.parent_sampled is None
+    assert ctx.dynamic_sampling_context == {}
+
+
+def test_context_with_values():
+    ctx = PropagationContext(
+        trace_id="1234567890abcdef1234567890abcdef",
+        span_id="1234567890abcdef",
+        parent_span_id="abcdef1234567890",
+        parent_sampled=True,
+        dynamic_sampling_context={
+            "foo": "bar",
+        },
+    )
+
+    assert ctx.trace_id == "1234567890abcdef1234567890abcdef"
+    assert ctx.span_id == "1234567890abcdef"
+    assert ctx.parent_span_id == "abcdef1234567890"
+    assert ctx.parent_sampled
+    assert ctx.dynamic_sampling_context == {
+        "foo": "bar",
+    }
+
+
+def test_lazy_uuids():
+    ctx = PropagationContext()
+    assert ctx._trace_id is None
+    assert ctx._span_id is None
+
+    assert ctx.trace_id is not None  # this sets _trace_id
+    assert ctx._trace_id is not None
+    assert ctx._span_id is None
+
+    assert ctx.span_id is not None  # this sets _span_id
+    assert ctx._trace_id is not None
+    assert ctx._span_id is not None
+
+
+def test_property_setters():
+    ctx = PropagationContext()
+
+    ctx.trace_id = "X234567890abcdef1234567890abcdef"
+    ctx.span_id = "X234567890abcdef"
+
+    assert ctx._trace_id == "X234567890abcdef1234567890abcdef"
+    assert ctx.trace_id == "X234567890abcdef1234567890abcdef"
+    assert ctx._span_id == "X234567890abcdef"
+    assert ctx.span_id == "X234567890abcdef"
+    assert ctx.dynamic_sampling_context == {}
+
+
+def test_update():
+    ctx = PropagationContext()
+
+    other_data = {
+        "trace_id": "Z234567890abcdef1234567890abcdef",
+        "parent_span_id": "Z234567890abcdef",
+        "parent_sampled": False,
+        "foo": "bar",
+    }
+    ctx.update(other_data)
+
+    assert ctx._trace_id == "Z234567890abcdef1234567890abcdef"
+    assert ctx.trace_id == "Z234567890abcdef1234567890abcdef"
+    assert ctx._span_id is None  # this will be set lazily
+    assert ctx.span_id is not None  # this sets _span_id
+    assert ctx._span_id is not None
+    assert ctx.parent_span_id == "Z234567890abcdef"
+    assert not ctx.parent_sampled
+    assert ctx.dynamic_sampling_context == {}
+
+    assert not hasattr(ctx, "foo")
+
+
+def test_existing_sample_rand_kept():
+    ctx = PropagationContext(
+        trace_id="00000000000000000000000000000000",
+        dynamic_sampling_context={"sample_rand": "0.5"},
+    )
+
+    # If sample_rand was regenerated, the value would be 0.919221 based on the trace_id
+    assert ctx.dynamic_sampling_context["sample_rand"] == "0.5"
+
+
+@pytest.mark.parametrize(
+    ("parent_sampled", "sample_rate", "expected_interval"),
+    (
+        # Note that parent_sampled and sample_rate do not scale the
+        # sample_rand value, only determine the range of the value.
+        # Expected values are determined by parent_sampled, sample_rate,
+        # and the trace_id.
+        (None, None, (0.0, 1.0)),
+        (None, "0.5", (0.0, 1.0)),
+        (False, None, (0.0, 1.0)),
+        (True, None, (0.0, 1.0)),
+        (False, "0.0", (0.0, 1.0)),
+        (False, "0.01", (0.01, 1.0)),
+        (True, "0.01", (0.0, 0.01)),
+        (False, "0.1", (0.1, 1.0)),
+        (True, "0.1", (0.0, 0.1)),
+        (False, "0.5", (0.5, 1.0)),
+        (True, "0.5", (0.0, 0.5)),
+        (True, "1.0", (0.0, 1.0)),
+    ),
+)
+def test_sample_rand_filled(parent_sampled, sample_rate, expected_interval):
+    """When continuing a trace, we want to fill in the sample_rand value if it's missing."""
+    if sample_rate is not None:
+        sample_rate_str = f",sentry-sample_rate={sample_rate}"  # noqa: E231
+    else:
+        sample_rate_str = ""
+
+    # for convenience, we'll just return the lower bound of the interval as an integer
+    mock_randrange = mock.Mock(return_value=int(expected_interval[0] * 1000000))
+
+    def mock_random_class(seed):
+        assert seed == "00000000000000000000000000000000", "seed should be the trace_id"
+        rv = Mock()
+        rv.randrange = mock_randrange
+        return rv
+
+    with mock.patch("sentry_sdk.tracing_utils.Random", mock_random_class):
+        ctx = PropagationContext().from_incoming_data(
+            {
+                "sentry-trace": f"00000000000000000000000000000000-0000000000000000{SAMPLED_FLAG[parent_sampled]}",
+                # Placeholder is needed, since we only add sample_rand if sentry items are present in baggage
+                "baggage": f"sentry-placeholder=asdf{sample_rate_str}",
+            }
+        )
+
+    assert (
+        ctx.dynamic_sampling_context["sample_rand"] == f"{expected_interval[0]:.6f}"  # noqa: E231
+    )
+    assert mock_randrange.call_count == 1
+    assert mock_randrange.call_args[0] == (
+        int(expected_interval[0] * 1000000),
+        int(expected_interval[1] * 1000000),
+    )
+
+
+def test_sample_rand_rounds_down():
+    # Mock value that should round down to 0.999_999
+    mock_randrange = mock.Mock(return_value=999999)
+
+    def mock_random_class(_):
+        rv = Mock()
+        rv.randrange = mock_randrange
+        return rv
+
+    with mock.patch("sentry_sdk.tracing_utils.Random", mock_random_class):
+        ctx = PropagationContext().from_incoming_data(
+            {
+                "sentry-trace": "00000000000000000000000000000000-0000000000000000",
+                "baggage": "sentry-placeholder=asdf",
+            }
+        )
+
+    assert ctx.dynamic_sampling_context["sample_rand"] == "0.999999"
diff --git a/tests/test_scope.py b/tests/test_scope.py
index 8bdd46e02f..86a0551a44 100644
--- a/tests/test_scope.py
+++ b/tests/test_scope.py
@@ -1,13 +1,24 @@
 import copy
 import os
 import pytest
-from sentry_sdk import capture_exception
-from sentry_sdk.scope import Scope
+from unittest import mock
 
-try:
-    from unittest import mock  # python 3.3 and above
-except ImportError:
-    import mock  # python < 3.3
+import sentry_sdk
+from sentry_sdk import (
+    capture_exception,
+    isolation_scope,
+    new_scope,
+)
+from sentry_sdk.client import Client, NonRecordingClient
+from sentry_sdk.scope import (
+    Scope,
+    ScopeType,
+    use_isolation_scope,
+    use_scope,
+    should_send_default_pii,
+    register_external_propagation_context,
+    remove_external_propagation_context,
+)
 
 
 def test_copying():
@@ -25,6 +36,37 @@ def test_copying():
     assert s1._fingerprint is s2._fingerprint
 
 
+def test_all_slots_copied():
+    scope = Scope()
+    scope_copy = copy.copy(scope)
+
+    # Check all attributes are copied
+    for attr in set(Scope.__slots__):
+        assert getattr(scope_copy, attr) == getattr(scope, attr)
+
+
+def test_scope_flags_copy():
+    # Assert forking creates a deepcopy of the flag buffer. The new
+    # scope is free to mutate without consequence to the old scope. The
+    # old scope is free to mutate without consequence to the new scope.
+    old_scope = Scope()
+    old_scope.flags.set("a", True)
+
+    new_scope = old_scope.fork()
+    new_scope.flags.set("a", False)
+    old_scope.flags.set("b", True)
+    new_scope.flags.set("c", True)
+
+    assert old_scope.flags.get() == [
+        {"flag": "a", "result": True},
+        {"flag": "b", "result": True},
+    ]
+    assert new_scope.flags.get() == [
+        {"flag": "a", "result": False},
+        {"flag": "c", "result": True},
+    ]
+
+
 def test_merging(sentry_init, capture_events):
     sentry_init()
 
@@ -157,3 +199,825 @@ def test_load_trace_data_from_env(env, excepted_value):
         s = Scope()
         incoming_trace_data = s._load_trace_data_from_env()
         assert incoming_trace_data == excepted_value
+
+
+def test_scope_client():
+    scope = Scope(ty="test_something")
+    assert scope._type == "test_something"
+    assert scope.client is not None
+    assert scope.client.__class__ == NonRecordingClient
+
+    custom_client = Client()
+    scope = Scope(ty="test_more", client=custom_client)
+    assert scope._type == "test_more"
+    assert scope.client is not None
+    assert scope.client.__class__ == Client
+    assert scope.client == custom_client
+
+
+def test_get_current_scope():
+    scope = Scope.get_current_scope()
+    assert scope is not None
+    assert scope.__class__ == Scope
+    assert scope._type == ScopeType.CURRENT
+
+
+def test_get_isolation_scope():
+    scope = Scope.get_isolation_scope()
+    assert scope is not None
+    assert scope.__class__ == Scope
+    assert scope._type == ScopeType.ISOLATION
+
+
+def test_get_global_scope():
+    scope = Scope.get_global_scope()
+    assert scope is not None
+    assert scope.__class__ == Scope
+    assert scope._type == ScopeType.GLOBAL
+
+
+def test_get_client():
+    client = Scope.get_client()
+    assert client is not None
+    assert client.__class__ == NonRecordingClient
+    assert not client.is_active()
+
+
+def test_set_client():
+    client1 = Client()
+    client2 = Client()
+    client3 = Client()
+
+    current_scope = Scope.get_current_scope()
+    isolation_scope = Scope.get_isolation_scope()
+    global_scope = Scope.get_global_scope()
+
+    current_scope.set_client(client1)
+    isolation_scope.set_client(client2)
+    global_scope.set_client(client3)
+
+    client = Scope.get_client()
+    assert client == client1
+
+    current_scope.set_client(None)
+    isolation_scope.set_client(client2)
+    global_scope.set_client(client3)
+
+    client = Scope.get_client()
+    assert client == client2
+
+    current_scope.set_client(None)
+    isolation_scope.set_client(None)
+    global_scope.set_client(client3)
+
+    client = Scope.get_client()
+    assert client == client3
+
+
+def test_fork():
+    scope = Scope()
+    forked_scope = scope.fork()
+
+    assert scope != forked_scope
+
+
+def test_get_global_scope_tags():
+    global_scope1 = Scope.get_global_scope()
+    global_scope2 = Scope.get_global_scope()
+    assert global_scope1 == global_scope2
+    assert global_scope1.client.__class__ == NonRecordingClient
+    assert not global_scope1.client.is_active()
+    assert global_scope2.client.__class__ == NonRecordingClient
+    assert not global_scope2.client.is_active()
+
+    global_scope1.set_tag("tag1", "value")
+    tags_scope1 = global_scope1._tags
+    tags_scope2 = global_scope2._tags
+    assert tags_scope1 == tags_scope2 == {"tag1": "value"}
+    assert global_scope1.client.__class__ == NonRecordingClient
+    assert not global_scope1.client.is_active()
+    assert global_scope2.client.__class__ == NonRecordingClient
+    assert not global_scope2.client.is_active()
+
+
+def test_get_global_with_scope():
+    original_global_scope = Scope.get_global_scope()
+
+    with new_scope() as scope:
+        in_with_global_scope = Scope.get_global_scope()
+
+        assert scope is not in_with_global_scope
+        assert in_with_global_scope is original_global_scope
+
+    after_with_global_scope = Scope.get_global_scope()
+    assert after_with_global_scope is original_global_scope
+
+
+def test_get_global_with_isolation_scope():
+    original_global_scope = Scope.get_global_scope()
+
+    with isolation_scope() as scope:
+        in_with_global_scope = Scope.get_global_scope()
+
+        assert scope is not in_with_global_scope
+        assert in_with_global_scope is original_global_scope
+
+    after_with_global_scope = Scope.get_global_scope()
+    assert after_with_global_scope is original_global_scope
+
+
+def test_get_isolation_scope_tags():
+    isolation_scope1 = Scope.get_isolation_scope()
+    isolation_scope2 = Scope.get_isolation_scope()
+    assert isolation_scope1 == isolation_scope2
+    assert isolation_scope1.client.__class__ == NonRecordingClient
+    assert not isolation_scope1.client.is_active()
+    assert isolation_scope2.client.__class__ == NonRecordingClient
+    assert not isolation_scope2.client.is_active()
+
+    isolation_scope1.set_tag("tag1", "value")
+    tags_scope1 = isolation_scope1._tags
+    tags_scope2 = isolation_scope2._tags
+    assert tags_scope1 == tags_scope2 == {"tag1": "value"}
+    assert isolation_scope1.client.__class__ == NonRecordingClient
+    assert not isolation_scope1.client.is_active()
+    assert isolation_scope2.client.__class__ == NonRecordingClient
+    assert not isolation_scope2.client.is_active()
+
+
+def test_get_current_scope_tags():
+    scope1 = Scope.get_current_scope()
+    scope2 = Scope.get_current_scope()
+    assert id(scope1) == id(scope2)
+    assert scope1.client.__class__ == NonRecordingClient
+    assert not scope1.client.is_active()
+    assert scope2.client.__class__ == NonRecordingClient
+    assert not scope2.client.is_active()
+
+    scope1.set_tag("tag1", "value")
+    tags_scope1 = scope1._tags
+    tags_scope2 = scope2._tags
+    assert tags_scope1 == tags_scope2 == {"tag1": "value"}
+    assert scope1.client.__class__ == NonRecordingClient
+    assert not scope1.client.is_active()
+    assert scope2.client.__class__ == NonRecordingClient
+    assert not scope2.client.is_active()
+
+
+def test_with_isolation_scope():
+    original_current_scope = Scope.get_current_scope()
+    original_isolation_scope = Scope.get_isolation_scope()
+
+    with isolation_scope() as scope:
+        assert scope._type == ScopeType.ISOLATION
+
+        in_with_current_scope = Scope.get_current_scope()
+        in_with_isolation_scope = Scope.get_isolation_scope()
+
+        assert scope is in_with_isolation_scope
+        assert in_with_current_scope is not original_current_scope
+        assert in_with_isolation_scope is not original_isolation_scope
+
+    after_with_current_scope = Scope.get_current_scope()
+    after_with_isolation_scope = Scope.get_isolation_scope()
+    assert after_with_current_scope is original_current_scope
+    assert after_with_isolation_scope is original_isolation_scope
+
+
+def test_with_isolation_scope_data():
+    """
+    When doing `with isolation_scope()` the isolation *and* the current scope are forked,
+    to prevent that by setting tags on the current scope in the context manager, data
+    bleeds to the outer current scope.
+    """
+    isolation_scope_before = Scope.get_isolation_scope()
+    current_scope_before = Scope.get_current_scope()
+
+    isolation_scope_before.set_tag("before_isolation_scope", 1)
+    current_scope_before.set_tag("before_current_scope", 1)
+
+    with isolation_scope() as scope:
+        assert scope._type == ScopeType.ISOLATION
+
+        isolation_scope_in = Scope.get_isolation_scope()
+        current_scope_in = Scope.get_current_scope()
+
+        assert isolation_scope_in._tags == {"before_isolation_scope": 1}
+        assert current_scope_in._tags == {"before_current_scope": 1}
+        assert scope._tags == {"before_isolation_scope": 1}
+
+        scope.set_tag("in_with_scope", 1)
+
+        assert isolation_scope_in._tags == {
+            "before_isolation_scope": 1,
+            "in_with_scope": 1,
+        }
+        assert current_scope_in._tags == {"before_current_scope": 1}
+        assert scope._tags == {"before_isolation_scope": 1, "in_with_scope": 1}
+
+        isolation_scope_in.set_tag("in_with_isolation_scope", 1)
+
+        assert isolation_scope_in._tags == {
+            "before_isolation_scope": 1,
+            "in_with_scope": 1,
+            "in_with_isolation_scope": 1,
+        }
+        assert current_scope_in._tags == {"before_current_scope": 1}
+        assert scope._tags == {
+            "before_isolation_scope": 1,
+            "in_with_scope": 1,
+            "in_with_isolation_scope": 1,
+        }
+
+        current_scope_in.set_tag("in_with_current_scope", 1)
+
+        assert isolation_scope_in._tags == {
+            "before_isolation_scope": 1,
+            "in_with_scope": 1,
+            "in_with_isolation_scope": 1,
+        }
+        assert current_scope_in._tags == {
+            "before_current_scope": 1,
+            "in_with_current_scope": 1,
+        }
+        assert scope._tags == {
+            "before_isolation_scope": 1,
+            "in_with_scope": 1,
+            "in_with_isolation_scope": 1,
+        }
+
+    isolation_scope_after = Scope.get_isolation_scope()
+    current_scope_after = Scope.get_current_scope()
+
+    isolation_scope_after.set_tag("after_isolation_scope", 1)
+
+    assert isolation_scope_after._tags == {
+        "before_isolation_scope": 1,
+        "after_isolation_scope": 1,
+    }
+    assert current_scope_after._tags == {"before_current_scope": 1}
+
+    current_scope_after.set_tag("after_current_scope", 1)
+
+    assert isolation_scope_after._tags == {
+        "before_isolation_scope": 1,
+        "after_isolation_scope": 1,
+    }
+    assert current_scope_after._tags == {
+        "before_current_scope": 1,
+        "after_current_scope": 1,
+    }
+
+
+def test_with_use_isolation_scope():
+    original_isolation_scope = Scope.get_isolation_scope()
+    original_current_scope = Scope.get_current_scope()
+    custom_isolation_scope = Scope()
+
+    with use_isolation_scope(custom_isolation_scope) as scope:
+        assert scope._type is None  # our custom scope has not type set
+
+        in_with_isolation_scope = Scope.get_isolation_scope()
+        in_with_current_scope = Scope.get_current_scope()
+
+        assert scope is custom_isolation_scope
+        assert scope is in_with_isolation_scope
+        assert scope is not in_with_current_scope
+        assert scope is not original_isolation_scope
+        assert scope is not original_current_scope
+        assert in_with_isolation_scope is not original_isolation_scope
+        assert in_with_current_scope is not original_current_scope
+
+    after_with_current_scope = Scope.get_current_scope()
+    after_with_isolation_scope = Scope.get_isolation_scope()
+
+    assert after_with_isolation_scope is original_isolation_scope
+    assert after_with_current_scope is original_current_scope
+    assert after_with_isolation_scope is not custom_isolation_scope
+    assert after_with_current_scope is not custom_isolation_scope
+
+
+def test_with_use_isolation_scope_data():
+    isolation_scope_before = Scope.get_isolation_scope()
+    current_scope_before = Scope.get_current_scope()
+    custom_isolation_scope = Scope()
+
+    isolation_scope_before.set_tag("before_isolation_scope", 1)
+    current_scope_before.set_tag("before_current_scope", 1)
+    custom_isolation_scope.set_tag("before_custom_isolation_scope", 1)
+
+    with use_isolation_scope(custom_isolation_scope) as scope:
+        assert scope._type is None  # our custom scope has not type set
+
+        isolation_scope_in = Scope.get_isolation_scope()
+        current_scope_in = Scope.get_current_scope()
+
+        assert isolation_scope_in._tags == {"before_custom_isolation_scope": 1}
+        assert current_scope_in._tags == {"before_current_scope": 1}
+        assert scope._tags == {"before_custom_isolation_scope": 1}
+
+        scope.set_tag("in_with_scope", 1)
+
+        assert isolation_scope_in._tags == {
+            "before_custom_isolation_scope": 1,
+            "in_with_scope": 1,
+        }
+        assert current_scope_in._tags == {"before_current_scope": 1}
+        assert scope._tags == {"before_custom_isolation_scope": 1, "in_with_scope": 1}
+
+        isolation_scope_in.set_tag("in_with_isolation_scope", 1)
+
+        assert isolation_scope_in._tags == {
+            "before_custom_isolation_scope": 1,
+            "in_with_scope": 1,
+            "in_with_isolation_scope": 1,
+        }
+        assert current_scope_in._tags == {"before_current_scope": 1}
+        assert scope._tags == {
+            "before_custom_isolation_scope": 1,
+            "in_with_scope": 1,
+            "in_with_isolation_scope": 1,
+        }
+
+        current_scope_in.set_tag("in_with_current_scope", 1)
+
+        assert isolation_scope_in._tags == {
+            "before_custom_isolation_scope": 1,
+            "in_with_scope": 1,
+            "in_with_isolation_scope": 1,
+        }
+        assert current_scope_in._tags == {
+            "before_current_scope": 1,
+            "in_with_current_scope": 1,
+        }
+        assert scope._tags == {
+            "before_custom_isolation_scope": 1,
+            "in_with_scope": 1,
+            "in_with_isolation_scope": 1,
+        }
+
+    assert custom_isolation_scope._tags == {
+        "before_custom_isolation_scope": 1,
+        "in_with_scope": 1,
+        "in_with_isolation_scope": 1,
+    }
+    isolation_scope_after = Scope.get_isolation_scope()
+    current_scope_after = Scope.get_current_scope()
+
+    isolation_scope_after.set_tag("after_isolation_scope", 1)
+
+    assert isolation_scope_after._tags == {
+        "before_isolation_scope": 1,
+        "after_isolation_scope": 1,
+    }
+    assert current_scope_after._tags == {"before_current_scope": 1}
+    assert custom_isolation_scope._tags == {
+        "before_custom_isolation_scope": 1,
+        "in_with_scope": 1,
+        "in_with_isolation_scope": 1,
+    }
+
+    current_scope_after.set_tag("after_current_scope", 1)
+
+    assert isolation_scope_after._tags == {
+        "before_isolation_scope": 1,
+        "after_isolation_scope": 1,
+    }
+    assert current_scope_after._tags == {
+        "before_current_scope": 1,
+        "after_current_scope": 1,
+    }
+    assert custom_isolation_scope._tags == {
+        "before_custom_isolation_scope": 1,
+        "in_with_scope": 1,
+        "in_with_isolation_scope": 1,
+    }
+
+
+def test_with_new_scope():
+    original_current_scope = Scope.get_current_scope()
+    original_isolation_scope = Scope.get_isolation_scope()
+
+    with new_scope() as scope:
+        assert scope._type == ScopeType.CURRENT
+
+        in_with_current_scope = Scope.get_current_scope()
+        in_with_isolation_scope = Scope.get_isolation_scope()
+
+        assert scope is in_with_current_scope
+        assert in_with_current_scope is not original_current_scope
+        assert in_with_isolation_scope is original_isolation_scope
+
+    after_with_current_scope = Scope.get_current_scope()
+    after_with_isolation_scope = Scope.get_isolation_scope()
+    assert after_with_current_scope is original_current_scope
+    assert after_with_isolation_scope is original_isolation_scope
+
+
+def test_with_new_scope_data():
+    """
+    When doing `with new_scope()` the current scope is forked but the isolation
+    scope stays untouched.
+    """
+    isolation_scope_before = Scope.get_isolation_scope()
+    current_scope_before = Scope.get_current_scope()
+
+    isolation_scope_before.set_tag("before_isolation_scope", 1)
+    current_scope_before.set_tag("before_current_scope", 1)
+
+    with new_scope() as scope:
+        assert scope._type == ScopeType.CURRENT
+
+        isolation_scope_in = Scope.get_isolation_scope()
+        current_scope_in = Scope.get_current_scope()
+
+        assert isolation_scope_in._tags == {"before_isolation_scope": 1}
+        assert current_scope_in._tags == {"before_current_scope": 1}
+        assert scope._tags == {"before_current_scope": 1}
+
+        scope.set_tag("in_with_scope", 1)
+
+        assert isolation_scope_in._tags == {"before_isolation_scope": 1}
+        assert current_scope_in._tags == {"before_current_scope": 1, "in_with_scope": 1}
+        assert scope._tags == {"before_current_scope": 1, "in_with_scope": 1}
+
+        isolation_scope_in.set_tag("in_with_isolation_scope", 1)
+
+        assert isolation_scope_in._tags == {
+            "before_isolation_scope": 1,
+            "in_with_isolation_scope": 1,
+        }
+        assert current_scope_in._tags == {"before_current_scope": 1, "in_with_scope": 1}
+        assert scope._tags == {"before_current_scope": 1, "in_with_scope": 1}
+
+        current_scope_in.set_tag("in_with_current_scope", 1)
+
+        assert isolation_scope_in._tags == {
+            "before_isolation_scope": 1,
+            "in_with_isolation_scope": 1,
+        }
+        assert current_scope_in._tags == {
+            "before_current_scope": 1,
+            "in_with_scope": 1,
+            "in_with_current_scope": 1,
+        }
+        assert scope._tags == {
+            "before_current_scope": 1,
+            "in_with_scope": 1,
+            "in_with_current_scope": 1,
+        }
+
+    isolation_scope_after = Scope.get_isolation_scope()
+    current_scope_after = Scope.get_current_scope()
+
+    isolation_scope_after.set_tag("after_isolation_scope", 1)
+
+    assert isolation_scope_after._tags == {
+        "before_isolation_scope": 1,
+        "in_with_isolation_scope": 1,
+        "after_isolation_scope": 1,
+    }
+    assert current_scope_after._tags == {"before_current_scope": 1}
+
+    current_scope_after.set_tag("after_current_scope", 1)
+
+    assert isolation_scope_after._tags == {
+        "before_isolation_scope": 1,
+        "in_with_isolation_scope": 1,
+        "after_isolation_scope": 1,
+    }
+    assert current_scope_after._tags == {
+        "before_current_scope": 1,
+        "after_current_scope": 1,
+    }
+
+
+def test_with_use_scope_data():
+    isolation_scope_before = Scope.get_isolation_scope()
+    current_scope_before = Scope.get_current_scope()
+    custom_current_scope = Scope()
+
+    isolation_scope_before.set_tag("before_isolation_scope", 1)
+    current_scope_before.set_tag("before_current_scope", 1)
+    custom_current_scope.set_tag("before_custom_current_scope", 1)
+
+    with use_scope(custom_current_scope) as scope:
+        assert scope._type is None  # our custom scope has not type set
+
+        isolation_scope_in = Scope.get_isolation_scope()
+        current_scope_in = Scope.get_current_scope()
+
+        assert isolation_scope_in._tags == {"before_isolation_scope": 1}
+        assert current_scope_in._tags == {"before_custom_current_scope": 1}
+        assert scope._tags == {"before_custom_current_scope": 1}
+
+        scope.set_tag("in_with_scope", 1)
+
+        assert isolation_scope_in._tags == {"before_isolation_scope": 1}
+        assert current_scope_in._tags == {
+            "before_custom_current_scope": 1,
+            "in_with_scope": 1,
+        }
+        assert scope._tags == {"before_custom_current_scope": 1, "in_with_scope": 1}
+
+        isolation_scope_in.set_tag("in_with_isolation_scope", 1)
+
+        assert isolation_scope_in._tags == {
+            "before_isolation_scope": 1,
+            "in_with_isolation_scope": 1,
+        }
+        assert current_scope_in._tags == {
+            "before_custom_current_scope": 1,
+            "in_with_scope": 1,
+        }
+        assert scope._tags == {"before_custom_current_scope": 1, "in_with_scope": 1}
+
+        current_scope_in.set_tag("in_with_current_scope", 1)
+
+        assert isolation_scope_in._tags == {
+            "before_isolation_scope": 1,
+            "in_with_isolation_scope": 1,
+        }
+        assert current_scope_in._tags == {
+            "before_custom_current_scope": 1,
+            "in_with_scope": 1,
+            "in_with_current_scope": 1,
+        }
+        assert scope._tags == {
+            "before_custom_current_scope": 1,
+            "in_with_scope": 1,
+            "in_with_current_scope": 1,
+        }
+
+    assert custom_current_scope._tags == {
+        "before_custom_current_scope": 1,
+        "in_with_scope": 1,
+        "in_with_current_scope": 1,
+    }
+    isolation_scope_after = Scope.get_isolation_scope()
+    current_scope_after = Scope.get_current_scope()
+
+    isolation_scope_after.set_tag("after_isolation_scope", 1)
+
+    assert isolation_scope_after._tags == {
+        "before_isolation_scope": 1,
+        "after_isolation_scope": 1,
+        "in_with_isolation_scope": 1,
+    }
+    assert current_scope_after._tags == {"before_current_scope": 1}
+    assert custom_current_scope._tags == {
+        "before_custom_current_scope": 1,
+        "in_with_scope": 1,
+        "in_with_current_scope": 1,
+    }
+
+    current_scope_after.set_tag("after_current_scope", 1)
+
+    assert isolation_scope_after._tags == {
+        "before_isolation_scope": 1,
+        "in_with_isolation_scope": 1,
+        "after_isolation_scope": 1,
+    }
+    assert current_scope_after._tags == {
+        "before_current_scope": 1,
+        "after_current_scope": 1,
+    }
+    assert custom_current_scope._tags == {
+        "before_custom_current_scope": 1,
+        "in_with_scope": 1,
+        "in_with_current_scope": 1,
+    }
+
+
+def test_nested_scopes_with_tags(sentry_init, capture_envelopes):
+    sentry_init(traces_sample_rate=1.0)
+    envelopes = capture_envelopes()
+
+    with sentry_sdk.isolation_scope() as scope1:
+        scope1.set_tag("isolation_scope1", 1)
+
+        with sentry_sdk.new_scope() as scope2:
+            scope2.set_tag("current_scope2", 1)
+
+            with sentry_sdk.start_transaction(name="trx") as trx:
+                trx.set_tag("trx", 1)
+
+                with sentry_sdk.start_span(op="span1") as span1:
+                    span1.set_tag("a", 1)
+
+                    with new_scope() as scope3:
+                        scope3.set_tag("current_scope3", 1)
+
+                        with sentry_sdk.start_span(op="span2") as span2:
+                            span2.set_tag("b", 1)
+
+    (envelope,) = envelopes
+    transaction = envelope.items[0].get_transaction_event()
+
+    assert transaction["tags"] == {"isolation_scope1": 1, "current_scope2": 1, "trx": 1}
+    assert transaction["spans"][0]["tags"] == {"a": 1}
+    assert transaction["spans"][1]["tags"] == {"b": 1}
+
+
+def test_should_send_default_pii_true(sentry_init):
+    sentry_init(send_default_pii=True)
+
+    assert should_send_default_pii() is True
+
+
+def test_should_send_default_pii_false(sentry_init):
+    sentry_init(send_default_pii=False)
+
+    assert should_send_default_pii() is False
+
+
+def test_should_send_default_pii_default_false(sentry_init):
+    sentry_init()
+
+    assert should_send_default_pii() is False
+
+
+def test_should_send_default_pii_false_with_dsn_and_spotlight(sentry_init):
+    sentry_init(dsn="http://key@localhost/1", spotlight=True)
+
+    assert should_send_default_pii() is False
+
+
+def test_should_send_default_pii_true_without_dsn_and_spotlight(sentry_init):
+    sentry_init(spotlight=True)
+
+    assert should_send_default_pii() is True
+
+
+def test_set_tags():
+    scope = Scope()
+    scope.set_tags({"tag1": "value1", "tag2": "value2"})
+    event = scope.apply_to_event({}, {})
+
+    assert event["tags"] == {"tag1": "value1", "tag2": "value2"}, "Setting tags failed"
+
+    scope.set_tags({"tag2": "updated", "tag3": "new"})
+    event = scope.apply_to_event({}, {})
+
+    assert event["tags"] == {
+        "tag1": "value1",
+        "tag2": "updated",
+        "tag3": "new",
+    }, "Updating tags failed"
+
+    scope.set_tags({})
+    event = scope.apply_to_event({}, {})
+
+    assert event["tags"] == {
+        "tag1": "value1",
+        "tag2": "updated",
+        "tag3": "new",
+    }, "Updating tags with empty dict changed tags"
+
+
+def test_last_event_id(sentry_init):
+    sentry_init(enable_tracing=True)
+
+    assert Scope.last_event_id() is None
+
+    sentry_sdk.capture_exception(Exception("test"))
+
+    assert Scope.last_event_id() is not None
+
+
+def test_last_event_id_transaction(sentry_init):
+    sentry_init(enable_tracing=True)
+
+    assert Scope.last_event_id() is None
+
+    with sentry_sdk.start_transaction(name="test"):
+        pass
+
+    assert Scope.last_event_id() is None, "Transaction should not set last_event_id"
+
+
+def test_last_event_id_cleared(sentry_init):
+    sentry_init(enable_tracing=True)
+
+    # Make sure last_event_id is set
+    sentry_sdk.capture_exception(Exception("test"))
+    assert Scope.last_event_id() is not None
+
+    # Clearing the isolation scope should clear the last_event_id
+    Scope.get_isolation_scope().clear()
+
+    assert Scope.last_event_id() is None, "last_event_id should be cleared"
+
+
+@pytest.mark.tests_internal_exceptions
+@pytest.mark.parametrize("error_cls", [LookupError, ValueError])
+@pytest.mark.parametrize(
+    "scope_manager",
+    [
+        new_scope,
+        use_scope,
+    ],
+)
+def test_handle_error_on_token_reset_current_scope(error_cls, scope_manager):
+    with mock.patch("sentry_sdk.scope.capture_internal_exception") as mock_capture:
+        with mock.patch("sentry_sdk.scope._current_scope") as mock_token_var:
+            mock_token_var.reset.side_effect = error_cls()
+
+            mock_token = mock.Mock()
+            mock_token_var.set.return_value = mock_token
+
+            try:
+                if scope_manager == use_scope:
+                    with scope_manager(Scope()):
+                        pass
+                else:
+                    with scope_manager():
+                        pass
+
+            except Exception:
+                pytest.fail(f"Context manager should handle {error_cls} gracefully")
+
+            mock_capture.assert_called_once()
+            mock_token_var.reset.assert_called_once_with(mock_token)
+
+
+@pytest.mark.tests_internal_exceptions
+@pytest.mark.parametrize("error_cls", [LookupError, ValueError])
+@pytest.mark.parametrize(
+    "scope_manager",
+    [
+        isolation_scope,
+        use_isolation_scope,
+    ],
+)
+def test_handle_error_on_token_reset_isolation_scope(error_cls, scope_manager):
+    with mock.patch("sentry_sdk.scope.capture_internal_exception") as mock_capture:
+        with mock.patch("sentry_sdk.scope._current_scope") as mock_current_scope:
+            with mock.patch(
+                "sentry_sdk.scope._isolation_scope"
+            ) as mock_isolation_scope:
+                mock_isolation_scope.reset.side_effect = error_cls()
+                mock_current_token = mock.Mock()
+                mock_current_scope.set.return_value = mock_current_token
+
+                try:
+                    if scope_manager == use_isolation_scope:
+                        with scope_manager(Scope()):
+                            pass
+                    else:
+                        with scope_manager():
+                            pass
+
+                except Exception:
+                    pytest.fail(f"Context manager should handle {error_cls} gracefully")
+
+                mock_capture.assert_called_once()
+                mock_current_scope.reset.assert_called_once_with(mock_current_token)
+
+
+def test_trace_context_tracing(sentry_init):
+    sentry_init(traces_sample_rate=1.0)
+
+    with sentry_sdk.start_transaction(name="trx") as transaction:
+        with sentry_sdk.start_span(op="span1"):
+            with sentry_sdk.start_span(op="span2") as span:
+                trace_context = sentry_sdk.get_current_scope().get_trace_context()
+
+    assert trace_context["trace_id"] == transaction.trace_id
+    assert trace_context["span_id"] == span.span_id
+    assert trace_context["parent_span_id"] == span.parent_span_id
+    assert "dynamic_sampling_context" in trace_context
+
+
+def test_trace_context_external_tracing(sentry_init):
+    sentry_init()
+
+    def external_propagation_context():
+        return ("trace_id_foo", "span_id_bar")
+
+    register_external_propagation_context(external_propagation_context)
+
+    scope = sentry_sdk.get_current_scope()
+
+    trace_context = scope.get_trace_context()
+    assert trace_context["trace_id"] == "trace_id_foo"
+    assert trace_context["span_id"] == "span_id_bar"
+
+    headers = list(scope.iter_trace_propagation_headers())
+    assert not headers
+
+    remove_external_propagation_context()
+
+
+def test_trace_context_without_performance(sentry_init):
+    sentry_init()
+
+    with sentry_sdk.isolation_scope() as isolation_scope:
+        trace_context = sentry_sdk.get_current_scope().get_trace_context()
+
+    propagation_context = isolation_scope._propagation_context
+    assert propagation_context is not None
+    assert trace_context["trace_id"] == propagation_context.trace_id
+    assert trace_context["span_id"] == propagation_context.span_id
+    assert trace_context["parent_span_id"] == propagation_context.parent_span_id
+    assert "dynamic_sampling_context" in trace_context
diff --git a/tests/test_scrubber.py b/tests/test_scrubber.py
index 4b2dfff450..2cc5f4139f 100644
--- a/tests/test_scrubber.py
+++ b/tests/test_scrubber.py
@@ -4,6 +4,7 @@
 from sentry_sdk import capture_exception, capture_event, start_transaction, start_span
 from sentry_sdk.utils import event_from_exception
 from sentry_sdk.scrubber import EventScrubber
+from tests.conftest import ApproxDict
 
 
 logger = logging.getLogger(__name__)
@@ -24,6 +25,7 @@ def test_request_scrubbing(sentry_init, capture_events):
                 "COOKIE": "secret",
                 "authorization": "Bearer bla",
                 "ORIGIN": "google.com",
+                "ip_address": "127.0.0.1",
             },
             "cookies": {
                 "sessionid": "secret",
@@ -44,6 +46,7 @@ def test_request_scrubbing(sentry_init, capture_events):
             "COOKIE": "[Filtered]",
             "authorization": "[Filtered]",
             "ORIGIN": "google.com",
+            "ip_address": "[Filtered]",
         },
         "cookies": {"sessionid": "[Filtered]", "foo": "bar"},
         "data": {"token": "[Filtered]", "foo": "bar"},
@@ -53,12 +56,39 @@ def test_request_scrubbing(sentry_init, capture_events):
         "headers": {
             "COOKIE": {"": {"rem": [["!config", "s"]]}},
             "authorization": {"": {"rem": [["!config", "s"]]}},
+            "ip_address": {"": {"rem": [["!config", "s"]]}},
         },
         "cookies": {"sessionid": {"": {"rem": [["!config", "s"]]}}},
         "data": {"token": {"": {"rem": [["!config", "s"]]}}},
     }
 
 
+def test_ip_address_not_scrubbed_when_pii_enabled(sentry_init, capture_events):
+    sentry_init(send_default_pii=True)
+    events = capture_events()
+
+    try:
+        1 / 0
+    except ZeroDivisionError:
+        ev, _hint = event_from_exception(sys.exc_info())
+
+        ev["request"] = {"headers": {"COOKIE": "secret", "ip_address": "127.0.0.1"}}
+
+        capture_event(ev)
+
+    (event,) = events
+
+    assert event["request"] == {
+        "headers": {"COOKIE": "[Filtered]", "ip_address": "127.0.0.1"}
+    }
+
+    assert event["_meta"]["request"] == {
+        "headers": {
+            "COOKIE": {"": {"rem": [["!config", "s"]]}},
+        }
+    }
+
+
 def test_stack_var_scrubbing(sentry_init, capture_events):
     sentry_init()
     events = capture_events()
@@ -89,25 +119,33 @@ def test_stack_var_scrubbing(sentry_init, capture_events):
 
 
 def test_breadcrumb_extra_scrubbing(sentry_init, capture_events):
-    sentry_init()
+    sentry_init(max_breadcrumbs=2)
     events = capture_events()
-
-    logger.info("bread", extra=dict(foo=42, password="secret"))
+    logger.info("breadcrumb 1", extra=dict(foo=1, password="secret"))
+    logger.info("breadcrumb 2", extra=dict(bar=2, auth="secret"))
+    logger.info("breadcrumb 3", extra=dict(foobar=3, password="secret"))
     logger.critical("whoops", extra=dict(bar=69, auth="secret"))
 
     (event,) = events
 
     assert event["extra"]["bar"] == 69
     assert event["extra"]["auth"] == "[Filtered]"
-
     assert event["breadcrumbs"]["values"][0]["data"] == {
-        "foo": 42,
+        "bar": 2,
+        "auth": "[Filtered]",
+    }
+    assert event["breadcrumbs"]["values"][1]["data"] == {
+        "foobar": 3,
         "password": "[Filtered]",
     }
 
     assert event["_meta"]["extra"]["auth"] == {"": {"rem": [["!config", "s"]]}}
     assert event["_meta"]["breadcrumbs"] == {
-        "values": {"0": {"data": {"password": {"": {"rem": [["!config", "s"]]}}}}}
+        "": {"len": 3},
+        "values": {
+            "0": {"data": {"auth": {"": {"rem": [["!config", "s"]]}}}},
+            "1": {"data": {"password": {"": {"rem": [["!config", "s"]]}}}},
+        },
     }
 
 
@@ -116,23 +154,30 @@ def test_span_data_scrubbing(sentry_init, capture_events):
     events = capture_events()
 
     with start_transaction(name="hi"):
-        with start_span(op="foo", description="bar") as span:
+        with start_span(op="foo", name="bar") as span:
             span.set_data("password", "secret")
             span.set_data("datafoo", "databar")
 
     (event,) = events
-    assert event["spans"][0]["data"] == {"password": "[Filtered]", "datafoo": "databar"}
+    assert event["spans"][0]["data"] == ApproxDict(
+        {"password": "[Filtered]", "datafoo": "databar"}
+    )
     assert event["_meta"]["spans"] == {
         "0": {"data": {"password": {"": {"rem": [["!config", "s"]]}}}}
     }
 
 
 def test_custom_denylist(sentry_init, capture_events):
-    sentry_init(event_scrubber=EventScrubber(denylist=["my_sensitive_var"]))
+    sentry_init(
+        event_scrubber=EventScrubber(
+            denylist=["my_sensitive_var"], pii_denylist=["my_pii_var"]
+        )
+    )
     events = capture_events()
 
     try:
         my_sensitive_var = "secret"  # noqa
+        my_pii_var = "jane.doe"  # noqa
         safe = "keepthis"  # noqa
         1 / 0
     except ZeroDivisionError:
@@ -143,6 +188,7 @@ def test_custom_denylist(sentry_init, capture_events):
     frames = event["exception"]["values"][0]["stacktrace"]["frames"]
     (frame,) = frames
     assert frame["vars"]["my_sensitive_var"] == "[Filtered]"
+    assert frame["vars"]["my_pii_var"] == "[Filtered]"
     assert frame["vars"]["safe"] == "'keepthis'"
 
     meta = event["_meta"]["exception"]["values"]["0"]["stacktrace"]["frames"]["0"][
@@ -150,6 +196,7 @@ def test_custom_denylist(sentry_init, capture_events):
     ]
     assert meta == {
         "my_sensitive_var": {"": {"rem": [["!config", "s"]]}},
+        "my_pii_var": {"": {"rem": [["!config", "s"]]}},
     }
 
 
@@ -169,3 +216,35 @@ def test_scrubbing_doesnt_affect_local_vars(sentry_init, capture_events):
     (frame,) = frames
     assert frame["vars"]["password"] == "[Filtered]"
     assert password == "cat123"
+
+
+def test_recursive_event_scrubber(sentry_init, capture_events):
+    sentry_init(event_scrubber=EventScrubber(recursive=True))
+    events = capture_events()
+    complex_structure = {
+        "deep": {
+            "deeper": [{"deepest": {"password": "my_darkest_secret"}}],
+        },
+    }
+
+    capture_event({"extra": complex_structure})
+
+    (event,) = events
+    assert event["extra"]["deep"]["deeper"][0]["deepest"]["password"] == "'[Filtered]'"
+
+
+def test_recursive_scrubber_does_not_override_original(sentry_init, capture_events):
+    sentry_init(event_scrubber=EventScrubber(recursive=True))
+    events = capture_events()
+
+    data = {"csrf": "secret"}
+    try:
+        raise RuntimeError("An error")
+    except Exception:
+        capture_exception()
+
+    (event,) = events
+    frames = event["exception"]["values"][0]["stacktrace"]["frames"]
+    (frame,) = frames
+    assert data["csrf"] == "secret"
+    assert frame["vars"]["data"]["csrf"] == "[Filtered]"
diff --git a/tests/test_serializer.py b/tests/test_serializer.py
index ddc65c9b3e..2f44ba8a08 100644
--- a/tests/test_serializer.py
+++ b/tests/test_serializer.py
@@ -1,7 +1,8 @@
 import re
-import sys
+
 import pytest
 
+from sentry_sdk.consts import DEFAULT_MAX_VALUE_LENGTH
 from sentry_sdk.serializer import MAX_DATABAG_BREADTH, MAX_DATABAG_DEPTH, serialize
 
 try:
@@ -61,12 +62,9 @@ def inner(body, **kwargs):
 def test_bytes_serialization_decode(message_normalizer):
     binary = b"abc123\x80\xf0\x9f\x8d\x95"
     result = message_normalizer(binary, should_repr_strings=False)
-    # fmt: off
-    assert result == u"abc123\ufffd\U0001f355"
-    # fmt: on
+    assert result == "abc123\ufffd\U0001f355"
 
 
-@pytest.mark.xfail(sys.version_info < (3,), reason="Known safe_repr bugs in Py2.7")
 def test_bytes_serialization_repr(message_normalizer):
     binary = b"abc123\x80\xf0\x9f\x8d\x95"
     result = message_normalizer(binary, should_repr_strings=True)
@@ -76,12 +74,9 @@ def test_bytes_serialization_repr(message_normalizer):
 def test_bytearray_serialization_decode(message_normalizer):
     binary = bytearray(b"abc123\x80\xf0\x9f\x8d\x95")
     result = message_normalizer(binary, should_repr_strings=False)
-    # fmt: off
-    assert result == u"abc123\ufffd\U0001f355"
-    # fmt: on
+    assert result == "abc123\ufffd\U0001f355"
 
 
-@pytest.mark.xfail(sys.version_info < (3,), reason="Known safe_repr bugs in Py2.7")
 def test_bytearray_serialization_repr(message_normalizer):
     binary = bytearray(b"abc123\x80\xf0\x9f\x8d\x95")
     result = message_normalizer(binary, should_repr_strings=True)
@@ -120,6 +115,31 @@ def test_custom_mapping_doesnt_mess_with_mock(extra_normalizer):
     assert len(m.mock_calls) == 0
 
 
+def test_custom_repr(extra_normalizer):
+    class Foo:
+        pass
+
+    def custom_repr(value):
+        if isinstance(value, Foo):
+            return "custom"
+        else:
+            return value
+
+    result = extra_normalizer({"foo": Foo(), "string": "abc"}, custom_repr=custom_repr)
+    assert result == {"foo": "custom", "string": "abc"}
+
+
+def test_custom_repr_graceful_fallback_to_safe_repr(extra_normalizer):
+    class Foo:
+        pass
+
+    def custom_repr(value):
+        raise ValueError("oops")
+
+    result = extra_normalizer({"foo": Foo()}, custom_repr=custom_repr)
+    assert "Foo object" in result["foo"]
+
+
 def test_trim_databag_breadth(body_normalizer):
     data = {
         "key{}".format(i): "value{}".format(i) for i in range(MAX_DATABAG_BREADTH + 10)
@@ -147,11 +167,11 @@ def test_no_trimming_if_max_request_body_size_is_always(body_normalizer):
 
 
 def test_max_value_length_default(body_normalizer):
-    data = {"key": "a" * 2000}
+    data = {"key": "a" * (DEFAULT_MAX_VALUE_LENGTH * 10)}
 
     result = body_normalizer(data)
 
-    assert len(result["key"]) == 1024  # fallback max length
+    assert len(result["key"]) == DEFAULT_MAX_VALUE_LENGTH  # fallback max length
 
 
 def test_max_value_length(body_normalizer):
diff --git a/tests/test_sessions.py b/tests/test_sessions.py
index 09b42b70a4..731b188727 100644
--- a/tests/test_sessions.py
+++ b/tests/test_sessions.py
@@ -1,7 +1,7 @@
-import sentry_sdk
+from unittest import mock
 
-from sentry_sdk import Hub
-from sentry_sdk.sessions import auto_session_tracking
+import sentry_sdk
+from sentry_sdk.sessions import auto_session_tracking, track_session
 
 
 def sorted_aggregates(item):
@@ -14,17 +14,17 @@ def test_basic(sentry_init, capture_envelopes):
     sentry_init(release="fun-release", environment="not-fun-env")
     envelopes = capture_envelopes()
 
-    hub = Hub.current
-    hub.start_session()
+    sentry_sdk.get_isolation_scope().start_session()
 
     try:
-        with hub.configure_scope() as scope:
-            scope.set_user({"id": "42"})
-            raise Exception("all is wrong")
+        scope = sentry_sdk.get_current_scope()
+        scope.set_user({"id": "42"})
+        raise Exception("all is wrong")
     except Exception:
-        hub.capture_exception()
-    hub.end_session()
-    hub.flush()
+        sentry_sdk.capture_exception()
+
+    sentry_sdk.get_isolation_scope().end_session()
+    sentry_sdk.flush()
 
     assert len(envelopes) == 2
     assert envelopes[0].get_event() is not None
@@ -50,23 +50,61 @@ def test_aggregates(sentry_init, capture_envelopes):
     )
     envelopes = capture_envelopes()
 
-    hub = Hub.current
+    with sentry_sdk.isolation_scope() as scope:
+        with track_session(scope, session_mode="request"):
+            try:
+                scope.set_user({"id": "42"})
+                raise Exception("all is wrong")
+            except Exception:
+                sentry_sdk.capture_exception()
+
+    with sentry_sdk.isolation_scope() as scope:
+        with track_session(scope, session_mode="request"):
+            pass
+
+    sentry_sdk.get_isolation_scope().start_session(session_mode="request")
+    sentry_sdk.get_isolation_scope().end_session()
+    sentry_sdk.flush()
+
+    assert len(envelopes) == 2
+    assert envelopes[0].get_event() is not None
+
+    sess = envelopes[1]
+    assert len(sess.items) == 1
+    sess_event = sess.items[0].payload.json
+    assert sess_event["attrs"] == {
+        "release": "fun-release",
+        "environment": "not-fun-env",
+    }
+
+    aggregates = sorted_aggregates(sess_event)
+    assert len(aggregates) == 1
+    assert aggregates[0]["exited"] == 2
+    assert aggregates[0]["errored"] == 1
+
+
+def test_aggregates_deprecated(
+    sentry_init, capture_envelopes, suppress_deprecation_warnings
+):
+    sentry_init(
+        release="fun-release",
+        environment="not-fun-env",
+    )
+    envelopes = capture_envelopes()
 
     with auto_session_tracking(session_mode="request"):
-        with sentry_sdk.push_scope():
+        with sentry_sdk.new_scope() as scope:
             try:
-                with sentry_sdk.configure_scope() as scope:
-                    scope.set_user({"id": "42"})
-                    raise Exception("all is wrong")
+                scope.set_user({"id": "42"})
+                raise Exception("all is wrong")
             except Exception:
                 sentry_sdk.capture_exception()
 
     with auto_session_tracking(session_mode="request"):
         pass
 
-    hub.start_session(session_mode="request")
-    hub.end_session()
-
+    sentry_sdk.get_isolation_scope().start_session(session_mode="request")
+    sentry_sdk.get_isolation_scope().end_session()
     sentry_sdk.flush()
 
     assert len(envelopes) == 2
@@ -94,10 +132,41 @@ def test_aggregates_explicitly_disabled_session_tracking_request_mode(
     )
     envelopes = capture_envelopes()
 
-    hub = Hub.current
+    with sentry_sdk.isolation_scope() as scope:
+        with track_session(scope, session_mode="request"):
+            try:
+                raise Exception("all is wrong")
+            except Exception:
+                sentry_sdk.capture_exception()
+
+    with sentry_sdk.isolation_scope() as scope:
+        with track_session(scope, session_mode="request"):
+            pass
+
+    sentry_sdk.get_isolation_scope().start_session(session_mode="request")
+    sentry_sdk.get_isolation_scope().end_session()
+    sentry_sdk.flush()
+
+    sess = envelopes[1]
+    assert len(sess.items) == 1
+    sess_event = sess.items[0].payload.json
+
+    aggregates = sorted_aggregates(sess_event)
+    assert len(aggregates) == 1
+    assert aggregates[0]["exited"] == 1
+    assert "errored" not in aggregates[0]
+
+
+def test_aggregates_explicitly_disabled_session_tracking_request_mode_deprecated(
+    sentry_init, capture_envelopes, suppress_deprecation_warnings
+):
+    sentry_init(
+        release="fun-release", environment="not-fun-env", auto_session_tracking=False
+    )
+    envelopes = capture_envelopes()
 
     with auto_session_tracking(session_mode="request"):
-        with sentry_sdk.push_scope():
+        with sentry_sdk.new_scope():
             try:
                 raise Exception("all is wrong")
             except Exception:
@@ -106,9 +175,8 @@ def test_aggregates_explicitly_disabled_session_tracking_request_mode(
     with auto_session_tracking(session_mode="request"):
         pass
 
-    hub.start_session(session_mode="request")
-    hub.end_session()
-
+    sentry_sdk.get_isolation_scope().start_session(session_mode="request")
+    sentry_sdk.get_isolation_scope().end_session()
     sentry_sdk.flush()
 
     sess = envelopes[1]
@@ -119,3 +187,111 @@ def test_aggregates_explicitly_disabled_session_tracking_request_mode(
     assert len(aggregates) == 1
     assert aggregates[0]["exited"] == 1
     assert "errored" not in aggregates[0]
+
+
+def test_no_thread_on_shutdown_no_errors(sentry_init):
+    sentry_init(
+        release="fun-release",
+        environment="not-fun-env",
+    )
+
+    # make it seem like the interpreter is shutting down
+    with mock.patch(
+        "threading.Thread.start",
+        side_effect=RuntimeError("can't create new thread at interpreter shutdown"),
+    ):
+        with sentry_sdk.isolation_scope() as scope:
+            with track_session(scope, session_mode="request"):
+                try:
+                    raise Exception("all is wrong")
+                except Exception:
+                    sentry_sdk.capture_exception()
+
+        with sentry_sdk.isolation_scope() as scope:
+            with track_session(scope, session_mode="request"):
+                pass
+
+        sentry_sdk.get_isolation_scope().start_session(session_mode="request")
+        sentry_sdk.get_isolation_scope().end_session()
+        sentry_sdk.flush()
+
+    # If we reach this point without error, the test is successful.
+
+
+def test_no_thread_on_shutdown_no_errors_deprecated(
+    sentry_init, suppress_deprecation_warnings
+):
+    sentry_init(
+        release="fun-release",
+        environment="not-fun-env",
+    )
+
+    # make it seem like the interpreter is shutting down
+    with mock.patch(
+        "threading.Thread.start",
+        side_effect=RuntimeError("can't create new thread at interpreter shutdown"),
+    ):
+        with auto_session_tracking(session_mode="request"):
+            with sentry_sdk.new_scope():
+                try:
+                    raise Exception("all is wrong")
+                except Exception:
+                    sentry_sdk.capture_exception()
+
+        with auto_session_tracking(session_mode="request"):
+            pass
+
+        sentry_sdk.get_isolation_scope().start_session(session_mode="request")
+        sentry_sdk.get_isolation_scope().end_session()
+        sentry_sdk.flush()
+
+    # If we reach this point without error, the test is successful.
+
+
+def test_top_level_start_session_basic(sentry_init, capture_envelopes):
+    """Test that top-level start_session starts a session on the isolation scope."""
+    sentry_init(release="test-release", environment="test-env")
+    envelopes = capture_envelopes()
+
+    # Start a session using the top-level API
+    sentry_sdk.start_session()
+
+    # End the session
+    sentry_sdk.end_session()
+    sentry_sdk.flush()
+
+    # Check that we got a session envelope
+    assert len(envelopes) == 1
+    sess = envelopes[0]
+    assert len(sess.items) == 1
+    sess_event = sess.items[0].payload.json
+
+    assert sess_event["attrs"] == {
+        "release": "test-release",
+        "environment": "test-env",
+    }
+    assert sess_event["status"] == "exited"
+
+
+def test_top_level_start_session_with_mode(sentry_init, capture_envelopes):
+    """Test that top-level start_session accepts session_mode parameter."""
+    sentry_init(release="test-release", environment="test-env")
+    envelopes = capture_envelopes()
+
+    # Start a session with request mode
+    sentry_sdk.start_session(session_mode="request")
+    sentry_sdk.end_session()
+    sentry_sdk.flush()
+
+    # Request mode sessions are aggregated
+    assert len(envelopes) == 1
+    sess = envelopes[0]
+    assert len(sess.items) == 1
+    sess_event = sess.items[0].payload.json
+
+    assert sess_event["attrs"] == {
+        "release": "test-release",
+        "environment": "test-env",
+    }
+    # Request sessions show up as aggregates
+    assert "aggregates" in sess_event
diff --git a/tests/test_shadowed_module.py b/tests/test_shadowed_module.py
new file mode 100644
index 0000000000..efca19746a
--- /dev/null
+++ b/tests/test_shadowed_module.py
@@ -0,0 +1,115 @@
+import sys
+import ast
+import types
+import pkgutil
+import importlib
+import pathlib
+import pytest
+
+from sentry_sdk import integrations
+from sentry_sdk.integrations import _DEFAULT_INTEGRATIONS, Integration
+
+
+def pytest_generate_tests(metafunc):
+    """
+    All submodules of sentry_sdk.integrations are picked up, so modules
+    without a subclass of sentry_sdk.integrations.Integration are also tested
+    for poorly gated imports.
+
+    This approach was chosen to keep the implementation simple.
+    """
+    if "integration_submodule_name" in metafunc.fixturenames:
+        submodule_names = {
+            submodule_name
+            for _, submodule_name, _ in pkgutil.walk_packages(integrations.__path__)
+        }
+
+        metafunc.parametrize(
+            "integration_submodule_name",
+            # Temporarily skip some integrations
+            submodule_names
+            - {
+                "clickhouse_driver",
+                "litellm",
+                "pure_eval",
+                "ray",
+                "typer",
+            },
+        )
+
+
+def find_unrecognized_dependencies(tree):
+    """
+    Finds unrecognized imports in the AST for a Python module. In an empty
+    environment the set of non-standard library modules is returned.
+    """
+    unrecognized_dependencies = set()
+    package_name = lambda name: name.split(".")[0]
+
+    for node in ast.walk(tree):
+        if isinstance(node, ast.Import):
+            for alias in node.names:
+                root = package_name(alias.name)
+
+                try:
+                    if not importlib.util.find_spec(root):
+                        unrecognized_dependencies.add(root)
+                except ValueError:
+                    continue
+
+        elif isinstance(node, ast.ImportFrom):
+            # if node.level is not 0 the import is relative
+            if node.level > 0 or node.module is None:
+                continue
+
+            root = package_name(node.module)
+
+            try:
+                if not importlib.util.find_spec(root):
+                    unrecognized_dependencies.add(root)
+            except ValueError:
+                continue
+
+    return unrecognized_dependencies
+
+
+@pytest.mark.skipif(
+    sys.version_info < (3, 7), reason="asyncpg imports __future__.annotations"
+)
+def test_shadowed_modules_when_importing_integrations(
+    sentry_init, integration_submodule_name
+):
+    """
+    Check that importing integrations for third-party module raises an
+    DidNotEnable exception when the associated module is shadowed by an empty
+    module.
+
+    An integration is determined to be for a third-party module if it cannot
+    be imported in the environment in which the tests run.
+    """
+    module_path = f"sentry_sdk.integrations.{integration_submodule_name}"
+    try:
+        # If importing the integration succeeds in the current environment, assume
+        # that the integration has no non-standard imports.
+        importlib.import_module(module_path)
+        return
+    except integrations.DidNotEnable:
+        spec = importlib.util.find_spec(module_path)
+        source = pathlib.Path(spec.origin).read_text(encoding="utf-8")
+        tree = ast.parse(source, filename=spec.origin)
+        integration_dependencies = find_unrecognized_dependencies(tree)
+
+        # For each non-standard import, create an empty shadow module to
+        # emulate an empty "agents.py" or analogous local module that
+        # shadows the package.
+        for dependency in integration_dependencies:
+            sys.modules[dependency] = types.ModuleType(dependency)
+
+        # Importing the integration must raise DidNotEnable, since the
+        # SDK catches the exception type when attempting to activate
+        # auto-enabling integrations.
+        with pytest.raises(integrations.DidNotEnable):
+            importlib.import_module(module_path)
+
+        for dependency in integration_dependencies:
+            del sys.modules[dependency]
diff --git a/tests/test_spotlight.py b/tests/test_spotlight.py
new file mode 100644
index 0000000000..f554ff7c5b
--- /dev/null
+++ b/tests/test_spotlight.py
@@ -0,0 +1,123 @@
+import pytest
+
+import sentry_sdk
+from sentry_sdk.spotlight import DEFAULT_SPOTLIGHT_URL
+
+
+@pytest.fixture
+def capture_spotlight_envelopes(monkeypatch):
+    def inner():
+        envelopes = []
+        test_spotlight = sentry_sdk.get_client().spotlight
+        old_capture_envelope = test_spotlight.capture_envelope
+
+        def append_envelope(envelope):
+            envelopes.append(envelope)
+            return old_capture_envelope(envelope)
+
+        monkeypatch.setattr(test_spotlight, "capture_envelope", append_envelope)
+        return envelopes
+
+    return inner
+
+
+def test_spotlight_off_by_default(sentry_init):
+    sentry_init()
+    assert sentry_sdk.get_client().spotlight is None
+
+
+def test_spotlight_default_url(sentry_init):
+    sentry_init(spotlight=True)
+
+    spotlight = sentry_sdk.get_client().spotlight
+    assert spotlight is not None
+    assert spotlight.url == "http://localhost:8969/stream"
+
+
+def test_spotlight_custom_url(sentry_init):
+    sentry_init(spotlight="http://foobar@test.com/132")
+
+    spotlight = sentry_sdk.get_client().spotlight
+    assert spotlight is not None
+    assert spotlight.url == "http://foobar@test.com/132"
+
+
+def test_spotlight_envelope(sentry_init, capture_spotlight_envelopes):
+    sentry_init(spotlight=True)
+    envelopes = capture_spotlight_envelopes()
+
+    try:
+        raise ValueError("aha!")
+    except Exception:
+        sentry_sdk.capture_exception()
+
+    (envelope,) = envelopes
+    payload = envelope.items[0].payload.json
+
+    assert payload["exception"]["values"][0]["value"] == "aha!"
+
+
+def test_spotlight_true_with_env_url_uses_env_url(sentry_init, monkeypatch):
+    """Per spec: spotlight=True + env URL -> use env URL"""
+    monkeypatch.setenv("SENTRY_SPOTLIGHT", "http://custom:9999/stream")
+    sentry_init(spotlight=True)
+
+    spotlight = sentry_sdk.get_client().spotlight
+    assert spotlight is not None
+    assert spotlight.url == "http://custom:9999/stream"
+
+
+def test_spotlight_false_ignores_env_var(sentry_init, monkeypatch, caplog):
+    """Per spec: spotlight=False ignores env var and logs warning"""
+    import logging
+
+    with caplog.at_level(logging.WARNING, logger="sentry_sdk.errors"):
+        monkeypatch.setenv("SENTRY_SPOTLIGHT", "true")
+        sentry_init(spotlight=False, debug=True)
+
+        assert sentry_sdk.get_client().spotlight is None
+        assert "ignoring SENTRY_SPOTLIGHT environment variable" in caplog.text
+
+
+def test_spotlight_config_url_overrides_env_url_with_warning(
+    sentry_init, monkeypatch, caplog
+):
+    """Per spec: config URL takes precedence over env URL with warning"""
+    import logging
+
+    with caplog.at_level(logging.WARNING, logger="sentry_sdk.errors"):
+        monkeypatch.setenv("SENTRY_SPOTLIGHT", "http://env:9999/stream")
+        sentry_init(spotlight="http://config:8888/stream", debug=True)
+
+        spotlight = sentry_sdk.get_client().spotlight
+        assert spotlight is not None
+        assert spotlight.url == "http://config:8888/stream"
+        assert "takes precedence over" in caplog.text
+
+
+def test_spotlight_config_url_same_as_env_no_warning(sentry_init, monkeypatch, caplog):
+    """No warning when config URL matches env URL"""
+    import logging
+
+    with caplog.at_level(logging.WARNING, logger="sentry_sdk.errors"):
+        monkeypatch.setenv("SENTRY_SPOTLIGHT", "http://same:9999/stream")
+        sentry_init(spotlight="http://same:9999/stream", debug=True)
+
+        spotlight = sentry_sdk.get_client().spotlight
+        assert spotlight is not None
+        assert spotlight.url == "http://same:9999/stream"
+        assert "takes precedence over" not in caplog.text
+
+
+def test_spotlight_receives_session_envelopes(sentry_init, capture_spotlight_envelopes):
+    """Spotlight should receive session envelopes, not just error events"""
+    sentry_init(spotlight=True, release="test-release")
+    envelopes = capture_spotlight_envelopes()
+
+    # Start and end a session
+    sentry_sdk.get_isolation_scope().start_session()
+    sentry_sdk.get_isolation_scope().end_session()
+    sentry_sdk.flush()
+
+    # Should have received at least one envelope with session data
+    assert len(envelopes) > 0
diff --git a/tests/test_tracing_utils.py b/tests/test_tracing_utils.py
new file mode 100644
index 0000000000..8960e04321
--- /dev/null
+++ b/tests/test_tracing_utils.py
@@ -0,0 +1,280 @@
+import pytest
+from dataclasses import asdict, dataclass
+from typing import Optional, List
+
+from sentry_sdk.tracing_utils import (
+    _should_be_included,
+    _should_continue_trace,
+    Baggage,
+)
+from tests.conftest import TestTransportWithOptions
+
+
+def id_function(val: object) -> str:
+    if isinstance(val, ShouldBeIncludedTestCase):
+        return val.id
+
+
+@dataclass(frozen=True)
+class ShouldBeIncludedTestCase:
+    id: str
+    is_sentry_sdk_frame: bool
+    namespace: Optional[str] = None
+    in_app_include: Optional[List[str]] = None
+    in_app_exclude: Optional[List[str]] = None
+    abs_path: Optional[str] = None
+    project_root: Optional[str] = None
+
+
+@pytest.mark.parametrize(
+    "test_case, expected",
+    [
+        (
+            ShouldBeIncludedTestCase(
+                id="Frame from Sentry SDK",
+                is_sentry_sdk_frame=True,
+            ),
+            False,
+        ),
+        (
+            ShouldBeIncludedTestCase(
+                id="Frame from Django installed in virtualenv inside project root",
+                is_sentry_sdk_frame=False,
+                abs_path="/home/username/some_project/.venv/lib/python3.12/site-packages/django/db/models/sql/compiler",
+                project_root="/home/username/some_project",
+                namespace="django.db.models.sql.compiler",
+                in_app_include=["django"],
+            ),
+            True,
+        ),
+        (
+            ShouldBeIncludedTestCase(
+                id="Frame from project",
+                is_sentry_sdk_frame=False,
+                abs_path="/home/username/some_project/some_project/__init__.py",
+                project_root="/home/username/some_project",
+                namespace="some_project",
+            ),
+            True,
+        ),
+        (
+            ShouldBeIncludedTestCase(
+                id="Frame from project module in `in_app_exclude`",
+                is_sentry_sdk_frame=False,
+                abs_path="/home/username/some_project/some_project/exclude_me/some_module.py",
+                project_root="/home/username/some_project",
+                namespace="some_project.exclude_me.some_module",
+                in_app_exclude=["some_project.exclude_me"],
+            ),
+            False,
+        ),
+        (
+            ShouldBeIncludedTestCase(
+                id="Frame from system-wide installed Django",
+                is_sentry_sdk_frame=False,
+                abs_path="/usr/lib/python3.12/site-packages/django/db/models/sql/compiler",
+                project_root="/home/username/some_project",
+                namespace="django.db.models.sql.compiler",
+            ),
+            False,
+        ),
+        (
+            ShouldBeIncludedTestCase(
+                id="Frame from system-wide installed Django with `django` in `in_app_include`",
+                is_sentry_sdk_frame=False,
+                abs_path="/usr/lib/python3.12/site-packages/django/db/models/sql/compiler",
+                project_root="/home/username/some_project",
+                namespace="django.db.models.sql.compiler",
+                in_app_include=["django"],
+            ),
+            True,
+        ),
+    ],
+    ids=id_function,
+)
+def test_should_be_included(
+    test_case: "ShouldBeIncludedTestCase", expected: bool
+) -> None:
+    """Checking logic, see: https://github.com/getsentry/sentry-python/issues/3312"""
+    kwargs = asdict(test_case)
+    kwargs.pop("id")
+    assert _should_be_included(**kwargs) == expected
+
+
+@pytest.mark.parametrize(
+    ("header", "expected"),
+    (
+        ("", ""),
+        ("foo=bar", "foo=bar"),
+        (" foo=bar, baz =  qux ", " foo=bar, baz =  qux "),
+        ("sentry-trace_id=123", ""),
+        ("  sentry-trace_id = 123  ", ""),
+        ("sentry-trace_id=123,sentry-public_key=456", ""),
+        ("foo=bar,sentry-trace_id=123", "foo=bar"),
+        ("foo=bar,sentry-trace_id=123,baz=qux", "foo=bar,baz=qux"),
+        (
+            "foo=bar,sentry-trace_id=123,baz=qux,sentry-public_key=456",
+            "foo=bar,baz=qux",
+        ),
+    ),
+)
+def test_strip_sentry_baggage(header, expected):
+    assert Baggage.strip_sentry_baggage(header) == expected
+
+
+@pytest.mark.parametrize(
+    ("baggage", "expected_repr"),
+    (
+        (Baggage(sentry_items={}), ''),
+        (Baggage(sentry_items={}, mutable=False), ''),
+        (
+            Baggage(sentry_items={"foo": "bar"}),
+            '',
+        ),
+        (
+            Baggage(sentry_items={"foo": "bar"}, mutable=False),
+            '',
+        ),
+        (
+            Baggage(sentry_items={"foo": "bar"}, third_party_items="asdf=1234,"),
+            '',
+        ),
+        (
+            Baggage(
+                sentry_items={"foo": "bar"},
+                third_party_items="asdf=1234,",
+                mutable=False,
+            ),
+            '',
+        ),
+    ),
+)
+def test_baggage_repr(baggage, expected_repr):
+    assert repr(baggage) == expected_repr
+
+
+@pytest.mark.parametrize(
+    (
+        "baggage_header",
+        "dsn",
+        "explicit_org_id",
+        "strict_trace_continuation",
+        "should_continue_trace",
+    ),
+    (
+        # continue cases when strict_trace_continuation=False
+        (
+            "sentry-trace_id=771a43a4192642f0b136d5159a501700, sentry-org_id=1234",
+            "https://mysecret@o1234.ingest.sentry.io/12312012",
+            None,
+            False,
+            True,
+        ),
+        (
+            "sentry-trace_id=771a43a4192642f0b136d5159a501700",
+            "https://mysecret@o1234.ingest.sentry.io/12312012",
+            None,
+            False,
+            True,
+        ),
+        (
+            "sentry-trace_id=771a43a4192642f0b136d5159a501700, sentry-org_id=1234",
+            None,
+            None,
+            False,
+            True,
+        ),
+        (
+            "sentry-trace_id=771a43a4192642f0b136d5159a501700, sentry-org_id=1234",
+            None,
+            "1234",
+            False,
+            True,
+        ),
+        (
+            "sentry-trace_id=771a43a4192642f0b136d5159a501700, sentry-org_id=1234",
+            "https://mysecret@not_org_id.ingest.sentry.io/12312012",
+            None,
+            False,
+            True,
+        ),
+        # start new cases when strict_trace_continuation=False
+        (
+            "sentry-trace_id=771a43a4192642f0b136d5159a501700, sentry-org_id=1234",
+            "https://mysecret@o9999.ingest.sentry.io/12312012",
+            None,
+            False,
+            False,
+        ),
+        (
+            "sentry-trace_id=771a43a4192642f0b136d5159a501700, sentry-org_id=1234",
+            "https://mysecret@o1234.ingest.sentry.io/12312012",
+            "9999",
+            False,
+            False,
+        ),
+        # continue cases when strict_trace_continuation=True
+        (
+            "sentry-trace_id=771a43a4192642f0b136d5159a501700, sentry-org_id=1234",
+            "https://mysecret@o1234.ingest.sentry.io/12312012",
+            None,
+            True,
+            True,
+        ),
+        ("sentry-trace_id=771a43a4192642f0b136d5159a501700", None, None, True, True),
+        # start new cases when strict_trace_continuation=True
+        (
+            "sentry-trace_id=771a43a4192642f0b136d5159a501700",
+            "https://mysecret@o1234.ingest.sentry.io/12312012",
+            None,
+            True,
+            False,
+        ),
+        (
+            "sentry-trace_id=771a43a4192642f0b136d5159a501700, sentry-org_id=1234",
+            None,
+            None,
+            True,
+            False,
+        ),
+        (
+            "sentry-trace_id=771a43a4192642f0b136d5159a501700, sentry-org_id=1234",
+            "https://mysecret@not_org_id.ingest.sentry.io/12312012",
+            None,
+            True,
+            False,
+        ),
+        (
+            "sentry-trace_id=771a43a4192642f0b136d5159a501700, sentry-org_id=1234",
+            "https://mysecret@o9999.ingest.sentry.io/12312012",
+            None,
+            True,
+            False,
+        ),
+        (
+            "sentry-trace_id=771a43a4192642f0b136d5159a501700, sentry-org_id=1234",
+            "https://mysecret@o1234.ingest.sentry.io/12312012",
+            "9999",
+            True,
+            False,
+        ),
+    ),
+)
+def test_should_continue_trace(
+    sentry_init,
+    baggage_header,
+    dsn,
+    explicit_org_id,
+    strict_trace_continuation,
+    should_continue_trace,
+):
+    sentry_init(
+        dsn=dsn,
+        org_id=explicit_org_id,
+        strict_trace_continuation=strict_trace_continuation,
+        traces_sample_rate=1.0,
+        transport=TestTransportWithOptions,
+    )
+
+    baggage = Baggage.from_incoming_header(baggage_header) if baggage_header else None
+    assert _should_continue_trace(baggage) == should_continue_trace
diff --git a/tests/test_transport.py b/tests/test_transport.py
index befba3c905..b5b88e0e2c 100644
--- a/tests/test_transport.py
+++ b/tests/test_transport.py
@@ -1,79 +1,54 @@
-# coding: utf-8
 import logging
 import pickle
-import gzip
-import io
-
-from datetime import datetime, timedelta
+import os
+import socket
+import sys
+from collections import defaultdict
+from datetime import datetime, timedelta, timezone
+from unittest import mock
 
 import pytest
-from collections import namedtuple
-from werkzeug.wrappers import Request, Response
-
-from pytest_localserver.http import WSGIServer
-
-from sentry_sdk import Hub, Client, add_breadcrumb, capture_message, Scope
-from sentry_sdk._compat import datetime_utcnow
-from sentry_sdk.transport import _parse_rate_limits
-from sentry_sdk.envelope import Envelope, parse_json
-from sentry_sdk.integrations.logging import LoggingIntegration
-
-
-CapturedData = namedtuple("CapturedData", ["path", "event", "envelope", "compressed"])
-
-
-class CapturingServer(WSGIServer):
-    def __init__(self, host="127.0.0.1", port=0, ssl_context=None):
-        WSGIServer.__init__(self, host, port, self, ssl_context=ssl_context)
-        self.code = 204
-        self.headers = {}
-        self.captured = []
-
-    def respond_with(self, code=200, headers=None):
-        self.code = code
-        if headers:
-            self.headers = headers
-
-    def clear_captured(self):
-        del self.captured[:]
-
-    def __call__(self, environ, start_response):
-        """
-        This is the WSGI application.
-        """
-        request = Request(environ)
-        event = envelope = None
-        if request.headers.get("content-encoding") == "gzip":
-            rdr = gzip.GzipFile(fileobj=io.BytesIO(request.data))
-            compressed = True
-        else:
-            rdr = io.BytesIO(request.data)
-            compressed = False
-
-        if request.mimetype == "application/json":
-            event = parse_json(rdr.read())
-        else:
-            envelope = Envelope.deserialize_from(rdr)
-
-        self.captured.append(
-            CapturedData(
-                path=request.path,
-                event=event,
-                envelope=envelope,
-                compressed=compressed,
-            )
-        )
+from tests.conftest import CapturingServer
+
+try:
+    import httpcore
+except (ImportError, ModuleNotFoundError):
+    httpcore = None
+
+import sentry_sdk
+from sentry_sdk import (
+    Client,
+    add_breadcrumb,
+    capture_message,
+    isolation_scope,
+    get_isolation_scope,
+    Hub,
+)
+from sentry_sdk._compat import PY37, PY38
+from sentry_sdk.envelope import Envelope, Item, parse_json, PayloadRef
+from sentry_sdk.transport import (
+    KEEP_ALIVE_SOCKET_OPTIONS,
+    _parse_rate_limits,
+    HttpTransport,
+)
+from sentry_sdk.integrations.logging import LoggingIntegration, ignore_logger
 
-        response = Response(status=self.code)
-        response.headers.extend(self.headers)
-        return response(environ, start_response)
 
+server = None
 
-@pytest.fixture
-def capturing_server(request):
+
+@pytest.fixture(scope="module", autouse=True)
+def make_capturing_server(request):
+    global server
     server = CapturingServer()
     server.start()
     request.addfinalizer(server.stop)
+
+
+@pytest.fixture
+def capturing_server():
+    global server
+    server.clear_captured()
     return server
 
 
@@ -82,17 +57,34 @@ def make_client(request, capturing_server):
     def inner(**kwargs):
         return Client(
             "http://foobar@{}/132".format(capturing_server.url[len("http://") :]),
-            **kwargs
+            **kwargs,
         )
 
     return inner
 
 
-@pytest.mark.forked
+def mock_transaction_envelope(span_count: int) -> "Envelope":
+    event = defaultdict(
+        mock.MagicMock,
+        type="transaction",
+        spans=[mock.MagicMock() for _ in range(span_count)],
+    )
+
+    envelope = Envelope()
+    envelope.add_transaction(event)
+
+    return envelope
+
+
 @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, None))
+@pytest.mark.parametrize(
+    "compression_algo",
+    (("gzip", "br", "", None) if PY37 else ("gzip", "", None)),
+)
+@pytest.mark.parametrize("http2", [True, False] if PY38 else [False])
 def test_transport_works(
     capturing_server,
     request,
@@ -102,24 +94,36 @@ def test_transport_works(
     make_client,
     client_flush_method,
     use_pickle,
-    compressionlevel,
-    maybe_monkeypatched_threading,
+    compression_level,
+    compression_algo,
+    http2,
 ):
     caplog.set_level(logging.DEBUG)
+
+    experiments = {}
+    if compression_level is not None:
+        experiments["transport_compression_level"] = compression_level
+
+    if compression_algo is not None:
+        experiments["transport_compression_algo"] = compression_algo
+
+    if http2:
+        experiments["transport_http2"] = True
+
     client = make_client(
         debug=debug,
-        _experiments={
-            "transport_zlib_compression_level": compressionlevel,
-        },
+        _experiments=experiments,
     )
 
     if use_pickle:
         client = pickle.loads(pickle.dumps(client))
 
-    Hub.current.bind_client(client)
-    request.addfinalizer(lambda: Hub.current.bind_client(None))
+    sentry_sdk.get_global_scope().set_client(client)
+    request.addfinalizer(lambda: sentry_sdk.get_global_scope().set_client(None))
 
-    add_breadcrumb(level="info", message="i like bread", timestamp=datetime_utcnow())
+    add_breadcrumb(
+        level="info", message="i like bread", timestamp=datetime.now(timezone.utc)
+    )
     capture_message("löl")
 
     getattr(client, client_flush_method)()
@@ -127,9 +131,185 @@ 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)
+    should_compress = (
+        # default is to compress with brotli if available, gzip otherwise
+        (compression_level is None)
+        or (
+            # setting compression level to 0 means don't compress
+            compression_level > 0
+        )
+    ) and (
+        # if we couldn't resolve to a known algo, we don't compress
+        compression_algo != ""
+    )
+
+    assert capturing_server.captured[0].compressed == should_compress
+
+    assert any("Sending envelope" in record.msg for record in caplog.records) == debug
+
+
+@pytest.mark.parametrize(
+    "num_pools,expected_num_pools",
+    (
+        (None, 2),
+        (2, 2),
+        (10, 10),
+    ),
+)
+def test_transport_num_pools(make_client, num_pools, expected_num_pools):
+    _experiments = {}
+    if num_pools is not None:
+        _experiments["transport_num_pools"] = num_pools
+
+    client = make_client(_experiments=_experiments)
+
+    options = client.transport._get_pool_options()
+    assert options["num_pools"] == expected_num_pools
+
+
+@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
+
+    current_dir = os.path.dirname(__file__)
+    cert_file = f"{current_dir}/test.pem"
+    key_file = f"{current_dir}/test.key"
+    client = make_client(
+        cert_file=cert_file,
+        key_file=key_file,
+        _experiments=_experiments,
+    )
+    options = client.transport._get_pool_options()
 
-    assert any("Sending event" in record.msg for record in caplog.records) == debug
+    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):
+    socket_options = [
+        (socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1),
+        (socket.SOL_TCP, socket.TCP_KEEPINTVL, 10),
+        (socket.SOL_TCP, socket.TCP_KEEPCNT, 6),
+    ]
+
+    client = make_client(socket_options=socket_options)
+
+    options = client.transport._get_pool_options()
+    assert options["socket_options"] == socket_options
+
+
+def test_keep_alive_true(make_client):
+    client = make_client(keep_alive=True)
+
+    options = client.transport._get_pool_options()
+    assert options["socket_options"] == KEEP_ALIVE_SOCKET_OPTIONS
+
+
+def test_keep_alive_on_by_default(make_client):
+    client = make_client()
+    options = client.transport._get_pool_options()
+    assert "socket_options" not in options
+
+
+def test_default_timeout(make_client):
+    client = make_client()
+
+    options = client.transport._get_pool_options()
+    assert "timeout" in options
+    assert options["timeout"].total == client.transport.TIMEOUT
+
+
+@pytest.mark.skipif(not PY38, reason="HTTP2 libraries are only available in py3.8+")
+def test_default_timeout_http2(make_client):
+    client = make_client(_experiments={"transport_http2": True})
+
+    with mock.patch(
+        "sentry_sdk.transport.httpcore.ConnectionPool.request",
+        return_value=httpcore.Response(200),
+    ) as request_mock:
+        sentry_sdk.get_global_scope().set_client(client)
+        capture_message("hi")
+        client.flush()
+
+    request_mock.assert_called_once()
+    assert request_mock.call_args.kwargs["extensions"] == {
+        "timeout": {
+            "pool": client.transport.TIMEOUT,
+            "connect": client.transport.TIMEOUT,
+            "write": client.transport.TIMEOUT,
+            "read": client.transport.TIMEOUT,
+        }
+    }
+
+
+@pytest.mark.skipif(not PY38, reason="HTTP2 libraries are only available in py3.8+")
+def test_http2_with_https_dsn(make_client):
+    client = make_client(_experiments={"transport_http2": True})
+    client.transport.parsed_dsn.scheme = "https"
+    options = client.transport._get_pool_options()
+    assert options["http2"] is True
+
+
+@pytest.mark.skipif(not PY38, reason="HTTP2 libraries are only available in py3.8+")
+def test_no_http2_with_http_dsn(make_client):
+    client = make_client(_experiments={"transport_http2": True})
+    client.transport.parsed_dsn.scheme = "http"
+    options = client.transport._get_pool_options()
+    assert options["http2"] is False
+
+
+def test_socket_options_override_keep_alive(make_client):
+    socket_options = [
+        (socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1),
+        (socket.SOL_TCP, socket.TCP_KEEPINTVL, 10),
+        (socket.SOL_TCP, socket.TCP_KEEPCNT, 6),
+    ]
+
+    client = make_client(socket_options=socket_options, keep_alive=False)
+
+    options = client.transport._get_pool_options()
+    assert options["socket_options"] == socket_options
+
+
+def test_socket_options_merge_with_keep_alive(make_client):
+    socket_options = [
+        (socket.SOL_SOCKET, socket.SO_KEEPALIVE, 42),
+        (socket.SOL_TCP, socket.TCP_KEEPINTVL, 42),
+    ]
+
+    client = make_client(socket_options=socket_options, keep_alive=True)
+
+    options = client.transport._get_pool_options()
+    try:
+        assert options["socket_options"] == [
+            (socket.SOL_SOCKET, socket.SO_KEEPALIVE, 42),
+            (socket.SOL_TCP, socket.TCP_KEEPINTVL, 42),
+            (socket.SOL_TCP, socket.TCP_KEEPIDLE, 45),
+            (socket.SOL_TCP, socket.TCP_KEEPCNT, 6),
+        ]
+    except AttributeError:
+        assert options["socket_options"] == [
+            (socket.SOL_SOCKET, socket.SO_KEEPALIVE, 42),
+            (socket.SOL_TCP, socket.TCP_KEEPINTVL, 42),
+            (socket.SOL_TCP, socket.TCP_KEEPCNT, 6),
+        ]
+
+
+def test_socket_options_override_defaults(make_client):
+    # If socket_options are set to [], this doesn't mean the user doesn't want
+    # any custom socket_options, but rather that they want to disable the urllib3
+    # socket option defaults, so we need to set this and not ignore it.
+    client = make_client(socket_options=[])
+
+    options = client.transport._get_pool_options()
+    assert options["socket_options"] == []
 
 
 def test_transport_infinite_loop(capturing_server, request, make_client):
@@ -139,13 +319,37 @@ def test_transport_infinite_loop(capturing_server, request, make_client):
         integrations=[LoggingIntegration(event_level=logging.DEBUG)],
     )
 
-    with Hub(client):
+    # I am not sure why, but "werkzeug" logger makes an INFO log on sending
+    # the message "hi" and does creates an infinite look.
+    # Ignoring this for breaking the infinite loop and still we can test
+    # that our own log messages (sent from `_IGNORED_LOGGERS`) are not leading
+    # to an infinite loop
+    ignore_logger("werkzeug")
+
+    sentry_sdk.get_global_scope().set_client(client)
+    with isolation_scope():
         capture_message("hi")
         client.flush()
 
     assert len(capturing_server.captured) == 1
 
 
+def test_transport_no_thread_on_shutdown_no_errors(capturing_server, make_client):
+    client = make_client()
+
+    # make it seem like the interpreter is shutting down
+    with mock.patch(
+        "threading.Thread.start",
+        side_effect=RuntimeError("can't create new thread at interpreter shutdown"),
+    ):
+        sentry_sdk.get_global_scope().set_client(client)
+        with isolation_scope():
+            capture_message("hi")
+
+    # nothing exploded but also no events can be sent anymore
+    assert len(capturing_server.captured) == 0
+
+
 NOW = datetime(2014, 6, 2)
 
 
@@ -181,7 +385,7 @@ def test_parse_rate_limits(input, expected):
     assert dict(_parse_rate_limits(input, now=NOW)) == expected
 
 
-def test_simple_rate_limits(capturing_server, capsys, caplog, make_client):
+def test_simple_rate_limits(capturing_server, make_client):
     client = make_client()
     capturing_server.respond_with(code=429, headers={"Retry-After": "4"})
 
@@ -203,7 +407,7 @@ def test_simple_rate_limits(capturing_server, capsys, caplog, make_client):
 
 @pytest.mark.parametrize("response_code", [200, 429])
 def test_data_category_limits(
-    capturing_server, capsys, caplog, response_code, make_client, monkeypatch
+    capturing_server, response_code, make_client, monkeypatch
 ):
     client = make_client(send_client_reports=False)
 
@@ -240,7 +444,7 @@ def record_lost_event(reason, data_category=None, item=None):
     client.flush()
 
     assert len(capturing_server.captured) == 1
-    assert capturing_server.captured[0].path == "/api/132/store/"
+    assert capturing_server.captured[0].path == "/api/132/envelope/"
 
     assert captured_outcomes == [
         ("ratelimit_backoff", "transaction"),
@@ -250,7 +454,7 @@ def record_lost_event(reason, data_category=None, item=None):
 
 @pytest.mark.parametrize("response_code", [200, 429])
 def test_data_category_limits_reporting(
-    capturing_server, capsys, caplog, response_code, make_client, monkeypatch
+    capturing_server, response_code, make_client, monkeypatch
 ):
     client = make_client(send_client_reports=True)
 
@@ -292,7 +496,7 @@ def intercepting_fetch(*args, **kwargs):
     client.transport._last_client_report_sent = 0
     outcomes_enabled = True
 
-    scope = Scope()
+    scope = get_isolation_scope()
     scope.add_attachment(bytes=b"Hello World", filename="hello.txt")
     client.capture_event({"type": "error"}, scope=scope)
     client.flush()
@@ -306,10 +510,26 @@ def intercepting_fetch(*args, **kwargs):
     assert envelope.items[0].type == "event"
     assert envelope.items[1].type == "client_report"
     report = parse_json(envelope.items[1].get_bytes())
-    assert sorted(report["discarded_events"], key=lambda x: x["quantity"]) == [
-        {"category": "transaction", "reason": "ratelimit_backoff", "quantity": 2},
-        {"category": "attachment", "reason": "ratelimit_backoff", "quantity": 11},
-    ]
+
+    discarded_events = report["discarded_events"]
+
+    assert len(discarded_events) == 3
+    assert {
+        "category": "transaction",
+        "reason": "ratelimit_backoff",
+        "quantity": 2,
+    } in discarded_events
+    assert {
+        "category": "span",
+        "reason": "ratelimit_backoff",
+        "quantity": 2,
+    } in discarded_events
+    assert {
+        "category": "attachment",
+        "reason": "ratelimit_backoff",
+        "quantity": 11,
+    } in discarded_events
+
     capturing_server.clear_captured()
 
     # here we sent a normal event
@@ -319,21 +539,32 @@ def intercepting_fetch(*args, **kwargs):
 
     assert len(capturing_server.captured) == 2
 
-    event = capturing_server.captured[0].event
+    assert len(capturing_server.captured[0].envelope.items) == 1
+    event = capturing_server.captured[0].envelope.items[0].get_event()
     assert event["type"] == "error"
     assert event["release"] == "foo"
 
     envelope = capturing_server.captured[1].envelope
     assert envelope.items[0].type == "client_report"
     report = parse_json(envelope.items[0].get_bytes())
-    assert report["discarded_events"] == [
-        {"category": "transaction", "reason": "ratelimit_backoff", "quantity": 1},
-    ]
+
+    discarded_events = report["discarded_events"]
+    assert len(discarded_events) == 2
+    assert {
+        "category": "transaction",
+        "reason": "ratelimit_backoff",
+        "quantity": 1,
+    } in discarded_events
+    assert {
+        "category": "span",
+        "reason": "ratelimit_backoff",
+        "quantity": 1,
+    } in discarded_events
 
 
 @pytest.mark.parametrize("response_code", [200, 429])
 def test_complex_limits_without_data_category(
-    capturing_server, capsys, caplog, response_code, make_client
+    capturing_server, response_code, make_client
 ):
     client = make_client()
     capturing_server.respond_with(
@@ -356,3 +587,230 @@ def test_complex_limits_without_data_category(
     client.flush()
 
     assert len(capturing_server.captured) == 0
+
+
+@pytest.mark.parametrize("response_code", [200, 429])
+@pytest.mark.parametrize(
+    "item",
+    [
+        Item(payload=b"{}", type="log"),
+        Item(
+            type="log",
+            content_type="application/vnd.sentry.items.log+json",
+            headers={
+                "item_count": 2,
+            },
+            payload=PayloadRef(
+                json={
+                    "items": [
+                        {
+                            "body": "This is a 'info' log...",
+                            "level": "info",
+                            "timestamp": datetime(
+                                2025, 1, 1, tzinfo=timezone.utc
+                            ).timestamp(),
+                            "trace_id": "00000000-0000-0000-0000-000000000000",
+                            "attributes": {
+                                "sentry.environment": {
+                                    "value": "production",
+                                    "type": "string",
+                                },
+                                "sentry.release": {
+                                    "value": "1.0.0",
+                                    "type": "string",
+                                },
+                                "sentry.sdk.name": {
+                                    "value": "sentry.python",
+                                    "type": "string",
+                                },
+                                "sentry.sdk.version": {
+                                    "value": "2.45.0",
+                                    "type": "string",
+                                },
+                                "sentry.severity_number": {
+                                    "value": 9,
+                                    "type": "integer",
+                                },
+                                "sentry.severity_text": {
+                                    "value": "info",
+                                    "type": "string",
+                                },
+                                "server.address": {
+                                    "value": "test-server",
+                                    "type": "string",
+                                },
+                            },
+                        },
+                        {
+                            "body": "The recorded value was '2.0'",
+                            "level": "warn",
+                            "timestamp": datetime(
+                                2025, 1, 1, tzinfo=timezone.utc
+                            ).timestamp(),
+                            "trace_id": "00000000-0000-0000-0000-000000000000",
+                            "attributes": {
+                                "sentry.message.parameter.float_var": {
+                                    "value": 2.0,
+                                    "type": "double",
+                                },
+                                "sentry.message.template": {
+                                    "value": "The recorded value was '{float_var}'",
+                                    "type": "string",
+                                },
+                                "sentry.sdk.name": {
+                                    "value": "sentry.python",
+                                    "type": "string",
+                                },
+                                "sentry.sdk.version": {
+                                    "value": "2.45.0",
+                                    "type": "string",
+                                },
+                                "server.address": {
+                                    "value": "test-server",
+                                    "type": "string",
+                                },
+                                "sentry.environment": {
+                                    "value": "production",
+                                    "type": "string",
+                                },
+                                "sentry.release": {
+                                    "value": "1.0.0",
+                                    "type": "string",
+                                },
+                                "sentry.severity_number": {
+                                    "value": 13,
+                                    "type": "integer",
+                                },
+                                "sentry.severity_text": {
+                                    "value": "warn",
+                                    "type": "string",
+                                },
+                            },
+                        },
+                    ]
+                }
+            ),
+        ),
+    ],
+)
+def test_log_item_limits(capturing_server, response_code, item, make_client):
+    client = make_client()
+    capturing_server.respond_with(
+        code=response_code,
+        headers={
+            "X-Sentry-Rate-Limits": "4711:log_item:organization:quota_exceeded:custom"
+        },
+    )
+
+    envelope = Envelope()
+    envelope.add_item(item)
+    client.transport.capture_envelope(envelope)
+    client.flush()
+
+    assert len(capturing_server.captured) == 1
+    assert capturing_server.captured[0].path == "/api/132/envelope/"
+    capturing_server.clear_captured()
+
+    assert set(client.transport._disabled_until) == {"log_item"}
+
+    client.transport.capture_envelope(envelope)
+    client.capture_event({"type": "transaction"})
+    client.flush()
+
+    assert len(capturing_server.captured) == 2
+
+    envelope = capturing_server.captured[0].envelope
+    assert envelope.items[0].type == "transaction"
+    envelope = capturing_server.captured[1].envelope
+    assert envelope.items[0].type == "client_report"
+    report = parse_json(envelope.items[0].get_bytes())
+
+    assert {
+        "category": "log_item",
+        "reason": "ratelimit_backoff",
+        "quantity": 1,
+    } in report["discarded_events"]
+
+    expected_lost_bytes = 1243
+    if item.payload.bytes == b"{}":
+        expected_lost_bytes = 2
+
+    assert {
+        "category": "log_byte",
+        "reason": "ratelimit_backoff",
+        "quantity": expected_lost_bytes,
+    } in report["discarded_events"]
+
+
+def test_hub_cls_backwards_compat():
+    class TestCustomHubClass(Hub):
+        pass
+
+    transport = HttpTransport(
+        defaultdict(lambda: None, {"dsn": "https://123abc@example.com/123"})
+    )
+
+    with pytest.deprecated_call():
+        assert transport.hub_cls is Hub
+
+    with pytest.deprecated_call():
+        transport.hub_cls = TestCustomHubClass
+
+    with pytest.deprecated_call():
+        assert transport.hub_cls is TestCustomHubClass
+
+
+@pytest.mark.parametrize("quantity", (1, 2, 10))
+def test_record_lost_event_quantity(capturing_server, make_client, quantity):
+    client = make_client()
+    transport = client.transport
+
+    transport.record_lost_event(reason="test", data_category="span", quantity=quantity)
+    client.flush()
+
+    (captured,) = capturing_server.captured  # Should only be one envelope
+    envelope = captured.envelope
+    (item,) = envelope.items  # Envelope should only have one item
+
+    assert item.type == "client_report"
+
+    report = parse_json(item.get_bytes())
+
+    assert report["discarded_events"] == [
+        {"category": "span", "reason": "test", "quantity": quantity}
+    ]
+
+
+@pytest.mark.parametrize("span_count", (0, 1, 2, 10))
+def test_record_lost_event_transaction_item(capturing_server, make_client, span_count):
+    client = make_client()
+    transport = client.transport
+
+    envelope = mock_transaction_envelope(span_count)
+    (transaction_item,) = envelope.items
+
+    transport.record_lost_event(reason="test", item=transaction_item)
+    client.flush()
+
+    (captured,) = capturing_server.captured  # Should only be one envelope
+    envelope = captured.envelope
+    (item,) = envelope.items  # Envelope should only have one item
+
+    assert item.type == "client_report"
+
+    report = parse_json(item.get_bytes())
+    discarded_events = report["discarded_events"]
+
+    assert len(discarded_events) == 2
+
+    assert {
+        "category": "transaction",
+        "reason": "test",
+        "quantity": 1,
+    } in discarded_events
+
+    assert {
+        "category": "span",
+        "reason": "test",
+        "quantity": span_count + 1,
+    } in discarded_events
diff --git a/tests/test_types.py b/tests/test_types.py
new file mode 100644
index 0000000000..bef6aaa59e
--- /dev/null
+++ b/tests/test_types.py
@@ -0,0 +1,28 @@
+import sys
+
+import pytest
+from sentry_sdk.types import Event, Hint
+
+
+@pytest.mark.skipif(
+    sys.version_info < (3, 10),
+    reason="Type hinting with `|` is available in Python 3.10+",
+)
+def test_event_or_none_runtime():
+    """
+    Ensures that the `Event` type's runtime value supports the `|` operation with `None`.
+    This test is needed to ensure that using an `Event | None` type hint (e.g. for
+    `before_send`'s return value) does not raise a TypeError at runtime.
+    """
+    Event | None
+
+
+@pytest.mark.skipif(
+    sys.version_info < (3, 10),
+    reason="Type hinting with `|` is available in Python 3.10+",
+)
+def test_hint_or_none_runtime():
+    """
+    Analogue to `test_event_or_none_runtime`, but for the `Hint` type.
+    """
+    Hint | None
diff --git a/tests/test_utils.py b/tests/test_utils.py
index ee73433dd5..d703e62f3a 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -1,11 +1,25 @@
-import pytest
+import threading
 import re
 import sys
+from datetime import timedelta, datetime, timezone
+from unittest import mock
+
+import pytest
 
+import sentry_sdk
+from sentry_sdk._compat import PY38
+from sentry_sdk.integrations import Integration
+from sentry_sdk._queue import Queue
 from sentry_sdk.utils import (
     Components,
     Dsn,
+    datetime_from_isoformat,
+    env_to_bool,
+    format_timestamp,
+    get_current_thread_meta,
+    get_default_release,
     get_error_message,
+    get_git_revision,
     is_valid_sample_rate,
     logger,
     match_regex_list,
@@ -15,14 +29,175 @@
     sanitize_url,
     serialize_frame,
     is_sentry_url,
+    _get_installed_modules,
+    ensure_integration_enabled,
+    to_string,
+    exc_info_from_error,
+    get_lines_from_file,
+    package_version,
 )
 
-import sentry_sdk
+
+class TestIntegration(Integration):
+    """
+    Test integration for testing ensure_integration_enabled decorator.
+    """
+
+    identifier = "test"
+    setup_once = mock.MagicMock()
+
 
 try:
-    from unittest import mock  # python 3.3 and above
+    import gevent
 except ImportError:
-    import mock  # python < 3.3
+    gevent = None
+
+
+def _normalize_distribution_name(name: str) -> str:
+    """Normalize distribution name according to PEP-0503.
+
+    See:
+    https://peps.python.org/pep-0503/#normalized-names
+    for more details.
+    """
+    return re.sub(r"[-_.]+", "-", name).lower()
+
+
+isoformat_inputs_and_datetime_outputs = (
+    (
+        "2021-01-01T00:00:00.000000Z",
+        datetime(2021, 1, 1, tzinfo=timezone.utc),
+    ),  # UTC time
+    (
+        "2021-01-01T00:00:00.000000",
+        datetime(2021, 1, 1).astimezone(timezone.utc),
+    ),  # No TZ -- assume local but convert to UTC
+    (
+        "2021-01-01T00:00:00Z",
+        datetime(2021, 1, 1, tzinfo=timezone.utc),
+    ),  # UTC - No milliseconds
+    (
+        "2021-01-01T00:00:00.000000+00:00",
+        datetime(2021, 1, 1, tzinfo=timezone.utc),
+    ),
+    (
+        "2021-01-01T00:00:00.000000-00:00",
+        datetime(2021, 1, 1, tzinfo=timezone.utc),
+    ),
+    (
+        "2021-01-01T00:00:00.000000+0000",
+        datetime(2021, 1, 1, tzinfo=timezone.utc),
+    ),
+    (
+        "2021-01-01T00:00:00.000000-0000",
+        datetime(2021, 1, 1, tzinfo=timezone.utc),
+    ),
+    (
+        "2020-12-31T00:00:00.000000+02:00",
+        datetime(2020, 12, 31, tzinfo=timezone(timedelta(hours=2))),
+    ),  # UTC+2 time
+    (
+        "2020-12-31T00:00:00.000000-0200",
+        datetime(2020, 12, 31, tzinfo=timezone(timedelta(hours=-2))),
+    ),  # UTC-2 time
+    (
+        "2020-12-31T00:00:00-0200",
+        datetime(2020, 12, 31, tzinfo=timezone(timedelta(hours=-2))),
+    ),  # UTC-2 time - no milliseconds
+)
+
+
+@pytest.mark.parametrize(
+    ("input_str", "expected_output"),
+    isoformat_inputs_and_datetime_outputs,
+)
+def test_datetime_from_isoformat(input_str, expected_output):
+    assert datetime_from_isoformat(input_str) == expected_output, input_str
+
+
+@pytest.mark.parametrize(
+    ("input_str", "expected_output"),
+    isoformat_inputs_and_datetime_outputs,
+)
+def test_datetime_from_isoformat_with_py_36_or_lower(input_str, expected_output):
+    """
+    `fromisoformat` was added in Python version 3.7
+    """
+    with mock.patch("sentry_sdk.utils.datetime") as datetime_mocked:
+        datetime_mocked.fromisoformat.side_effect = AttributeError()
+        datetime_mocked.strptime = datetime.strptime
+        assert datetime_from_isoformat(input_str) == expected_output, input_str
+
+
+@pytest.mark.parametrize(
+    "env_var_value,strict,expected",
+    [
+        (None, True, None),
+        (None, False, False),
+        ("", True, None),
+        ("", False, False),
+        ("t", True, True),
+        ("T", True, True),
+        ("t", False, True),
+        ("T", False, True),
+        ("y", True, True),
+        ("Y", True, True),
+        ("y", False, True),
+        ("Y", False, True),
+        ("1", True, True),
+        ("1", False, True),
+        ("True", True, True),
+        ("True", False, True),
+        ("true", True, True),
+        ("true", False, True),
+        ("tRuE", True, True),
+        ("tRuE", False, True),
+        ("Yes", True, True),
+        ("Yes", False, True),
+        ("yes", True, True),
+        ("yes", False, True),
+        ("yEs", True, True),
+        ("yEs", False, True),
+        ("On", True, True),
+        ("On", False, True),
+        ("on", True, True),
+        ("on", False, True),
+        ("oN", True, True),
+        ("oN", False, True),
+        ("f", True, False),
+        ("f", False, False),
+        ("n", True, False),
+        ("N", True, False),
+        ("n", False, False),
+        ("N", False, False),
+        ("0", True, False),
+        ("0", False, False),
+        ("False", True, False),
+        ("False", False, False),
+        ("false", True, False),
+        ("false", False, False),
+        ("FaLsE", True, False),
+        ("FaLsE", False, False),
+        ("No", True, False),
+        ("No", False, False),
+        ("no", True, False),
+        ("no", False, False),
+        ("nO", True, False),
+        ("nO", False, False),
+        ("Off", True, False),
+        ("Off", False, False),
+        ("off", True, False),
+        ("off", False, False),
+        ("oFf", True, False),
+        ("oFf", False, False),
+        ("xxx", True, None),
+        ("xxx", False, True),
+    ],
+)
+def test_env_to_bool(env_var_value, strict, expected):
+    assert env_to_bool(env_var_value, strict=strict) == expected, (
+        f"Value: {env_var_value}, strict: {strict}"
+    )
 
 
 @pytest.mark.parametrize(
@@ -68,12 +243,7 @@
     ],
 )
 def test_sanitize_url(url, expected_result):
-    # sort parts because old Python versions (<3.6) don't preserve order
-    sanitized_url = sanitize_url(url)
-    parts = sorted(re.split(r"\&|\?|\#", sanitized_url))
-    expected_parts = sorted(re.split(r"\&|\?|\#", expected_result))
-
-    assert parts == expected_parts
+    assert sanitize_url(url) == expected_result
 
 
 @pytest.mark.parametrize(
@@ -187,17 +357,20 @@ def test_sanitize_url(url, expected_result):
 )
 def test_sanitize_url_and_split(url, expected_result):
     sanitized_url = sanitize_url(url, split=True)
-    # sort query because old Python versions (<3.6) don't preserve order
-    query = sorted(sanitized_url.query.split("&"))
-    expected_query = sorted(expected_result.query.split("&"))
 
     assert sanitized_url.scheme == expected_result.scheme
     assert sanitized_url.netloc == expected_result.netloc
-    assert query == expected_query
+    assert sanitized_url.query == expected_result.query
     assert sanitized_url.path == expected_result.path
     assert sanitized_url.fragment == expected_result.fragment
 
 
+def test_sanitize_url_remove_authority_is_false():
+    url = "https://usr:pwd@example.com"
+    sanitized_url = sanitize_url(url, remove_authority=False)
+    assert sanitized_url == url
+
+
 @pytest.mark.parametrize(
     ("url", "sanitize", "expected_url", "expected_query", "expected_fragment"),
     [
@@ -320,13 +493,7 @@ def test_sanitize_url_and_split(url, expected_result):
 def test_parse_url(url, sanitize, expected_url, expected_query, expected_fragment):
     assert parse_url(url, sanitize=sanitize).url == expected_url
     assert parse_url(url, sanitize=sanitize).fragment == expected_fragment
-
-    # sort parts because old Python versions (<3.6) don't preserve order
-    sanitized_query = parse_url(url, sanitize=sanitize).query
-    query_parts = sorted(re.split(r"\&|\?|\#", sanitized_query))
-    expected_query_parts = sorted(re.split(r"\&|\?|\#", expected_query))
-
-    assert query_parts == expected_query_parts
+    assert parse_url(url, sanitize=sanitize).query == expected_query
 
 
 @pytest.mark.parametrize(
@@ -432,19 +599,17 @@ def test_parse_version(version, expected_result):
 
 
 @pytest.fixture
-def mock_hub_with_dsn_netloc():
+def mock_client_with_dsn_netloc():
     """
-    Returns a mocked hub with a DSN netloc of "abcd1234.ingest.sentry.io".
+    Returns a mocked Client with a DSN netloc of "abcd1234.ingest.sentry.io".
     """
+    mock_client = mock.Mock(spec=sentry_sdk.Client)
+    mock_client.transport = mock.Mock(spec=sentry_sdk.Transport)
+    mock_client.transport.parsed_dsn = mock.Mock(spec=Dsn)
 
-    mock_hub = mock.Mock(spec=sentry_sdk.Hub)
-    mock_hub.client = mock.Mock(spec=sentry_sdk.Client)
-    mock_hub.client.transport = mock.Mock(spec=sentry_sdk.Transport)
-    mock_hub.client.transport.parsed_dsn = mock.Mock(spec=Dsn)
+    mock_client.transport.parsed_dsn.netloc = "abcd1234.ingest.sentry.io"
 
-    mock_hub.client.transport.parsed_dsn.netloc = "abcd1234.ingest.sentry.io"
-
-    return mock_hub
+    return mock_client
 
 
 @pytest.mark.parametrize(
@@ -454,19 +619,18 @@ def mock_hub_with_dsn_netloc():
         ["https://asdf@abcd1234.ingest.notsentry.io/123456789", False],
     ],
 )
-def test_is_sentry_url_true(test_url, is_sentry_url_expected, mock_hub_with_dsn_netloc):
-    ret_val = is_sentry_url(mock_hub_with_dsn_netloc, test_url)
+def test_is_sentry_url_true(
+    test_url, is_sentry_url_expected, mock_client_with_dsn_netloc
+):
+    ret_val = is_sentry_url(mock_client_with_dsn_netloc, test_url)
 
     assert ret_val == is_sentry_url_expected
 
 
 def test_is_sentry_url_no_client():
-    hub = mock.Mock()
-    hub.client = None
-
     test_url = "https://asdf@abcd1234.ingest.sentry.io/123456789"
 
-    ret_val = is_sentry_url(hub, test_url)
+    ret_val = is_sentry_url(None, test_url)
 
     assert not ret_val
 
@@ -488,3 +652,385 @@ def test_get_error_message(error, expected_result):
         exc_value.detail = error
         raise Exception
     assert get_error_message(exc_value) == expected_result(exc_value)
+
+
+def test_safe_str_fails():
+    class ExplodingStr:
+        def __str__(self):
+            raise Exception
+
+    obj = ExplodingStr()
+    result = safe_str(obj)
+
+    assert result == repr(obj)
+
+
+def test_installed_modules_caching():
+    mock_generate_installed_modules = mock.Mock()
+    mock_generate_installed_modules.return_value = {"package": "1.0.0"}
+    with mock.patch("sentry_sdk.utils._installed_modules", None):
+        with mock.patch(
+            "sentry_sdk.utils._generate_installed_modules",
+            mock_generate_installed_modules,
+        ):
+            _get_installed_modules()
+            assert mock_generate_installed_modules.called
+            mock_generate_installed_modules.reset_mock()
+
+            _get_installed_modules()
+            mock_generate_installed_modules.assert_not_called()
+
+
+def test_devnull_inaccessible():
+    with mock.patch("sentry_sdk.utils.open", side_effect=OSError("oh no")):
+        revision = get_git_revision()
+
+    assert revision is None
+
+
+def test_devnull_not_found():
+    with mock.patch("sentry_sdk.utils.open", side_effect=FileNotFoundError("oh no")):
+        revision = get_git_revision()
+
+    assert revision is None
+
+
+def test_default_release():
+    release = get_default_release()
+    assert release is not None
+
+
+def test_default_release_empty_string():
+    with mock.patch("sentry_sdk.utils.get_git_revision", return_value=""):
+        release = get_default_release()
+
+    assert release is None
+
+
+def test_get_default_release_sentry_release_env(monkeypatch):
+    monkeypatch.setenv("SENTRY_RELEASE", "sentry-env-release")
+    assert get_default_release() == "sentry-env-release"
+
+
+def test_get_default_release_other_release_env(monkeypatch):
+    monkeypatch.setenv("SOURCE_VERSION", "other-env-release")
+
+    with mock.patch("sentry_sdk.utils.get_git_revision", return_value=""):
+        release = get_default_release()
+
+    assert release == "other-env-release"
+
+
+def test_ensure_integration_enabled_integration_enabled(sentry_init):
+    def original_function():
+        return "original"
+
+    def function_to_patch():
+        return "patched"
+
+    sentry_init(integrations=[TestIntegration()])
+
+    # Test the decorator by applying to function_to_patch
+    patched_function = ensure_integration_enabled(TestIntegration, original_function)(
+        function_to_patch
+    )
+
+    assert patched_function() == "patched"
+    assert patched_function.__name__ == "original_function"
+
+
+def test_ensure_integration_enabled_integration_disabled(sentry_init):
+    def original_function():
+        return "original"
+
+    def function_to_patch():
+        return "patched"
+
+    sentry_init(integrations=[])  # TestIntegration is disabled
+
+    # Test the decorator by applying to function_to_patch
+    patched_function = ensure_integration_enabled(TestIntegration, original_function)(
+        function_to_patch
+    )
+
+    assert patched_function() == "original"
+    assert patched_function.__name__ == "original_function"
+
+
+def test_ensure_integration_enabled_no_original_function_enabled(sentry_init):
+    shared_variable = "original"
+
+    def function_to_patch():
+        nonlocal shared_variable
+        shared_variable = "patched"
+
+    sentry_init(integrations=[TestIntegration])
+
+    # Test the decorator by applying to function_to_patch
+    patched_function = ensure_integration_enabled(TestIntegration)(function_to_patch)
+    patched_function()
+
+    assert shared_variable == "patched"
+    assert patched_function.__name__ == "function_to_patch"
+
+
+def test_ensure_integration_enabled_no_original_function_disabled(sentry_init):
+    shared_variable = "original"
+
+    def function_to_patch():
+        nonlocal shared_variable
+        shared_variable = "patched"
+
+    sentry_init(integrations=[])
+
+    # Test the decorator by applying to function_to_patch
+    patched_function = ensure_integration_enabled(TestIntegration)(function_to_patch)
+    patched_function()
+
+    assert shared_variable == "original"
+    assert patched_function.__name__ == "function_to_patch"
+
+
+@pytest.mark.parametrize(
+    "delta,expected_milliseconds",
+    [
+        [timedelta(milliseconds=132), 132.0],
+        [timedelta(hours=1, milliseconds=132), float(60 * 60 * 1000 + 132)],
+        [timedelta(days=10), float(10 * 24 * 60 * 60 * 1000)],
+        [timedelta(microseconds=100), 0.1],
+    ],
+)
+def test_duration_in_milliseconds(delta, expected_milliseconds):
+    assert delta / timedelta(milliseconds=1) == expected_milliseconds
+
+
+def test_get_current_thread_meta_explicit_thread():
+    results = Queue(maxsize=1)
+
+    def target1():
+        pass
+
+    def target2():
+        results.put(get_current_thread_meta(thread1))
+
+    thread1 = threading.Thread(target=target1)
+    thread1.start()
+
+    thread2 = threading.Thread(target=target2)
+    thread2.start()
+
+    thread2.join()
+    thread1.join()
+
+    assert (thread1.ident, thread1.name) == results.get(timeout=1)
+
+
+def test_get_current_thread_meta_bad_explicit_thread():
+    thread = "fake thread"
+
+    main_thread = threading.main_thread()
+
+    assert (main_thread.ident, main_thread.name) == get_current_thread_meta(thread)
+
+
+@pytest.mark.skipif(gevent is None, reason="gevent not enabled")
+def test_get_current_thread_meta_gevent_in_thread():
+    results = Queue(maxsize=1)
+
+    def target():
+        with mock.patch("sentry_sdk.utils.is_gevent", side_effect=[True]):
+            job = gevent.spawn(get_current_thread_meta)
+            job.join()
+            results.put(job.value)
+
+    thread = threading.Thread(target=target)
+    thread.start()
+    thread.join()
+    assert (thread.ident, None) == results.get(timeout=1)
+
+
+@pytest.mark.skipif(gevent is None, reason="gevent not enabled")
+def test_get_current_thread_meta_gevent_in_thread_failed_to_get_hub():
+    results = Queue(maxsize=1)
+
+    def target():
+        with mock.patch("sentry_sdk.utils.is_gevent", side_effect=[True]):
+            with mock.patch(
+                "sentry_sdk.utils.get_gevent_hub", side_effect=["fake gevent hub"]
+            ):
+                job = gevent.spawn(get_current_thread_meta)
+                job.join()
+                results.put(job.value)
+
+    thread = threading.Thread(target=target)
+    thread.start()
+    thread.join()
+    assert (thread.ident, thread.name) == results.get(timeout=1)
+
+
+def test_get_current_thread_meta_running_thread():
+    results = Queue(maxsize=1)
+
+    def target():
+        results.put(get_current_thread_meta())
+
+    thread = threading.Thread(target=target)
+    thread.start()
+    thread.join()
+    assert (thread.ident, thread.name) == results.get(timeout=1)
+
+
+def test_get_current_thread_meta_bad_running_thread():
+    results = Queue(maxsize=1)
+
+    def target():
+        with mock.patch("threading.current_thread", side_effect=["fake thread"]):
+            results.put(get_current_thread_meta())
+
+    thread = threading.Thread(target=target)
+    thread.start()
+    thread.join()
+
+    main_thread = threading.main_thread()
+    assert (main_thread.ident, main_thread.name) == results.get(timeout=1)
+
+
+def test_get_current_thread_meta_main_thread():
+    results = Queue(maxsize=1)
+
+    def target():
+        # mock that somehow the current thread doesn't exist
+        with mock.patch("threading.current_thread", side_effect=[None]):
+            results.put(get_current_thread_meta())
+
+    main_thread = threading.main_thread()
+
+    thread = threading.Thread(target=target)
+    thread.start()
+    thread.join()
+    assert (main_thread.ident, main_thread.name) == results.get(timeout=1)
+
+
+@pytest.mark.skipif(PY38, reason="Flakes a lot on 3.8 in CI.")
+def test_get_current_thread_meta_failed_to_get_main_thread():
+    results = Queue(maxsize=1)
+
+    def target():
+        with mock.patch("threading.current_thread", side_effect=["fake thread"]):
+            with mock.patch("threading.current_thread", side_effect=["fake thread"]):
+                results.put(get_current_thread_meta())
+
+    main_thread = threading.main_thread()
+
+    thread = threading.Thread(target=target)
+    thread.start()
+    thread.join()
+    assert (main_thread.ident, main_thread.name) == results.get(timeout=1)
+
+
+@pytest.mark.parametrize(
+    ("datetime_object", "expected_output"),
+    (
+        (
+            datetime(2021, 1, 1, tzinfo=timezone.utc),
+            "2021-01-01T00:00:00.000000Z",
+        ),  # UTC time
+        (
+            datetime(2021, 1, 1, tzinfo=timezone(timedelta(hours=2))),
+            "2020-12-31T22:00:00.000000Z",
+        ),  # UTC+2 time
+        (
+            datetime(2021, 1, 1, tzinfo=timezone(timedelta(hours=-7))),
+            "2021-01-01T07:00:00.000000Z",
+        ),  # UTC-7 time
+        (
+            datetime(2021, 2, 3, 4, 56, 7, 890123, tzinfo=timezone.utc),
+            "2021-02-03T04:56:07.890123Z",
+        ),  # UTC time all non-zero fields
+    ),
+)
+def test_format_timestamp(datetime_object, expected_output):
+    formatted = format_timestamp(datetime_object)
+
+    assert formatted == expected_output
+
+
+def test_format_timestamp_naive():
+    datetime_object = datetime(2021, 1, 1)
+    timestamp_regex = r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{6}Z"
+
+    # Ensure that some timestamp is returned, without error. We currently treat these as local time, but this is an
+    # implementation detail which we should not assert here.
+    assert re.fullmatch(timestamp_regex, format_timestamp(datetime_object))
+
+
+def test_qualname_from_function_inner_function():
+    def test_function(): ...
+
+    assert (
+        sentry_sdk.utils.qualname_from_function(test_function)
+        == "tests.test_utils.test_qualname_from_function_inner_function..test_function"
+    )
+
+
+def test_qualname_from_function_none_name():
+    def test_function(): ...
+
+    test_function.__module__ = None
+
+    assert (
+        sentry_sdk.utils.qualname_from_function(test_function)
+        == "test_qualname_from_function_none_name..test_function"
+    )
+
+
+def test_to_string_unicode_decode_error():
+    class BadStr:
+        def __str__(self):
+            raise UnicodeDecodeError("utf-8", b"", 0, 1, "reason")
+
+    obj = BadStr()
+    result = to_string(obj)
+    assert result == repr(obj)[1:-1]
+
+
+def test_exc_info_from_error_dont_get_an_exc():
+    class NotAnException:
+        pass
+
+    with pytest.raises(ValueError) as exc:
+        exc_info_from_error(NotAnException())
+
+    assert "Expected Exception object to report, got .my_agent"
+    )
+    assert agent_span["data"] == {
+        "gen_ai.agent.name": "test_decorator.test_span_templates_ai_dicts..my_agent",
+        "gen_ai.operation.name": "invoke_agent",
+        "thread.id": mock.ANY,
+        "thread.name": mock.ANY,
+    }
+
+    assert tool_span["op"] == "gen_ai.execute_tool"
+    assert (
+        tool_span["description"]
+        == "execute_tool test_decorator.test_span_templates_ai_dicts..my_tool"
+    )
+    assert tool_span["data"] == {
+        "gen_ai.tool.name": "test_decorator.test_span_templates_ai_dicts..my_tool",
+        "gen_ai.operation.name": "execute_tool",
+        "gen_ai.usage.input_tokens": 10,
+        "gen_ai.usage.output_tokens": 20,
+        "gen_ai.usage.total_tokens": 30,
+        "thread.id": mock.ANY,
+        "thread.name": mock.ANY,
+    }
+    assert "gen_ai.tool.description" not in tool_span["data"]
+
+    assert chat_span["op"] == "gen_ai.chat"
+    assert chat_span["description"] == "chat my-gpt-4o-mini"
+    assert chat_span["data"] == {
+        "gen_ai.operation.name": "chat",
+        "gen_ai.request.frequency_penalty": 1.0,
+        "gen_ai.request.max_tokens": 100,
+        "gen_ai.request.messages": "[{'role': 'user', 'content': 'What is the weather in Tokyo?'}, {'role': 'system', 'content': 'You are a helpful assistant that can answer questions about the weather.'}]",
+        "gen_ai.request.model": "my-gpt-4o-mini",
+        "gen_ai.request.presence_penalty": 2.0,
+        "gen_ai.request.temperature": 0.5,
+        "gen_ai.request.top_k": 40,
+        "gen_ai.request.top_p": 0.9,
+        "gen_ai.response.model": "my-gpt-4o-mini-v123",
+        "gen_ai.usage.input_tokens": 11,
+        "gen_ai.usage.output_tokens": 22,
+        "gen_ai.usage.total_tokens": 33,
+        "thread.id": mock.ANY,
+        "thread.name": mock.ANY,
+    }
+
+
+def test_span_templates_ai_objects(sentry_init, capture_events):
+    sentry_init(traces_sample_rate=1.0)
+    events = capture_events()
+
+    @sentry_sdk.trace(template=SPANTEMPLATE.AI_TOOL)
+    def my_tool(arg1, arg2):
+        """This is a tool function."""
+        mock_usage = mock.Mock()
+        mock_usage.prompt_tokens = 10
+        mock_usage.completion_tokens = 20
+        mock_usage.total_tokens = 30
+
+        mock_result = mock.Mock()
+        mock_result.output = "my_tool_result"
+        mock_result.usage = mock_usage
+
+        return mock_result
+
+    @sentry_sdk.trace(template=SPANTEMPLATE.AI_CHAT)
+    def my_chat(model=None, **kwargs):
+        mock_result = mock.Mock()
+        mock_result.content = "my_chat_result"
+        mock_result.usage = mock.Mock(
+            input_tokens=11,
+            output_tokens=22,
+            total_tokens=33,
+        )
+        mock_result.model = f"{model}-v123"
+
+        return mock_result
+
+    @sentry_sdk.trace(template=SPANTEMPLATE.AI_AGENT)
+    def my_agent():
+        my_tool(1, 2)
+        my_chat(
+            model="my-gpt-4o-mini",
+            prompt="What is the weather in Tokyo?",
+            system_prompt="You are a helpful assistant that can answer questions about the weather.",
+            max_tokens=100,
+            temperature=0.5,
+            top_p=0.9,
+            top_k=40,
+            frequency_penalty=1.0,
+            presence_penalty=2.0,
+        )
+
+    with sentry_sdk.start_transaction(name="test-transaction"):
+        my_agent()
+
+    (event,) = events
+    (agent_span, tool_span, chat_span) = event["spans"]
+
+    assert agent_span["op"] == "gen_ai.invoke_agent"
+    assert (
+        agent_span["description"]
+        == "invoke_agent test_decorator.test_span_templates_ai_objects..my_agent"
+    )
+    assert agent_span["data"] == {
+        "gen_ai.agent.name": "test_decorator.test_span_templates_ai_objects..my_agent",
+        "gen_ai.operation.name": "invoke_agent",
+        "thread.id": mock.ANY,
+        "thread.name": mock.ANY,
+    }
+
+    assert tool_span["op"] == "gen_ai.execute_tool"
+    assert (
+        tool_span["description"]
+        == "execute_tool test_decorator.test_span_templates_ai_objects..my_tool"
+    )
+    assert tool_span["data"] == {
+        "gen_ai.tool.name": "test_decorator.test_span_templates_ai_objects..my_tool",
+        "gen_ai.tool.description": "This is a tool function.",
+        "gen_ai.operation.name": "execute_tool",
+        "gen_ai.usage.input_tokens": 10,
+        "gen_ai.usage.output_tokens": 20,
+        "gen_ai.usage.total_tokens": 30,
+        "thread.id": mock.ANY,
+        "thread.name": mock.ANY,
+    }
+
+    assert chat_span["op"] == "gen_ai.chat"
+    assert chat_span["description"] == "chat my-gpt-4o-mini"
+    assert chat_span["data"] == {
+        "gen_ai.operation.name": "chat",
+        "gen_ai.request.frequency_penalty": 1.0,
+        "gen_ai.request.max_tokens": 100,
+        "gen_ai.request.messages": "[{'role': 'user', 'content': 'What is the weather in Tokyo?'}, {'role': 'system', 'content': 'You are a helpful assistant that can answer questions about the weather.'}]",
+        "gen_ai.request.model": "my-gpt-4o-mini",
+        "gen_ai.request.presence_penalty": 2.0,
+        "gen_ai.request.temperature": 0.5,
+        "gen_ai.request.top_k": 40,
+        "gen_ai.request.top_p": 0.9,
+        "gen_ai.response.model": "my-gpt-4o-mini-v123",
+        "gen_ai.usage.input_tokens": 11,
+        "gen_ai.usage.output_tokens": 22,
+        "gen_ai.usage.total_tokens": 33,
+        "thread.id": mock.ANY,
+        "thread.name": mock.ANY,
+    }
+
+
+@pytest.mark.parametrize("send_default_pii", [True, False])
+def test_span_templates_ai_pii(sentry_init, capture_events, send_default_pii):
+    sentry_init(traces_sample_rate=1.0, send_default_pii=send_default_pii)
+    events = capture_events()
+
+    @sentry_sdk.trace(template=SPANTEMPLATE.AI_TOOL)
+    def my_tool(arg1, arg2, **kwargs):
+        """This is a tool function."""
+        return "tool_output"
+
+    @sentry_sdk.trace(template=SPANTEMPLATE.AI_CHAT)
+    def my_chat(model=None, **kwargs):
+        return "chat_output"
+
+    @sentry_sdk.trace(template=SPANTEMPLATE.AI_AGENT)
+    def my_agent(*args, **kwargs):
+        my_tool(1, 2, tool_arg1="3", tool_arg2="4")
+        my_chat(
+            model="my-gpt-4o-mini",
+            prompt="What is the weather in Tokyo?",
+            system_prompt="You are a helpful assistant that can answer questions about the weather.",
+            max_tokens=100,
+            temperature=0.5,
+            top_p=0.9,
+            top_k=40,
+            frequency_penalty=1.0,
+            presence_penalty=2.0,
+        )
+        return "agent_output"
+
+    with sentry_sdk.start_transaction(name="test-transaction"):
+        my_agent(22, 33, arg1=44, arg2=55)
+
+    (event,) = events
+    (_, tool_span, _) = event["spans"]
+
+    if send_default_pii:
+        assert (
+            tool_span["data"]["gen_ai.tool.input"]
+            == "{'args': (1, 2), 'kwargs': {'tool_arg1': '3', 'tool_arg2': '4'}}"
+        )
+        assert tool_span["data"]["gen_ai.tool.output"] == "'tool_output'"
+    else:
+        assert "gen_ai.tool.input" not in tool_span["data"]
+        assert "gen_ai.tool.output" not in tool_span["data"]
diff --git a/tests/tracing/test_decorator_py2.py b/tests/tracing/test_decorator_py2.py
deleted file mode 100644
index 9969786623..0000000000
--- a/tests/tracing/test_decorator_py2.py
+++ /dev/null
@@ -1,54 +0,0 @@
-from sentry_sdk.tracing_utils_py2 import (
-    start_child_span_decorator as start_child_span_decorator_py2,
-)
-from sentry_sdk.utils import logger
-
-try:
-    from unittest import mock  # python 3.3 and above
-except ImportError:
-    import mock  # python < 3.3
-
-
-def my_example_function():
-    return "return_of_sync_function"
-
-
-def test_trace_decorator_py2():
-    fake_start_child = mock.MagicMock()
-    fake_transaction = mock.MagicMock()
-    fake_transaction.start_child = fake_start_child
-
-    with mock.patch(
-        "sentry_sdk.tracing_utils_py2.get_current_span",
-        return_value=fake_transaction,
-    ):
-        result = my_example_function()
-        fake_start_child.assert_not_called()
-        assert result == "return_of_sync_function"
-
-        result2 = start_child_span_decorator_py2(my_example_function)()
-        fake_start_child.assert_called_once_with(
-            op="function", description="test_decorator_py2.my_example_function"
-        )
-        assert result2 == "return_of_sync_function"
-
-
-def test_trace_decorator_py2_no_trx():
-    fake_transaction = None
-
-    with mock.patch(
-        "sentry_sdk.tracing_utils_py2.get_current_span",
-        return_value=fake_transaction,
-    ):
-        with mock.patch.object(logger, "warning", mock.Mock()) as fake_warning:
-            result = my_example_function()
-            fake_warning.assert_not_called()
-            assert result == "return_of_sync_function"
-
-            result2 = start_child_span_decorator_py2(my_example_function)()
-            fake_warning.assert_called_once_with(
-                "Can not create a child span for %s. "
-                "Please start a Sentry transaction before calling this function.",
-                "test_decorator_py2.my_example_function",
-            )
-            assert result2 == "return_of_sync_function"
diff --git a/tests/tracing/test_decorator_py3.py b/tests/tracing/test_decorator_py3.py
deleted file mode 100644
index c458e8add4..0000000000
--- a/tests/tracing/test_decorator_py3.py
+++ /dev/null
@@ -1,103 +0,0 @@
-from unittest import mock
-import pytest
-import sys
-
-from sentry_sdk.tracing_utils_py3 import (
-    start_child_span_decorator as start_child_span_decorator_py3,
-)
-from sentry_sdk.utils import logger
-
-if sys.version_info < (3, 6):
-    pytest.skip("Async decorator only works on Python 3.6+", allow_module_level=True)
-
-
-def my_example_function():
-    return "return_of_sync_function"
-
-
-async def my_async_example_function():
-    return "return_of_async_function"
-
-
-def test_trace_decorator_sync_py3():
-    fake_start_child = mock.MagicMock()
-    fake_transaction = mock.MagicMock()
-    fake_transaction.start_child = fake_start_child
-
-    with mock.patch(
-        "sentry_sdk.tracing_utils_py3.get_current_span",
-        return_value=fake_transaction,
-    ):
-        result = my_example_function()
-        fake_start_child.assert_not_called()
-        assert result == "return_of_sync_function"
-
-        result2 = start_child_span_decorator_py3(my_example_function)()
-        fake_start_child.assert_called_once_with(
-            op="function", description="test_decorator_py3.my_example_function"
-        )
-        assert result2 == "return_of_sync_function"
-
-
-def test_trace_decorator_sync_py3_no_trx():
-    fake_transaction = None
-
-    with mock.patch(
-        "sentry_sdk.tracing_utils_py3.get_current_span",
-        return_value=fake_transaction,
-    ):
-        with mock.patch.object(logger, "warning", mock.Mock()) as fake_warning:
-            result = my_example_function()
-            fake_warning.assert_not_called()
-            assert result == "return_of_sync_function"
-
-            result2 = start_child_span_decorator_py3(my_example_function)()
-            fake_warning.assert_called_once_with(
-                "Can not create a child span for %s. "
-                "Please start a Sentry transaction before calling this function.",
-                "test_decorator_py3.my_example_function",
-            )
-            assert result2 == "return_of_sync_function"
-
-
-@pytest.mark.asyncio
-async def test_trace_decorator_async_py3():
-    fake_start_child = mock.MagicMock()
-    fake_transaction = mock.MagicMock()
-    fake_transaction.start_child = fake_start_child
-
-    with mock.patch(
-        "sentry_sdk.tracing_utils_py3.get_current_span",
-        return_value=fake_transaction,
-    ):
-        result = await my_async_example_function()
-        fake_start_child.assert_not_called()
-        assert result == "return_of_async_function"
-
-        result2 = await start_child_span_decorator_py3(my_async_example_function)()
-        fake_start_child.assert_called_once_with(
-            op="function", description="test_decorator_py3.my_async_example_function"
-        )
-        assert result2 == "return_of_async_function"
-
-
-@pytest.mark.asyncio
-async def test_trace_decorator_async_py3_no_trx():
-    fake_transaction = None
-
-    with mock.patch(
-        "sentry_sdk.tracing_utils_py3.get_current_span",
-        return_value=fake_transaction,
-    ):
-        with mock.patch.object(logger, "warning", mock.Mock()) as fake_warning:
-            result = await my_async_example_function()
-            fake_warning.assert_not_called()
-            assert result == "return_of_async_function"
-
-            result2 = await start_child_span_decorator_py3(my_async_example_function)()
-            fake_warning.assert_called_once_with(
-                "Can not create a child span for %s. "
-                "Please start a Sentry transaction before calling this function.",
-                "test_decorator_py3.my_async_example_function",
-            )
-            assert result2 == "return_of_async_function"
diff --git a/tests/tracing/test_deprecated.py b/tests/tracing/test_deprecated.py
index 0ce9096b6e..ac3b8d7463 100644
--- a/tests/tracing/test_deprecated.py
+++ b/tests/tracing/test_deprecated.py
@@ -1,20 +1,41 @@
+import warnings
+
+import pytest
+
+import sentry_sdk
+import sentry_sdk.tracing
 from sentry_sdk import start_span
 
 from sentry_sdk.tracing import Span
 
 
-def test_start_span_to_start_transaction(sentry_init, capture_events):
-    # XXX: this only exists for backwards compatibility with code before
-    # Transaction / start_transaction were introduced.
-    sentry_init(traces_sample_rate=1.0)
-    events = capture_events()
+@pytest.mark.parametrize(
+    "parameter_value_getter",
+    # Use lambda to avoid Hub deprecation warning here (will suppress it in the test)
+    (lambda: sentry_sdk.Hub(), lambda: sentry_sdk.Scope()),
+)
+def test_passing_hub_parameter_to_transaction_finish(
+    suppress_deprecation_warnings, parameter_value_getter
+):
+    parameter_value = parameter_value_getter()
+    transaction = sentry_sdk.tracing.Transaction()
+    with pytest.warns(DeprecationWarning):
+        transaction.finish(hub=parameter_value)
+
+
+def test_passing_hub_object_to_scope_transaction_finish(suppress_deprecation_warnings):
+    transaction = sentry_sdk.tracing.Transaction()
+
+    # Do not move the following line under the `with` statement. Otherwise, the Hub.__init__ deprecation
+    # warning will be confused with the transaction.finish deprecation warning that we are testing.
+    hub = sentry_sdk.Hub()
 
-    with start_span(transaction="/1/"):
-        pass
+    with pytest.warns(DeprecationWarning):
+        transaction.finish(hub)
 
-    with start_span(Span(transaction="/2/")):
-        pass
 
-    assert len(events) == 2
-    assert events[0]["transaction"] == "/1/"
-    assert events[1]["transaction"] == "/2/"
+def test_no_warnings_scope_to_transaction_finish():
+    transaction = sentry_sdk.tracing.Transaction()
+    with warnings.catch_warnings():
+        warnings.simplefilter("error")
+        transaction.finish(sentry_sdk.Scope())
diff --git a/tests/tracing/test_http_headers.py b/tests/tracing/test_http_headers.py
index 443bb163e8..6a8467101e 100644
--- a/tests/tracing/test_http_headers.py
+++ b/tests/tracing/test_http_headers.py
@@ -1,15 +1,11 @@
+from unittest import mock
+
 import pytest
 
 from sentry_sdk.tracing import Transaction
 from sentry_sdk.tracing_utils import extract_sentrytrace_data
 
 
-try:
-    from unittest import mock  # python 3.3 and above
-except ImportError:
-    import mock  # python < 3.3
-
-
 @pytest.mark.parametrize("sampled", [True, False, None])
 def test_to_traceparent(sampled):
     transaction = Transaction(
diff --git a/tests/tracing/test_ignore_status_codes.py b/tests/tracing/test_ignore_status_codes.py
new file mode 100644
index 0000000000..b2899e0ad9
--- /dev/null
+++ b/tests/tracing/test_ignore_status_codes.py
@@ -0,0 +1,139 @@
+import sentry_sdk
+from sentry_sdk import start_transaction, start_span
+
+import pytest
+
+from collections import Counter
+
+
+def test_no_ignored_codes(sentry_init, capture_events):
+    sentry_init(
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    with start_transaction(op="http", name="GET /"):
+        span_or_tx = sentry_sdk.get_current_span()
+        span_or_tx.set_data("http.response.status_code", 404)
+
+    assert len(events) == 1
+
+
+@pytest.mark.parametrize("status_code", [200, 404])
+def test_single_code_ignored(sentry_init, capture_events, status_code):
+    sentry_init(
+        traces_sample_rate=1.0,
+        trace_ignore_status_codes={
+            404,
+        },
+    )
+    events = capture_events()
+
+    with start_transaction(op="http", name="GET /"):
+        span_or_tx = sentry_sdk.get_current_span()
+        span_or_tx.set_data("http.response.status_code", status_code)
+
+    if status_code == 404:
+        assert not events
+    else:
+        assert len(events) == 1
+
+
+@pytest.mark.parametrize("status_code", [200, 305, 307, 399, 404])
+def test_range_ignored(sentry_init, capture_events, status_code):
+    sentry_init(
+        traces_sample_rate=1.0,
+        trace_ignore_status_codes=set(
+            range(
+                305,
+                400,
+            ),
+        ),
+    )
+    events = capture_events()
+
+    with start_transaction(op="http", name="GET /"):
+        span_or_tx = sentry_sdk.get_current_span()
+        span_or_tx.set_data("http.response.status_code", status_code)
+
+    if 305 <= status_code <= 399:
+        assert not events
+    else:
+        assert len(events) == 1
+
+
+@pytest.mark.parametrize("status_code", [200, 301, 303, 355, 404])
+def test_variety_ignored(sentry_init, capture_events, status_code):
+    sentry_init(
+        traces_sample_rate=1.0,
+        trace_ignore_status_codes={
+            301,
+            302,
+            303,
+            *range(
+                305,
+                400,
+            ),
+            *range(
+                401,
+                405,
+            ),
+        },
+    )
+    events = capture_events()
+
+    with start_transaction(op="http", name="GET /"):
+        span_or_tx = sentry_sdk.get_current_span()
+        span_or_tx.set_data("http.response.status_code", status_code)
+
+    if (
+        301 <= status_code <= 303
+        or 305 <= status_code <= 399
+        or 401 <= status_code <= 404
+    ):
+        assert not events
+    else:
+        assert len(events) == 1
+
+
+def test_transaction_not_ignored_when_status_code_has_invalid_type(
+    sentry_init, capture_events
+):
+    sentry_init(
+        traces_sample_rate=1.0,
+        trace_ignore_status_codes=set(
+            range(401, 404),
+        ),
+    )
+    events = capture_events()
+
+    with start_transaction(op="http", name="GET /"):
+        span_or_tx = sentry_sdk.get_current_span()
+        span_or_tx.set_data("http.response.status_code", "404")
+
+    assert len(events) == 1
+
+
+def test_records_lost_events(sentry_init, capture_record_lost_event_calls):
+    sentry_init(
+        traces_sample_rate=1.0,
+        trace_ignore_status_codes={
+            404,
+        },
+    )
+    record_lost_event_calls = capture_record_lost_event_calls()
+
+    with start_transaction(op="http", name="GET /"):
+        span_or_tx = sentry_sdk.get_current_span()
+        span_or_tx.set_data("http.response.status_code", 404)
+
+        with start_span(op="child-span"):
+            with start_span(op="child-child-span"):
+                pass
+
+    assert Counter(record_lost_event_calls) == Counter(
+        [
+            ("event_processor", "transaction", None, 1),
+            ("event_processor", "span", None, 3),
+        ]
+    )
diff --git a/tests/tracing/test_integration_tests.py b/tests/tracing/test_integration_tests.py
index 0fe8117c8e..80945c1db5 100644
--- a/tests/tracing/test_integration_tests.py
+++ b/tests/tracing/test_integration_tests.py
@@ -1,19 +1,21 @@
-# coding: utf-8
-import weakref
 import gc
 import re
+import sys
+import weakref
+from unittest import mock
+
 import pytest
-import random
 
+import sentry_sdk
 from sentry_sdk import (
     capture_message,
-    configure_scope,
-    Hub,
     start_span,
     start_transaction,
+    continue_trace,
 )
+from sentry_sdk.consts import SPANSTATUS
 from sentry_sdk.transport import Transport
-from sentry_sdk.tracing import Transaction
+from tests.conftest import TestTransportWithOptions
 
 
 @pytest.mark.parametrize("sample_rate", [0.0, 1.0])
@@ -22,12 +24,12 @@ def test_basic(sentry_init, capture_events, sample_rate):
     events = capture_events()
 
     with start_transaction(name="hi") as transaction:
-        transaction.set_status("ok")
+        transaction.set_status(SPANSTATUS.OK)
         with pytest.raises(ZeroDivisionError):
-            with start_span(op="foo", description="foodesc"):
+            with start_span(op="foo", name="foodesc"):
                 1 / 0
 
-        with start_span(op="bar", description="bardesc"):
+        with start_span(op="bar", name="bardesc"):
             pass
 
     if sample_rate:
@@ -39,9 +41,11 @@ def test_basic(sentry_init, capture_events, sample_rate):
 
         span1, span2 = event["spans"]
         parent_span = event
+        assert span1["status"] == "internal_error"
         assert span1["tags"]["status"] == "internal_error"
         assert span1["op"] == "foo"
         assert span1["description"] == "foodesc"
+        assert "status" not in span2
         assert "status" not in span2.get("tags", {})
         assert span2["op"] == "bar"
         assert span2["description"] == "bardesc"
@@ -52,9 +56,9 @@ def test_basic(sentry_init, capture_events, sample_rate):
         assert not events
 
 
-@pytest.mark.parametrize("sampled", [True, False, None])
+@pytest.mark.parametrize("parent_sampled", [True, False, None])
 @pytest.mark.parametrize("sample_rate", [0.0, 1.0])
-def test_continue_from_headers(sentry_init, capture_envelopes, sampled, sample_rate):
+def test_continue_trace(sentry_init, capture_envelopes, parent_sampled, sample_rate):
     """
     Ensure data is actually passed along via headers, and that they are read
     correctly.
@@ -65,20 +69,23 @@ def test_continue_from_headers(sentry_init, capture_envelopes, sampled, sample_r
     # make a parent transaction (normally this would be in a different service)
     with start_transaction(name="hi", sampled=True if sample_rate == 0 else None):
         with start_span() as old_span:
-            old_span.sampled = sampled
-            headers = dict(Hub.current.iter_trace_propagation_headers(old_span))
+            old_span.sampled = parent_sampled
+            headers = dict(
+                sentry_sdk.get_current_scope().iter_trace_propagation_headers(old_span)
+            )
             headers["baggage"] = (
                 "other-vendor-value-1=foo;bar;baz, "
                 "sentry-trace_id=771a43a4192642f0b136d5159a501700, "
                 "sentry-public_key=49d0f7386ad645858ae85020e393bef3, "
                 "sentry-sample_rate=0.01337, sentry-user_id=Amelie, "
+                "sentry-sample_rand=0.250000, "
                 "other-vendor-value-2=foo;bar;"
             )
 
     # child transaction, to prove that we can read 'sentry-trace' header data correctly
-    child_transaction = Transaction.continue_from_headers(headers, name="WRONG")
+    child_transaction = continue_trace(headers, name="WRONG")
     assert child_transaction is not None
-    assert child_transaction.parent_sampled == sampled
+    assert child_transaction.parent_sampled == parent_sampled
     assert child_transaction.trace_id == old_span.trace_id
     assert child_transaction.same_process_as_parent is False
     assert child_transaction.parent_span_id == old_span.span_id
@@ -91,6 +98,7 @@ def test_continue_from_headers(sentry_init, capture_envelopes, sampled, sample_r
         "public_key": "49d0f7386ad645858ae85020e393bef3",
         "trace_id": "771a43a4192642f0b136d5159a501700",
         "user_id": "Amelie",
+        "sample_rand": "0.250000",
         "sample_rate": "0.01337",
     }
 
@@ -98,14 +106,13 @@ def test_continue_from_headers(sentry_init, capture_envelopes, sampled, sample_r
     # be tagged with the trace id (since it happens while the transaction is
     # open)
     with start_transaction(child_transaction):
-        with configure_scope() as scope:
-            # change the transaction name from "WRONG" to make sure the change
-            # is reflected in the final data
-            scope.transaction = "ho"
+        # change the transaction name from "WRONG" to make sure the change
+        # is reflected in the final data
+        sentry_sdk.get_current_scope().transaction = "ho"
         capture_message("hello")
 
-    # in this case the child transaction won't be captured
-    if sampled is False or (sample_rate == 0 and sampled is None):
+    if parent_sampled is False or (sample_rate == 0 and parent_sampled is None):
+        # in this case the child transaction won't be captured
         trace1, message = envelopes
         message_payload = message.get_event()
         trace1_payload = trace1.get_transaction_event()
@@ -127,17 +134,37 @@ def test_continue_from_headers(sentry_init, capture_envelopes, sampled, sample_r
             == message_payload["contexts"]["trace"]["trace_id"]
         )
 
+        if parent_sampled is not None:
+            expected_sample_rate = str(float(parent_sampled))
+        else:
+            expected_sample_rate = str(sample_rate)
+
         assert trace2.headers["trace"] == baggage.dynamic_sampling_context()
         assert trace2.headers["trace"] == {
             "public_key": "49d0f7386ad645858ae85020e393bef3",
             "trace_id": "771a43a4192642f0b136d5159a501700",
             "user_id": "Amelie",
-            "sample_rate": "0.01337",
+            "sample_rand": "0.250000",
+            "sample_rate": expected_sample_rate,
         }
 
     assert message_payload["message"] == "hello"
 
 
+@pytest.mark.parametrize("sample_rate", [0.0, 1.0])
+def test_propagate_traces_deprecation_warning(sentry_init, sample_rate):
+    sentry_init(traces_sample_rate=sample_rate, propagate_traces=False)
+
+    with start_transaction(name="hi"):
+        with start_span() as old_span:
+            with pytest.warns(DeprecationWarning):
+                dict(
+                    sentry_sdk.get_current_scope().iter_trace_propagation_headers(
+                        old_span
+                    )
+                )
+
+
 @pytest.mark.parametrize("sample_rate", [0.5, 1.0])
 def test_dynamic_sampling_head_sdk_creates_dsc(
     sentry_init, capture_envelopes, sample_rate, monkeypatch
@@ -146,19 +173,14 @@ def test_dynamic_sampling_head_sdk_creates_dsc(
     envelopes = capture_envelopes()
 
     # make sure transaction is sampled for both cases
-    monkeypatch.setattr(random, "random", lambda: 0.1)
+    with mock.patch("sentry_sdk.tracing_utils.Random.randrange", return_value=250000):
+        transaction = continue_trace({}, name="Head SDK tx")
 
-    transaction = Transaction.continue_from_headers({}, name="Head SDK tx")
-
-    # will create empty mutable baggage
     baggage = transaction._baggage
-    assert baggage
-    assert baggage.mutable
-    assert baggage.sentry_items == {}
-    assert baggage.third_party_items == ""
+    assert baggage is None
 
     with start_transaction(transaction):
-        with start_span(op="foo", description="foodesc"):
+        with start_span(op="foo", name="foodesc"):
             pass
 
     # finish will create a new baggage entry
@@ -173,15 +195,22 @@ def test_dynamic_sampling_head_sdk_creates_dsc(
         "release": "foo",
         "sample_rate": str(sample_rate),
         "sampled": "true" if transaction.sampled else "false",
+        "sample_rand": "0.250000",
         "transaction": "Head SDK tx",
         "trace_id": trace_id,
     }
 
     expected_baggage = (
-        "sentry-environment=production,sentry-release=foo,sentry-sample_rate=%s,sentry-transaction=Head%%20SDK%%20tx,sentry-trace_id=%s,sentry-sampled=%s"
-        % (sample_rate, trace_id, "true" if transaction.sampled else "false")
+        "sentry-trace_id=%s,"
+        "sentry-sample_rand=0.250000,"
+        "sentry-environment=production,"
+        "sentry-release=foo,"
+        "sentry-transaction=Head%%20SDK%%20tx,"
+        "sentry-sample_rate=%s,"
+        "sentry-sampled=%s"
+        % (trace_id, sample_rate, "true" if transaction.sampled else "false")
     )
-    assert sorted(baggage.serialize().split(",")) == sorted(expected_baggage.split(","))
+    assert baggage.serialize() == expected_baggage
 
     (envelope,) = envelopes
     assert envelope.headers["trace"] == baggage.dynamic_sampling_context()
@@ -189,6 +218,7 @@ def test_dynamic_sampling_head_sdk_creates_dsc(
         "environment": "production",
         "release": "foo",
         "sample_rate": str(sample_rate),
+        "sample_rand": "0.250000",
         "sampled": "true" if transaction.sampled else "false",
         "transaction": "Head SDK tx",
         "trace_id": trace_id,
@@ -206,7 +236,7 @@ def test_memory_usage(sentry_init, capture_events, args, expected_refcount):
 
     with start_transaction(name="hi"):
         for i in range(100):
-            with start_span(op="helloworld", description="hi {}".format(i)) as span:
+            with start_span(op="helloworld", name="hi {}".format(i)) as span:
 
                 def foo():
                     pass
@@ -243,14 +273,14 @@ def capture_envelope(self, envelope):
             pass
 
         def capture_event(self, event):
-            start_span(op="toolate", description="justdont")
+            start_span(op="toolate", name="justdont")
             pass
 
     sentry_init(traces_sample_rate=1, transport=CustomTransport())
     events = capture_events()
 
     with start_transaction(name="hi"):
-        with start_span(op="bar", description="bardesc"):
+        with start_span(op="bar", name="bardesc"):
             pass
 
     assert len(events) == 1
@@ -259,14 +289,14 @@ def capture_event(self, event):
 def test_trace_propagation_meta_head_sdk(sentry_init):
     sentry_init(traces_sample_rate=1.0, release="foo")
 
-    transaction = Transaction.continue_from_headers({}, name="Head SDK tx")
+    transaction = continue_trace({}, name="Head SDK tx")
     meta = None
     span = None
 
     with start_transaction(transaction):
-        with start_span(op="foo", description="foodesc") as current_span:
+        with start_span(op="foo", name="foodesc") as current_span:
             span = current_span
-            meta = Hub.current.trace_propagation_meta()
+            meta = sentry_sdk.get_current_scope().trace_propagation_meta()
 
     ind = meta.find(">") + 1
     sentry_trace, baggage = meta[:ind], meta[ind:]
@@ -278,3 +308,125 @@ def test_trace_propagation_meta_head_sdk(sentry_init):
     assert 'meta name="baggage"' in baggage
     baggage_content = re.findall('content="([^"]*)"', baggage)[0]
     assert baggage_content == transaction.get_baggage().serialize()
+
+
+@pytest.mark.parametrize(
+    "exception_cls,exception_value",
+    [
+        (SystemExit, 0),
+    ],
+)
+def test_non_error_exceptions(
+    sentry_init, capture_events, exception_cls, exception_value
+):
+    sentry_init(traces_sample_rate=1.0)
+    events = capture_events()
+
+    with start_transaction(name="hi") as transaction:
+        transaction.set_status(SPANSTATUS.OK)
+        with pytest.raises(exception_cls):
+            with start_span(op="foo", name="foodesc"):
+                raise exception_cls(exception_value)
+
+    assert len(events) == 1
+    event = events[0]
+
+    span = event["spans"][0]
+    assert "status" not in span
+    assert "status" not in span.get("tags", {})
+    assert "status" not in event["tags"]
+    assert event["contexts"]["trace"]["status"] == "ok"
+
+
+@pytest.mark.parametrize("exception_value", [None, 0, False])
+def test_good_sysexit_doesnt_fail_transaction(
+    sentry_init, capture_events, exception_value
+):
+    sentry_init(traces_sample_rate=1.0)
+    events = capture_events()
+
+    with start_transaction(name="hi") as transaction:
+        transaction.set_status(SPANSTATUS.OK)
+        with pytest.raises(SystemExit):
+            with start_span(op="foo", name="foodesc"):
+                if exception_value is not False:
+                    sys.exit(exception_value)
+                else:
+                    sys.exit()
+
+    assert len(events) == 1
+    event = events[0]
+
+    span = event["spans"][0]
+    assert "status" not in span
+    assert "status" not in span.get("tags", {})
+    assert "status" not in event["tags"]
+    assert event["contexts"]["trace"]["status"] == "ok"
+
+
+@pytest.mark.parametrize(
+    "strict_trace_continuation,baggage_org_id,dsn_org_id,should_continue_trace",
+    (
+        (True, "sentry-org_id=1234", "o1234", True),
+        (True, "sentry-org_id=1234", "o9999", False),
+        (True, "sentry-org_id=9999", "o1234", False),
+        (False, "sentry-org_id=1234", "o1234", True),
+        (False, "sentry-org_id=9999", "o1234", False),
+        (False, "sentry-org_id=1234", "o9999", False),
+        (False, "sentry-org_id=1234", "not_org_id", True),
+        (False, "", "o1234", True),
+    ),
+)
+def test_continue_trace_strict_trace_continuation(
+    sentry_init,
+    strict_trace_continuation,
+    baggage_org_id,
+    dsn_org_id,
+    should_continue_trace,
+):
+    sentry_init(
+        dsn=f"https://mysecret@{dsn_org_id}.ingest.sentry.io/12312012",
+        strict_trace_continuation=strict_trace_continuation,
+        traces_sample_rate=1.0,
+        transport=TestTransportWithOptions,
+    )
+
+    headers = {
+        "sentry-trace": "771a43a4192642f0b136d5159a501700-1234567890abcdef-1",
+        "baggage": (
+            "other-vendor-value-1=foo;bar;baz, sentry-trace_id=771a43a4192642f0b136d5159a501700, "
+            f"{baggage_org_id}, "
+            "sentry-public_key=49d0f7386ad645858ae85020e393bef3, sentry-sample_rate=0.01337, "
+            "sentry-user_id=Am%C3%A9lie, other-vendor-value-2=foo;bar;"
+        ),
+    }
+
+    transaction = continue_trace(headers, name="strict trace")
+
+    if should_continue_trace:
+        assert (
+            transaction.trace_id
+            == "771a43a4192642f0b136d5159a501700"
+            == "771a43a4192642f0b136d5159a501700"
+        )
+        assert transaction.parent_span_id == "1234567890abcdef"
+        assert transaction.parent_sampled
+    else:
+        assert (
+            transaction.trace_id
+            != "771a43a4192642f0b136d5159a501700"
+            == "771a43a4192642f0b136d5159a501700"
+        )
+        assert transaction.parent_span_id != "1234567890abcdef"
+        assert not transaction.parent_sampled
+
+
+def test_continue_trace_forces_new_traces_when_no_propagation(sentry_init):
+    """This is to make sure we don't have a long running trace because of TWP logic for the no propagation case."""
+
+    sentry_init(traces_sample_rate=1.0)
+
+    tx1 = continue_trace({}, name="tx1")
+    tx2 = continue_trace({}, name="tx2")
+
+    assert tx1.trace_id != tx2.trace_id
diff --git a/tests/tracing/test_misc.py b/tests/tracing/test_misc.py
index 01bf1c1b07..e1de847102 100644
--- a/tests/tracing/test_misc.py
+++ b/tests/tracing/test_misc.py
@@ -2,20 +2,16 @@
 import gc
 import uuid
 import os
+from unittest import mock
+from unittest.mock import MagicMock
 
 import sentry_sdk
-from sentry_sdk import Hub, start_span, start_transaction, set_measurement
+from sentry_sdk import start_span, start_transaction, set_measurement
 from sentry_sdk.consts import MATCH_ALL
 from sentry_sdk.tracing import Span, Transaction
 from sentry_sdk.tracing_utils import should_propagate_trace
 from sentry_sdk.utils import Dsn
-
-try:
-    from unittest import mock  # python 3.3 and above
-    from unittest.mock import MagicMock
-except ImportError:
-    import mock  # python < 3.3
-    from mock import MagicMock
+from tests.conftest import ApproxDict
 
 
 def test_span_trimming(sentry_init, capture_events):
@@ -36,16 +32,38 @@ def test_span_trimming(sentry_init, capture_events):
     assert span2["op"] == "foo1"
     assert span3["op"] == "foo2"
 
+    assert event["_meta"]["spans"][""]["len"] == 10
+    assert "_dropped_spans" not in event
+    assert "dropped_spans" not in event
+
+
+def test_span_data_scrubbing_and_trimming(sentry_init, capture_events):
+    sentry_init(traces_sample_rate=1.0, _experiments={"max_spans": 3})
+    events = capture_events()
+
+    with start_transaction(name="hi"):
+        with start_span(op="foo", name="bar") as span:
+            span.set_data("password", "secret")
+            span.set_data("datafoo", "databar")
+
+        for i in range(10):
+            with start_span(op="foo{}".format(i)):
+                pass
+
+    (event,) = events
+    assert event["spans"][0]["data"] == ApproxDict(
+        {"password": "[Filtered]", "datafoo": "databar"}
+    )
+    assert event["_meta"]["spans"] == {
+        "0": {"data": {"password": {"": {"rem": [["!config", "s"]]}}}},
+        "": {"len": 11},
+    }
+
 
 def test_transaction_naming(sentry_init, capture_events):
     sentry_init(traces_sample_rate=1.0)
     events = capture_events()
 
-    # only transactions have names - spans don't
-    with pytest.raises(TypeError):
-        start_span(name="foo")
-    assert len(events) == 0
-
     # default name in event if no name is passed
     with start_transaction() as transaction:
         pass
@@ -65,6 +83,33 @@ def test_transaction_naming(sentry_init, capture_events):
     assert events[2]["transaction"] == "a"
 
 
+def test_transaction_data(sentry_init, capture_events):
+    sentry_init(traces_sample_rate=1.0)
+    events = capture_events()
+
+    with start_transaction(name="test-transaction"):
+        span_or_tx = sentry_sdk.get_current_span()
+        span_or_tx.set_data("foo", "bar")
+        with start_span(op="test-span") as span:
+            span.set_data("spanfoo", "spanbar")
+
+    assert len(events) == 1
+
+    transaction = events[0]
+    transaction_data = transaction["contexts"]["trace"]["data"]
+
+    assert "data" not in transaction.keys()
+    assert transaction_data.items() >= {"foo": "bar"}.items()
+
+    assert len(transaction["spans"]) == 1
+
+    span = transaction["spans"][0]
+    span_data = span["data"]
+
+    assert "contexts" not in span.keys()
+    assert span_data.items() >= {"spanfoo": "spanbar"}.items()
+
+
 def test_start_transaction(sentry_init):
     sentry_init(traces_sample_rate=1.0)
 
@@ -89,7 +134,7 @@ def test_finds_transaction_on_scope(sentry_init):
 
     transaction = start_transaction(name="dogpark")
 
-    scope = Hub.current.scope
+    scope = sentry_sdk.get_current_scope()
 
     # See note in Scope class re: getters and setters of the `transaction`
     # property. For the moment, assigning to scope.transaction merely sets the
@@ -118,7 +163,7 @@ def test_finds_transaction_when_descendent_span_is_on_scope(
     transaction = start_transaction(name="dogpark")
     child_span = transaction.start_child(op="sniffing")
 
-    scope = Hub.current.scope
+    scope = sentry_sdk.get_current_scope()
     scope._span = child_span
 
     # this is the same whether it's the transaction itself or one of its
@@ -141,7 +186,7 @@ def test_finds_orphan_span_on_scope(sentry_init):
 
     span = start_span(op="sniffing")
 
-    scope = Hub.current.scope
+    scope = sentry_sdk.get_current_scope()
     scope._span = span
 
     assert scope._span is not None
@@ -155,7 +200,7 @@ def test_finds_non_orphan_span_on_scope(sentry_init):
     transaction = start_transaction(name="dogpark")
     child_span = transaction.start_child(op="sniffing")
 
-    scope = Hub.current.scope
+    scope = sentry_sdk.get_current_scope()
     scope._span = child_span
 
     assert scope._span is not None
@@ -278,6 +323,48 @@ def test_set_meaurement_public_api(sentry_init, capture_events):
     assert event["measurements"]["metric.bar"] == {"value": 456, "unit": "second"}
 
 
+def test_set_measurement_deprecated(sentry_init):
+    sentry_init(traces_sample_rate=1.0)
+
+    with start_transaction(name="measuring stuff") as trx:
+        with pytest.warns(DeprecationWarning):
+            set_measurement("metric.foo", 123)
+
+        with pytest.warns(DeprecationWarning):
+            trx.set_measurement("metric.bar", 456)
+
+        with start_span(op="measuring span") as span:
+            with pytest.warns(DeprecationWarning):
+                span.set_measurement("metric.baz", 420.69, unit="custom")
+
+
+def test_set_meaurement_compared_to_set_data(sentry_init, capture_events):
+    """
+    This is just a test to see the difference
+    between measurements and data in the resulting event payload.
+    """
+    sentry_init(traces_sample_rate=1.0)
+
+    events = capture_events()
+
+    with start_transaction(name="measuring stuff") as transaction:
+        transaction.set_measurement("metric.foo", 123)
+        transaction.set_data("metric.bar", 456)
+
+        with start_span(op="measuring span") as span:
+            span.set_measurement("metric.baz", 420.69, unit="custom")
+            span.set_data("metric.qux", 789)
+
+    (event,) = events
+    assert event["measurements"]["metric.foo"] == {"value": 123, "unit": ""}
+    assert event["contexts"]["trace"]["data"]["metric.bar"] == 456
+    assert event["spans"][0]["measurements"]["metric.baz"] == {
+        "value": 420.69,
+        "unit": "custom",
+    }
+    assert event["spans"][0]["data"]["metric.qux"] == 789
+
+
 @pytest.mark.parametrize(
     "trace_propagation_targets,url,expected_propagation_decision",
     [
@@ -303,17 +390,16 @@ def test_set_meaurement_public_api(sentry_init, capture_events):
 def test_should_propagate_trace(
     trace_propagation_targets, url, expected_propagation_decision
 ):
-    hub = MagicMock()
-    hub.client = MagicMock()
+    client = MagicMock()
 
     # This test assumes the urls are not Sentry URLs. Use test_should_propagate_trace_to_sentry for sentry URLs.
-    hub.is_sentry_url = lambda _: False
+    client.is_sentry_url = lambda _: False
 
-    hub.client.options = {"trace_propagation_targets": trace_propagation_targets}
-    hub.client.transport = MagicMock()
-    hub.client.transport.parsed_dsn = Dsn("https://bla@xxx.sentry.io/12312012")
+    client.options = {"trace_propagation_targets": trace_propagation_targets}
+    client.transport = MagicMock()
+    client.transport.parsed_dsn = Dsn("https://bla@xxx.sentry.io/12312012")
 
-    assert should_propagate_trace(hub, url) == expected_propagation_decision
+    assert should_propagate_trace(client, url) == expected_propagation_decision
 
 
 @pytest.mark.parametrize(
@@ -354,6 +440,148 @@ def test_should_propagate_trace_to_sentry(
         traces_sample_rate=1.0,
     )
 
-    Hub.current.client.transport.parsed_dsn = Dsn(dsn)
+    client = sentry_sdk.get_client()
+    client.transport.parsed_dsn = Dsn(dsn)
+
+    assert should_propagate_trace(client, url) == expected_propagation_decision
+
+
+def test_start_transaction_updates_scope_name_source(sentry_init):
+    sentry_init(traces_sample_rate=1.0)
+
+    scope = sentry_sdk.get_current_scope()
+
+    with start_transaction(name="foobar", source="route"):
+        assert scope._transaction == "foobar"
+        assert scope._transaction_info == {"source": "route"}
+
+
+@pytest.mark.parametrize("sampled", (True, None))
+def test_transaction_dropped_debug_not_started(sentry_init, sampled):
+    sentry_init(enable_tracing=True)
+
+    tx = Transaction(sampled=sampled)
+
+    with mock.patch("sentry_sdk.tracing.logger") as mock_logger:
+        with tx:
+            pass
+
+    mock_logger.debug.assert_any_call(
+        "Discarding transaction because it was not started with sentry_sdk.start_transaction"
+    )
+
+    with pytest.raises(AssertionError):
+        # We should NOT see the "sampled = False" message here
+        mock_logger.debug.assert_any_call(
+            "Discarding transaction because sampled = False"
+        )
+
+
+def test_transaction_dropeed_sampled_false(sentry_init):
+    sentry_init(enable_tracing=True)
+
+    tx = Transaction(sampled=False)
+
+    with mock.patch("sentry_sdk.tracing.logger") as mock_logger:
+        with sentry_sdk.start_transaction(tx):
+            pass
+
+    mock_logger.debug.assert_any_call("Discarding transaction because sampled = False")
+
+    with pytest.raises(AssertionError):
+        # We should not see the "not started" message here
+        mock_logger.debug.assert_any_call(
+            "Discarding transaction because it was not started with sentry_sdk.start_transaction"
+        )
+
+
+def test_transaction_not_started_warning(sentry_init):
+    sentry_init(enable_tracing=True)
 
-    assert should_propagate_trace(Hub.current, url) == expected_propagation_decision
+    tx = Transaction()
+
+    with mock.patch("sentry_sdk.tracing.logger") as mock_logger:
+        with tx:
+            pass
+
+    mock_logger.debug.assert_any_call(
+        "Transaction was entered without being started with sentry_sdk.start_transaction."
+        "The transaction will not be sent to Sentry. To fix, start the transaction by"
+        "passing it to sentry_sdk.start_transaction."
+    )
+
+
+def test_span_set_data_update_data(sentry_init, capture_events):
+    sentry_init(traces_sample_rate=1.0)
+
+    events = capture_events()
+
+    with sentry_sdk.start_transaction(name="test-transaction"):
+        with start_span(op="test-span") as span:
+            span.set_data("key0", "value0")
+            span.set_data("key1", "value1")
+
+            span.update_data(
+                {
+                    "key1": "updated-value1",
+                    "key2": "value2",
+                    "key3": "value3",
+                }
+            )
+
+    (event,) = events
+    span = event["spans"][0]
+
+    assert span["data"] == {
+        "key0": "value0",
+        "key1": "updated-value1",
+        "key2": "value2",
+        "key3": "value3",
+        "thread.id": mock.ANY,
+        "thread.name": mock.ANY,
+    }
+
+
+def test_update_current_span(sentry_init, capture_events):
+    sentry_init(traces_sample_rate=1.0)
+
+    events = capture_events()
+
+    with sentry_sdk.start_transaction(name="test-transaction"):
+        with start_span(op="test-span-op", name="test-span-name"):
+            sentry_sdk.update_current_span(
+                op="updated-span-op",
+                name="updated-span-name",
+                attributes={
+                    "key0": "value0",
+                    "key1": "value1",
+                },
+            )
+
+            sentry_sdk.update_current_span(
+                op="updated-span-op-2",
+            )
+
+            sentry_sdk.update_current_span(
+                name="updated-span-name-3",
+            )
+
+            sentry_sdk.update_current_span(
+                attributes={
+                    "key1": "updated-value-4",
+                    "key2": "value2",
+                },
+            )
+
+    (event,) = events
+    span = event["spans"][0]
+
+    assert span["op"] == "updated-span-op-2"
+    assert span["description"] == "updated-span-name-3"
+    assert span["data"] == {
+        "key0": "value0",
+        "key1": "updated-value-4",
+        "key2": "value2",
+        "thread.id": mock.ANY,
+        "thread.name": mock.ANY,
+    }
diff --git a/tests/tracing/test_noop_span.py b/tests/tracing/test_noop_span.py
index 9896afb007..36778cd485 100644
--- a/tests/tracing/test_noop_span.py
+++ b/tests/tracing/test_noop_span.py
@@ -1,52 +1,52 @@
 import sentry_sdk
 from sentry_sdk.tracing import NoOpSpan
 
-# This tests make sure, that the examples from the documentation [1]
-# are working when OTel (OpenTelementry) instrumentation is turned on
-# and therefore the Senntry tracing should not do anything.
+# These tests make sure that the examples from the documentation [1]
+# are working when OTel (OpenTelemetry) instrumentation is turned on,
+# and therefore, the Sentry tracing should not do anything.
 #
 # 1: https://docs.sentry.io/platforms/python/performance/instrumentation/custom-instrumentation/
 
 
 def test_noop_start_transaction(sentry_init):
-    sentry_init(instrumenter="otel", debug=True)
+    sentry_init(instrumenter="otel")
 
     with sentry_sdk.start_transaction(
         op="task", name="test_transaction_name"
     ) as transaction:
         assert isinstance(transaction, NoOpSpan)
-        assert sentry_sdk.Hub.current.scope.span is transaction
+        assert sentry_sdk.get_current_scope().span is transaction
 
         transaction.name = "new name"
 
 
 def test_noop_start_span(sentry_init):
-    sentry_init(instrumenter="otel", debug=True)
+    sentry_init(instrumenter="otel")
 
-    with sentry_sdk.start_span(op="http", description="GET /") as span:
+    with sentry_sdk.start_span(op="http", name="GET /") as span:
         assert isinstance(span, NoOpSpan)
-        assert sentry_sdk.Hub.current.scope.span is span
+        assert sentry_sdk.get_current_scope().span is span
 
         span.set_tag("http.response.status_code", 418)
         span.set_data("http.entity_type", "teapot")
 
 
 def test_noop_transaction_start_child(sentry_init):
-    sentry_init(instrumenter="otel", debug=True)
+    sentry_init(instrumenter="otel")
 
     transaction = sentry_sdk.start_transaction(name="task")
     assert isinstance(transaction, NoOpSpan)
 
     with transaction.start_child(op="child_task") as child:
         assert isinstance(child, NoOpSpan)
-        assert sentry_sdk.Hub.current.scope.span is child
+        assert sentry_sdk.get_current_scope().span is child
 
 
 def test_noop_span_start_child(sentry_init):
-    sentry_init(instrumenter="otel", debug=True)
+    sentry_init(instrumenter="otel")
     span = sentry_sdk.start_span(name="task")
     assert isinstance(span, NoOpSpan)
 
     with span.start_child(op="child_task") as child:
         assert isinstance(child, NoOpSpan)
-        assert sentry_sdk.Hub.current.scope.span is child
+        assert sentry_sdk.get_current_scope().span is child
diff --git a/tests/tracing/test_propagation.py b/tests/tracing/test_propagation.py
new file mode 100644
index 0000000000..730bf2672b
--- /dev/null
+++ b/tests/tracing/test_propagation.py
@@ -0,0 +1,40 @@
+import sentry_sdk
+import pytest
+
+
+def test_standalone_span_iter_headers(sentry_init):
+    sentry_init(enable_tracing=True)
+
+    with sentry_sdk.start_span(op="test") as span:
+        with pytest.raises(StopIteration):
+            # We should not have any propagation headers
+            next(span.iter_headers())
+
+
+def test_span_in_span_iter_headers(sentry_init):
+    sentry_init(enable_tracing=True)
+
+    with sentry_sdk.start_span(op="test"):
+        with sentry_sdk.start_span(op="test2") as span_inner:
+            with pytest.raises(StopIteration):
+                # We should not have any propagation headers
+                next(span_inner.iter_headers())
+
+
+def test_span_in_transaction(sentry_init):
+    sentry_init(enable_tracing=True)
+
+    with sentry_sdk.start_transaction(op="test"):
+        with sentry_sdk.start_span(op="test2") as span:
+            # Ensure the headers are there
+            next(span.iter_headers())
+
+
+def test_span_in_span_in_transaction(sentry_init):
+    sentry_init(enable_tracing=True)
+
+    with sentry_sdk.start_transaction(op="test"):
+        with sentry_sdk.start_span(op="test2"):
+            with sentry_sdk.start_span(op="test3") as span_inner:
+                # Ensure the headers are there
+                next(span_inner.iter_headers())
diff --git a/tests/tracing/test_sample_rand.py b/tests/tracing/test_sample_rand.py
new file mode 100644
index 0000000000..4a74950b30
--- /dev/null
+++ b/tests/tracing/test_sample_rand.py
@@ -0,0 +1,56 @@
+from unittest import mock
+
+import pytest
+
+import sentry_sdk
+from sentry_sdk.tracing_utils import Baggage
+
+
+@pytest.mark.parametrize("sample_rand", (0.0, 0.25, 0.5, 0.75))
+@pytest.mark.parametrize("sample_rate", (0.0, 0.25, 0.5, 0.75, 1.0))
+def test_deterministic_sampled(sentry_init, capture_events, sample_rate, sample_rand):
+    """
+    Test that sample_rand is generated on new traces, that it is used to
+    make the sampling decision, and that it is included in the transaction's
+    baggage.
+    """
+    sentry_init(traces_sample_rate=sample_rate)
+    events = capture_events()
+
+    with mock.patch(
+        "sentry_sdk.tracing_utils.Random.randrange",
+        return_value=int(sample_rand * 1000000),
+    ):
+        with sentry_sdk.start_transaction() as transaction:
+            assert (
+                transaction.get_baggage().sentry_items["sample_rand"]
+                == f"{sample_rand:.6f}"  # noqa: E231
+            )
+
+    # Transaction event captured if sample_rand < sample_rate, indicating that
+    # sample_rand is used to make the sampling decision.
+    assert len(events) == int(sample_rand < sample_rate)
+
+
+@pytest.mark.parametrize("sample_rand", (0.0, 0.25, 0.5, 0.75))
+@pytest.mark.parametrize("sample_rate", (0.0, 0.25, 0.5, 0.75, 1.0))
+def test_transaction_uses_incoming_sample_rand(
+    sentry_init, capture_events, sample_rate, sample_rand
+):
+    """
+    Test that the transaction uses the sample_rand value from the incoming baggage.
+    """
+    baggage = Baggage(sentry_items={"sample_rand": f"{sample_rand:.6f}"})  # noqa: E231
+
+    sentry_init(traces_sample_rate=sample_rate)
+    events = capture_events()
+
+    with sentry_sdk.start_transaction(baggage=baggage) as transaction:
+        assert (
+            transaction.get_baggage().sentry_items["sample_rand"]
+            == f"{sample_rand:.6f}"  # noqa: E231
+        )
+
+    # Transaction event captured if sample_rand < sample_rate, indicating that
+    # sample_rand is used to make the sampling decision.
+    assert len(events) == int(sample_rand < sample_rate)
diff --git a/tests/tracing/test_sample_rand_propagation.py b/tests/tracing/test_sample_rand_propagation.py
new file mode 100644
index 0000000000..e6f3e99510
--- /dev/null
+++ b/tests/tracing/test_sample_rand_propagation.py
@@ -0,0 +1,43 @@
+"""
+These tests exist to verify that Scope.continue_trace() correctly propagates the
+sample_rand value onto the transaction's baggage.
+
+We check both the case where there is an incoming sample_rand, as well as the case
+where we need to compute it because it is missing.
+"""
+
+from unittest import mock
+from unittest.mock import Mock
+
+import sentry_sdk
+
+
+def test_continue_trace_with_sample_rand():
+    """
+    Test that an incoming sample_rand is propagated onto the transaction's baggage.
+    """
+    headers = {
+        "sentry-trace": "00000000000000000000000000000000-0000000000000000-0",
+        "baggage": "sentry-sample_rand=0.1,sentry-sample_rate=0.5",
+    }
+
+    transaction = sentry_sdk.continue_trace(headers)
+    assert transaction.get_baggage().sentry_items["sample_rand"] == "0.1"
+
+
+def test_continue_trace_missing_sample_rand():
+    """
+    Test that a missing sample_rand is filled in onto the transaction's baggage.
+    """
+
+    headers = {
+        "sentry-trace": "00000000000000000000000000000000-0000000000000000",
+        "baggage": "sentry-placeholder=asdf",
+    }
+
+    with mock.patch(
+        "sentry_sdk.tracing_utils.Random.randrange", Mock(return_value=500000)
+    ):
+        transaction = sentry_sdk.continue_trace(headers)
+
+    assert transaction.get_baggage().sentry_items["sample_rand"] == "0.500000"
diff --git a/tests/tracing/test_sampling.py b/tests/tracing/test_sampling.py
index 6101a948ef..c0f307ecf7 100644
--- a/tests/tracing/test_sampling.py
+++ b/tests/tracing/test_sampling.py
@@ -1,16 +1,14 @@
 import random
+from collections import Counter
+from unittest import mock
 
 import pytest
 
-from sentry_sdk import Hub, start_span, start_transaction, capture_exception
-from sentry_sdk.tracing import Transaction
+import sentry_sdk
+from sentry_sdk import start_span, start_transaction, capture_exception, continue_trace
+from sentry_sdk.tracing_utils import Baggage
 from sentry_sdk.utils import logger
 
-try:
-    from unittest import mock  # python 3.3 and above
-except ImportError:
-    import mock  # python < 3.3
-
 
 def test_sampling_decided_only_for_transactions(sentry_init, capture_events):
     sentry_init(traces_sample_rate=0.5)
@@ -59,7 +57,7 @@ def test_get_transaction_and_span_from_scope_regardless_of_sampling_decision(
     with start_transaction(name="/", sampled=sampling_decision):
         with start_span(op="child-span"):
             with start_span(op="child-child-span"):
-                scope = Hub.current.scope
+                scope = sentry_sdk.get_current_scope()
                 assert scope.span.op == "child-child-span"
                 assert scope.transaction.name == "/"
 
@@ -75,9 +73,9 @@ def test_uses_traces_sample_rate_correctly(
 ):
     sentry_init(traces_sample_rate=traces_sample_rate)
 
-    with mock.patch.object(random, "random", return_value=0.5):
-        transaction = start_transaction(name="dogpark")
-        assert transaction.sampled is expected_decision
+    baggage = Baggage(sentry_items={"sample_rand": "0.500000"})
+    transaction = start_transaction(name="dogpark", baggage=baggage)
+    assert transaction.sampled is expected_decision
 
 
 @pytest.mark.parametrize(
@@ -91,9 +89,9 @@ def test_uses_traces_sampler_return_value_correctly(
 ):
     sentry_init(traces_sampler=mock.Mock(return_value=traces_sampler_return_value))
 
-    with mock.patch.object(random, "random", return_value=0.5):
-        transaction = start_transaction(name="dogpark")
-        assert transaction.sampled is expected_decision
+    baggage = Baggage(sentry_items={"sample_rand": "0.500000"})
+    transaction = start_transaction(name="dogpark", baggage=baggage)
+    assert transaction.sampled is expected_decision
 
 
 @pytest.mark.parametrize("traces_sampler_return_value", [True, False])
@@ -197,24 +195,25 @@ def test_passes_parent_sampling_decision_in_sampling_context(
         )
     )
 
-    transaction = Transaction.continue_from_headers(
-        headers={"sentry-trace": sentry_trace_header}, name="dogpark"
+    transaction = sentry_sdk.continue_trace(
+        {"sentry-trace": sentry_trace_header},
+        name="dogpark",
     )
-    spy = mock.Mock(wraps=transaction)
-    start_transaction(transaction=spy)
 
-    # there's only one call (so index at 0) and kwargs are always last in a call
-    # tuple (so index at -1)
-    sampling_context = spy._set_initial_sampling_decision.mock_calls[0][-1][
-        "sampling_context"
-    ]
-    assert "parent_sampled" in sampling_context
-    # because we passed in a spy, attribute access requires unwrapping
-    assert sampling_context["parent_sampled"]._mock_wraps is parent_sampling_decision
+    def mock_set_initial_sampling_decision(_, sampling_context):
+        assert "parent_sampled" in sampling_context
+        assert sampling_context["parent_sampled"] is parent_sampling_decision
 
+    with mock.patch(
+        "sentry_sdk.tracing.Transaction._set_initial_sampling_decision",
+        mock_set_initial_sampling_decision,
+    ):
+        start_transaction(transaction=transaction)
 
-def test_passes_custom_samling_context_from_start_transaction_to_traces_sampler(
-    sentry_init, DictionaryContaining  # noqa: N803
+
+def test_passes_custom_sampling_context_from_start_transaction_to_traces_sampler(
+    sentry_init,
+    DictionaryContaining,  # noqa: N803
 ):
     traces_sampler = mock.Mock()
     sentry_init(traces_sampler=traces_sampler)
@@ -253,7 +252,9 @@ def test_sample_rate_affects_errors(sentry_init, capture_events):
     ],
 )
 def test_warns_and_sets_sampled_to_false_on_invalid_traces_sampler_return_value(
-    sentry_init, traces_sampler_return_value, StringContaining  # noqa: N803
+    sentry_init,
+    traces_sampler_return_value,
+    StringContaining,  # noqa: N803
 ):
     sentry_init(traces_sampler=mock.Mock(return_value=traces_sampler_return_value))
 
@@ -264,58 +265,60 @@ def test_warns_and_sets_sampled_to_false_on_invalid_traces_sampler_return_value(
 
 
 @pytest.mark.parametrize(
-    "traces_sample_rate,sampled_output,reports_output",
+    "traces_sample_rate,sampled_output,expected_record_lost_event_calls",
     [
         (None, False, []),
-        (0.0, False, [("sample_rate", "transaction")]),
+        (
+            0.0,
+            False,
+            [("sample_rate", "transaction", None, 1), ("sample_rate", "span", None, 1)],
+        ),
         (1.0, True, []),
     ],
 )
 def test_records_lost_event_only_if_traces_sample_rate_enabled(
-    sentry_init, traces_sample_rate, sampled_output, reports_output, monkeypatch
+    sentry_init,
+    capture_record_lost_event_calls,
+    traces_sample_rate,
+    sampled_output,
+    expected_record_lost_event_calls,
 ):
-    reports = []
-
-    def record_lost_event(reason, data_category=None, item=None):
-        reports.append((reason, data_category))
-
     sentry_init(traces_sample_rate=traces_sample_rate)
-
-    monkeypatch.setattr(
-        Hub.current.client.transport, "record_lost_event", record_lost_event
-    )
+    record_lost_event_calls = capture_record_lost_event_calls()
 
     transaction = start_transaction(name="dogpark")
     assert transaction.sampled is sampled_output
     transaction.finish()
 
-    assert reports == reports_output
+    # Use Counter because order of calls does not matter
+    assert Counter(record_lost_event_calls) == Counter(expected_record_lost_event_calls)
 
 
 @pytest.mark.parametrize(
-    "traces_sampler,sampled_output,reports_output",
+    "traces_sampler,sampled_output,expected_record_lost_event_calls",
     [
         (None, False, []),
-        (lambda _x: 0.0, False, [("sample_rate", "transaction")]),
+        (
+            lambda _x: 0.0,
+            False,
+            [("sample_rate", "transaction", None, 1), ("sample_rate", "span", None, 1)],
+        ),
         (lambda _x: 1.0, True, []),
     ],
 )
 def test_records_lost_event_only_if_traces_sampler_enabled(
-    sentry_init, traces_sampler, sampled_output, reports_output, monkeypatch
+    sentry_init,
+    capture_record_lost_event_calls,
+    traces_sampler,
+    sampled_output,
+    expected_record_lost_event_calls,
 ):
-    reports = []
-
-    def record_lost_event(reason, data_category=None, item=None):
-        reports.append((reason, data_category))
-
     sentry_init(traces_sampler=traces_sampler)
-
-    monkeypatch.setattr(
-        Hub.current.client.transport, "record_lost_event", record_lost_event
-    )
+    record_lost_event_calls = capture_record_lost_event_calls()
 
     transaction = start_transaction(name="dogpark")
     assert transaction.sampled is sampled_output
     transaction.finish()
 
-    assert reports == reports_output
+    # Use Counter because order of calls does not matter
+    assert Counter(record_lost_event_calls) == Counter(expected_record_lost_event_calls)
diff --git a/tests/tracing/test_span_name.py b/tests/tracing/test_span_name.py
new file mode 100644
index 0000000000..9c1768990a
--- /dev/null
+++ b/tests/tracing/test_span_name.py
@@ -0,0 +1,59 @@
+import pytest
+
+import sentry_sdk
+
+
+def test_start_span_description(sentry_init, capture_events):
+    sentry_init(traces_sample_rate=1.0)
+    events = capture_events()
+
+    with sentry_sdk.start_transaction(name="hi"):
+        with pytest.deprecated_call():
+            with sentry_sdk.start_span(op="foo", description="span-desc"):
+                ...
+
+    (event,) = events
+
+    assert event["spans"][0]["description"] == "span-desc"
+
+
+def test_start_span_name(sentry_init, capture_events):
+    sentry_init(traces_sample_rate=1.0)
+    events = capture_events()
+
+    with sentry_sdk.start_transaction(name="hi"):
+        with sentry_sdk.start_span(op="foo", name="span-name"):
+            ...
+
+    (event,) = events
+
+    assert event["spans"][0]["description"] == "span-name"
+
+
+def test_start_child_description(sentry_init, capture_events):
+    sentry_init(traces_sample_rate=1.0)
+    events = capture_events()
+
+    with sentry_sdk.start_transaction(name="hi"):
+        with pytest.deprecated_call():
+            with sentry_sdk.start_span(op="foo", description="span-desc") as span:
+                with span.start_child(op="bar", description="child-desc"):
+                    ...
+
+    (event,) = events
+
+    assert event["spans"][-1]["description"] == "child-desc"
+
+
+def test_start_child_name(sentry_init, capture_events):
+    sentry_init(traces_sample_rate=1.0)
+    events = capture_events()
+
+    with sentry_sdk.start_transaction(name="hi"):
+        with sentry_sdk.start_span(op="foo", name="span-name") as span:
+            with span.start_child(op="bar", name="child-name"):
+                ...
+
+    (event,) = events
+
+    assert event["spans"][-1]["description"] == "child-name"
diff --git a/tests/tracing/test_span_origin.py b/tests/tracing/test_span_origin.py
new file mode 100644
index 0000000000..16635871b3
--- /dev/null
+++ b/tests/tracing/test_span_origin.py
@@ -0,0 +1,38 @@
+from sentry_sdk import start_transaction, start_span
+
+
+def test_span_origin_manual(sentry_init, capture_events):
+    sentry_init(traces_sample_rate=1.0)
+    events = capture_events()
+
+    with start_transaction(name="hi"):
+        with start_span(op="foo", name="bar"):
+            pass
+
+    (event,) = events
+
+    assert len(events) == 1
+    assert event["spans"][0]["origin"] == "manual"
+    assert event["contexts"]["trace"]["origin"] == "manual"
+
+
+def test_span_origin_custom(sentry_init, capture_events):
+    sentry_init(traces_sample_rate=1.0)
+    events = capture_events()
+
+    with start_transaction(name="hi"):
+        with start_span(op="foo", name="bar", origin="foo.foo2.foo3"):
+            pass
+
+    with start_transaction(name="ho", origin="ho.ho2.ho3"):
+        with start_span(op="baz", name="qux", origin="baz.baz2.baz3"):
+            pass
+
+    (first_transaction, second_transaction) = events
+
+    assert len(events) == 2
+    assert first_transaction["contexts"]["trace"]["origin"] == "manual"
+    assert first_transaction["spans"][0]["origin"] == "foo.foo2.foo3"
+
+    assert second_transaction["contexts"]["trace"]["origin"] == "ho.ho2.ho3"
+    assert second_transaction["spans"][0]["origin"] == "baz.baz2.baz3"
diff --git a/tests/utils/test_contextvars.py b/tests/utils/test_contextvars.py
index a6d296bb1f..cefa4c13fd 100644
--- a/tests/utils/test_contextvars.py
+++ b/tests/utils/test_contextvars.py
@@ -1,10 +1,10 @@
 import pytest
 import random
 import time
+from unittest import mock
 
 
-@pytest.mark.forked
-def test_leaks(maybe_monkeypatched_threading):
+def _run_contextvar_threaded_test():
     import threading
 
     # Need to explicitly call _get_contextvars because the SDK has already
@@ -39,3 +39,14 @@ def run():
         t.join()
 
     assert len(success) == 20
+
+
+@pytest.mark.forked
+def test_leaks(maybe_monkeypatched_threading):
+    _run_contextvar_threaded_test()
+
+
+@pytest.mark.forked
+@mock.patch("sentry_sdk.utils._is_contextvars_broken", return_value=True)
+def test_leaks_when_is_contextvars_broken_is_false(maybe_monkeypatched_threading):
+    _run_contextvar_threaded_test()
diff --git a/tests/utils/test_general.py b/tests/utils/test_general.py
index 6f53de32c3..6a06abe68a 100644
--- a/tests/utils/test_general.py
+++ b/tests/utils/test_general.py
@@ -1,8 +1,8 @@
-# coding: utf-8
 import sys
 import os
 
 import pytest
+from sentry_sdk.ai.utils import _normalize_data
 
 
 from sentry_sdk.utils import (
@@ -18,7 +18,7 @@
     strip_string,
     AnnotatedValue,
 )
-from sentry_sdk._compat import text_type, string_types
+from sentry_sdk.consts import EndpointType
 
 
 try:
@@ -32,24 +32,16 @@
     @given(x=any_string)
     def test_safe_repr_never_broken_for_strings(x):
         r = safe_repr(x)
-        assert isinstance(r, text_type)
+        assert isinstance(r, str)
         assert "broken repr" not in r
 
 
 def test_safe_repr_regressions():
-    # fmt: off
-    assert u"лошадь" in safe_repr(u"лошадь")
-    # fmt: on
+    assert "лошадь" in safe_repr("лошадь")
 
 
-@pytest.mark.xfail(
-    sys.version_info < (3,),
-    reason="Fixing this in Python 2 would break other behaviors",
-)
-# fmt: off
-@pytest.mark.parametrize("prefix", ("", "abcd", u"лошадь"))
-@pytest.mark.parametrize("character", u"\x00\x07\x1b\n")
-# fmt: on
+@pytest.mark.parametrize("prefix", ("", "abcd", "лошадь"))
+@pytest.mark.parametrize("character", "\x00\x07\x1b\n")
 def test_safe_repr_non_printable(prefix, character):
     """Check that non-printable characters are escaped"""
     string = prefix + character
@@ -83,39 +75,60 @@ def test_filename():
 
     assert x("bogus", "bogus") == "bogus"
 
+    assert x("bogus", "bogus.pyc") == "bogus.py"
+
     assert x("os", os.__file__) == "os.py"
 
+    assert x("foo.bar", "path/to/foo/bar.py") == "path/to/foo/bar.py"
+
     import sentry_sdk.utils
 
     assert x("sentry_sdk.utils", sentry_sdk.utils.__file__) == "sentry_sdk/utils.py"
 
 
+def test_filename_module_file_is_none():
+    class DummyModule:
+        __file__ = None
+
+    os.sys.modules["foo"] = DummyModule()
+
+    assert filename_for_module("foo.bar", "path/to/foo/bar.py") == "path/to/foo/bar.py"
+
+
 @pytest.mark.parametrize(
-    "given,expected_store,expected_envelope",
+    "given,expected_envelope",
     [
         (
             "https://foobar@sentry.io/123",
-            "https://sentry.io/api/123/store/",
             "https://sentry.io/api/123/envelope/",
         ),
         (
             "https://foobar@sentry.io/bam/123",
-            "https://sentry.io/bam/api/123/store/",
             "https://sentry.io/bam/api/123/envelope/",
         ),
         (
             "https://foobar@sentry.io/bam/baz/123",
-            "https://sentry.io/bam/baz/api/123/store/",
             "https://sentry.io/bam/baz/api/123/envelope/",
         ),
     ],
 )
-def test_parse_dsn_paths(given, expected_store, expected_envelope):
+def test_parse_dsn_paths(given, expected_envelope):
     dsn = Dsn(given)
     auth = dsn.to_auth()
-    assert auth.store_api_url == expected_store
-    assert auth.get_api_url("store") == expected_store
-    assert auth.get_api_url("envelope") == expected_envelope
+    assert auth.get_api_url() == expected_envelope
+    assert auth.get_api_url(EndpointType.ENVELOPE) == expected_envelope
+
+
+@pytest.mark.parametrize(
+    "given,expected",
+    [
+        ("https://foobar@sentry.io/123", None),
+        ("https://foobar@o1234.ingest.sentry.io/123", "1234"),
+    ],
+)
+def test_parse_dsn_org_id(given, expected):
+    dsn = Dsn(given)
+    assert dsn.org_id == expected
 
 
 @pytest.mark.parametrize(
@@ -133,6 +146,21 @@ def test_parse_invalid_dsn(dsn):
         dsn = Dsn(dsn)
 
 
+@pytest.mark.parametrize(
+    "dsn,error_message",
+    [
+        ("foo://barbaz@sentry.io", "Unsupported scheme 'foo'"),
+        ("https://foobar@", "Missing hostname"),
+        ("https://@sentry.io", "Missing public key"),
+    ],
+)
+def test_dsn_validations(dsn, error_message):
+    with pytest.raises(BadDsn) as e:
+        dsn = Dsn(dsn)
+
+    assert str(e.value) == error_message
+
+
 @pytest.mark.parametrize(
     "frame,in_app_include,in_app_exclude,project_root,resulting_frame",
     [
@@ -517,27 +545,25 @@ def test_iter_stacktraces():
     ) == {1, 2, 3}
 
 
-# fmt: off
 @pytest.mark.parametrize(
     ("original", "base64_encoded"),
     [
         # ascii only
         ("Dogs are great!", "RG9ncyBhcmUgZ3JlYXQh"),
         # emoji
-        (u"🐶", "8J+Qtg=="),
+        ("🐶", "8J+Qtg=="),
         # non-ascii
         (
-            u"Καλό κορίτσι, Μάιζεϊ!",
+            "Καλό κορίτσι, Μάιζεϊ!",
             "zprOsc67z4wgzrrOv8+Bzq/PhM+DzrksIM6czqzOuc62zrXPiiE=",
         ),
         # mix of ascii and non-ascii
         (
-            u"Of margir hundar! Ég geri ráð fyrir að ég þurfi stærra rúm.",
+            "Of margir hundar! Ég geri ráð fyrir að ég þurfi stærra rúm.",
             "T2YgbWFyZ2lyIGh1bmRhciEgw4lnIGdlcmkgcsOhw7AgZnlyaXIgYcOwIMOpZyDDvnVyZmkgc3TDpnJyYSByw7ptLg==",
         ),
     ],
 )
-# fmt: on
 def test_successful_base64_conversion(original, base64_encoded):
     # all unicode characters should be handled correctly
     assert to_base64(original) == base64_encoded
@@ -568,26 +594,69 @@ def test_failed_base64_conversion(input):
 
     # any string can be converted to base64, so only type errors will cause
     # failures
-    if type(input) not in string_types:
+    if not isinstance(input, str):
         assert to_base64(input) is None
 
 
-def test_strip_string():
-    # If value is None returns None.
-    assert strip_string(None) is None
+@pytest.mark.parametrize(
+    "input,max_length,result",
+    [
+        [None, None, None],
+        ["a" * 256, None, "a" * 256],
+        [
+            "a" * 257,
+            256,
+            AnnotatedValue(
+                value="a" * 253 + "...",
+                metadata={"len": 257, "rem": [["!limit", "x", 253, 256]]},
+            ),
+        ],
+        ["éééé", None, "éééé"],
+        [
+            "éééé",
+            5,
+            AnnotatedValue(
+                value="é...", metadata={"len": 8, "rem": [["!limit", "x", 2, 5]]}
+            ),
+        ],
+        [
+            "\udfff\udfff\udfff\udfff\udfff\udfff",
+            5,
+            AnnotatedValue(
+                value="\udfff\udfff...",
+                metadata={
+                    "len": 6,
+                    "rem": [["!limit", "x", 5 - 3, 5]],
+                },
+            ),
+        ],
+    ],
+)
+def test_strip_string(input, max_length, result):
+    assert strip_string(input, max_length) == result
+
+
+def test_normalize_data_with_pydantic_class():
+    """Test that _normalize_data handles Pydantic model classes"""
+
+    class TestClass:
+        name: str = None
+
+        def __init__(self, name: str):
+            self.name = name
+
+        def model_dump(self):
+            return {"name": self.name}
 
-    # If max_length is not passed, returns the full text (up to 1024 bytes).
-    text_1024_long = "a" * 1024
-    assert strip_string(text_1024_long).count("a") == 1024
+    # Test with class (should NOT call model_dump())
+    result = _normalize_data(TestClass)
+    assert result == ""
 
-    # If value exceeds the max_length, returns an AnnotatedValue.
-    text_1025_long = "a" * 1025
-    stripped_text = strip_string(text_1025_long)
-    assert isinstance(stripped_text, AnnotatedValue)
-    assert stripped_text.value.count("a") == 1021  # + '...' is 1024
+    # Test with instance (should call model_dump())
+    instance = TestClass(name="test")
+    result = _normalize_data(instance)
+    assert result == {"name": "test"}
 
-    # If text has unicode characters, it counts bytes and not number of characters.
-    # fmt: off
-    text_with_unicode_character = u"éê"
-    assert strip_string(text_with_unicode_character, max_length=2).value == u"é..."
-    # fmt: on
+    # Test with dict containing class
+    result = _normalize_data({"schema": TestClass, "count": 5})
+    assert result == {"schema": "", "count": 5}
diff --git a/tests/utils/test_transaction.py b/tests/utils/test_transaction.py
index bfb87f4c29..96145e092a 100644
--- a/tests/utils/test_transaction.py
+++ b/tests/utils/test_transaction.py
@@ -1,15 +1,7 @@
-import sys
-from functools import partial
-
-import pytest
+from functools import partial, partialmethod
 
 from sentry_sdk.utils import transaction_from_function
 
-try:
-    from functools import partialmethod
-except ImportError:
-    pass
-
 
 class MyClass:
     def myfunc(self):
@@ -48,7 +40,6 @@ def test_transaction_from_function():
     )
 
 
-@pytest.mark.skipif(sys.version_info < (3, 4), reason="Require python 3.4 or higher")
 def test_transaction_from_function_partialmethod():
     x = transaction_from_function
 
diff --git a/tox.ini b/tox.ini
index 2f082b8d58..26166555c1 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,580 +1,894 @@
-# Tox (http://codespeak.net/~hpk/tox/) is a tool for running tests
-# in multiple virtualenvs. This configuration file will run the
-# test suite on all supported python versions. To use it, "pip install tox"
-# and then run "tox" from this directory.
+# DON'T EDIT THIS FILE BY HAND. This file has been generated from a template by
+# `scripts/populate_tox/populate_tox.py`.
+#
+# Any changes to the test matrix should be made
+# - either in the script config in `scripts/populate_tox/config.py` (if you want
+#   to change the auto-generated part)
+# - or in the template in `scripts/populate_tox/tox.jinja` (if you want to change
+#   a hardcoded part of the file)
+#
+# This file (and all resulting CI YAMLs) then needs to be regenerated via
+# `scripts/generate-test-files.sh`.
+#
+# See also `scripts/populate_tox/README.md` for more info.
 
 [tox]
+requires =
+    # This version introduced using pip 24.1 which does not work with older Celery and HTTPX versions.
+    virtualenv<20.26.3
 envlist =
     # === Common ===
-    {py2.7,py3.5,py3.6,py3.7,py3.8,py3.9,py3.10,py3.11}-common
+    {py3.6,py3.7,py3.8,py3.9,py3.10,py3.11,py3.12,py3.13,py3.14,py3.14t}-common
 
-    # === Integrations ===
-    # General format is {pythonversion}-{integrationname}-v{frameworkversion}
-    # 1 blank line between different integrations
-    # Each framework version should only be mentioned once. I.e:
-    #   {py3.7,py3.10}-django-v{3.2}
-    #   {py3.10}-django-v{4.0}
-    # instead of:
-    #   {py3.7}-django-v{3.2}
-    #   {py3.7,py3.10}-django-v{3.2,4.0}
+    # === Gevent ===
+    {py3.6,py3.8,py3.10,py3.11,py3.12}-gevent
 
-    # AIOHTTP
-    {py3.7}-aiohttp-v{3.5}
-    {py3.7,py3.8,py3.9,py3.10,py3.11}-aiohttp-v{3.6}
+    # === Integration Deactivation ===
+    {py3.9,py3.10,py3.11,py3.12,py3.13,py3.14}-integration_deactivation
 
-    # Ariadne
-    {py3.8,py3.9,py3.10,py3.11}-ariadne
+    # === Shadowed Module ===
+    {py3.7,py3.8,py3.9,py3.10,py3.11,py3.12,py3.13,py3.14,py3.14t}-shadowed_module
 
-    # Arq
-    {py3.7,py3.8,py3.9,py3.10,py3.11}-arq
+    # === Integrations ===
 
     # Asgi
-    {py3.7,py3.8,py3.9,py3.10,py3.11}-asgi
-
-    # asyncpg
-    {py3.7,py3.8,py3.9,py3.10,py3.11}-asyncpg
+    {py3.7,py3.12,py3.13,py3.14,py3.14t}-asgi
 
     # AWS Lambda
-    # The aws_lambda tests deploy to the real AWS and have their own matrix of Python versions.
-    {py3.7}-aws_lambda
+    {py3.8,py3.9,py3.11,py3.13}-aws_lambda
 
-    # Beam
-    {py3.7}-beam-v{2.12,2.13,2.32,2.33}
+    # Cloud Resource Context
+    {py3.6,py3.12,py3.13}-cloud_resource_context
 
-    # Boto3
-    {py2.7,py3.6,py3.7,py3.8}-boto3-v{1.9,1.10,1.11,1.12,1.13,1.14,1.15,1.16}
+    # GCP
+    {py3.7}-gcp
 
-    # Bottle
-    {py2.7,py3.5,py3.6,py3.7,py3.8,py3.9,py3.10,py3.11}-bottle-v{0.12}
+    # OpenTelemetry (OTel)
+    {py3.7,py3.9,py3.12,py3.13,py3.14,py3.14t}-opentelemetry
 
-    # Celery
-    {py2.7}-celery-v{3}
-    {py2.7,py3.5,py3.6}-celery-v{4.1,4.2}
-    {py2.7,py3.5,py3.6,py3.7,py3.8}-celery-v{4.3,4.4}
-    {py3.6,py3.7,py3.8}-celery-v{5.0}
-    {py3.7,py3.8,py3.9,py3.10}-celery-v{5.1,5.2}
-    {py3.8,py3.9,py3.10,py3.11}-celery-v{5.3}
+    # OpenTelemetry with OTLP
+    {py3.7,py3.9,py3.12,py3.13,py3.14}-otlp
 
-    # Chalice
-    {py3.6,py3.7,py3.8}-chalice-v{1.18,1.20,1.22,1.24}
+    # OpenTelemetry Experimental (POTel)
+    {py3.8,py3.9,py3.10,py3.11,py3.12,py3.13}-potel
 
-    # Clickhouse Driver
-    {py3.8,py3.9,py3.10,py3.11}-clickhouse_driver-v{0.2.4,0.2.5,0.2.6}
+    # === Integrations - Auto-generated ===
+    # These come from the populate_tox.py script.
 
-    # Cloud Resource Context
-    {py3.6,py3.7,py3.8,py3.9,py3.10,py3.11}-cloud_resource_context
-
-    # Django
-    # - Django 1.x
-    {py2.7,py3.5}-django-v{1.8,1.9,1.10}
-    {py2.7,py3.5,py3.6,py3.7}-django-v{1.11}
-    # - Django 2.x
-    {py3.5,py3.6,py3.7}-django-v{2.0,2.1}
-    {py3.5,py3.6,py3.7,py3.8,py3.9}-django-v{2.2}
-    # - Django 3.x
-    {py3.6,py3.7,py3.8,py3.9}-django-v{3.0,3.1}
-    {py3.6,py3.7,py3.8,py3.9,py3.10,py3.11}-django-v{3.2}
-    # - Django 4.x
-    {py3.8,py3.9,py3.10,py3.11}-django-v{4.0,4.1}
-
-    # Falcon
-    {py2.7,py3.5,py3.6,py3.7}-falcon-v{1.4}
-    {py2.7,py3.5,py3.6,py3.7}-falcon-v{2.0}
-    {py3.5,py3.6,py3.7,py3.8,py3.9,py3.10,py3.11}-falcon-v{3.0}
-
-    # FastAPI
-    {py3.7,py3.8,py3.9,py3.10,py3.11}-fastapi
-
-    # Flask
-    {py2.7,py3.5,py3.6,py3.7,py3.8,py3.9}-flask-v{0.11,0.12,1.0}
-    {py2.7,py3.5,py3.6,py3.7,py3.8,py3.9,py3.10,py3.11}-flask-v{1.1}
-    {py3.6,py3.8,py3.9,py3.10,py3.11}-flask-v{2.0}
-
-    # Gevent
-    {py2.7,py3.6,py3.7,py3.8,py3.9,py3.10,py3.11}-gevent
+    # ~~~ MCP ~~~
+    {py3.10,py3.12,py3.13}-mcp-v1.15.0
+    {py3.10,py3.12,py3.13}-mcp-v1.18.0
+    {py3.10,py3.12,py3.13}-mcp-v1.21.2
+    {py3.10,py3.12,py3.13}-mcp-v1.24.0
 
-    # GCP
-    {py3.7}-gcp
+    {py3.10,py3.13,py3.14,py3.14t}-fastmcp-v0.1.0
+    {py3.10,py3.13,py3.14,py3.14t}-fastmcp-v0.4.1
+    {py3.10,py3.13,py3.14,py3.14t}-fastmcp-v1.0
+    {py3.10,py3.12,py3.13}-fastmcp-v2.14.1
 
-    # GQL
-    {py3.7,py3.8,py3.9,py3.10,py3.11}-gql
 
-    # Graphene
-    {py3.7,py3.8,py3.9,py3.10,py3.11}-graphene
+    # ~~~ Agents ~~~
+    {py3.10,py3.11,py3.12}-openai_agents-v0.0.19
+    {py3.10,py3.12,py3.13}-openai_agents-v0.2.11
+    {py3.10,py3.12,py3.13}-openai_agents-v0.4.2
+    {py3.10,py3.13,py3.14,py3.14t}-openai_agents-v0.6.4
 
-    # Grpc
-    {py3.7,py3.8,py3.9,py3.10}-grpc-v{1.40,1.44,1.48}
-    {py3.7,py3.8,py3.9,py3.10,py3.11}-grpc-v{1.54,1.56,1.58}
+    {py3.10,py3.12,py3.13}-pydantic_ai-v1.0.18
+    {py3.10,py3.12,py3.13}-pydantic_ai-v1.12.0
+    {py3.10,py3.12,py3.13}-pydantic_ai-v1.24.0
+    {py3.10,py3.12,py3.13}-pydantic_ai-v1.36.0
 
-    # HTTPX
-    {py3.6,py3.7,py3.8,py3.9}-httpx-v{0.16,0.17,0.18}
-    {py3.6,py3.7,py3.8,py3.9,py3.10}-httpx-v{0.19,0.20,0.21,0.22}
-    {py3.7,py3.8,py3.9,py3.10,py3.11}-httpx-v{0.23}
 
-    # Huey
-    {py2.7,py3.5,py3.6,py3.7,py3.8,py3.9,py3.10,py3.11}-huey-2
+    # ~~~ AI Workflow ~~~
+    {py3.9,py3.11,py3.12}-langchain-base-v0.1.20
+    {py3.9,py3.12,py3.13}-langchain-base-v0.3.27
+    {py3.10,py3.13,py3.14}-langchain-base-v1.2.0
 
-    # Loguru
-    {py3.5,py3.6,py3.7,py3.8,py3.9,py3.10,py3.11}-loguru-v{0.5,0.6,0.7}
+    {py3.9,py3.11,py3.12}-langchain-notiktoken-v0.1.20
+    {py3.9,py3.12,py3.13}-langchain-notiktoken-v0.3.27
+    {py3.10,py3.13,py3.14}-langchain-notiktoken-v1.2.0
 
-    # OpenTelemetry (OTel)
-    {py3.7,py3.8,py3.9,py3.10,py3.11}-opentelemetry
+    {py3.9,py3.13,py3.14}-langgraph-v0.6.11
+    {py3.10,py3.12,py3.13}-langgraph-v1.0.5
+
+
+    # ~~~ AI ~~~
+    {py3.8,py3.11,py3.12}-anthropic-v0.16.0
+    {py3.8,py3.11,py3.12}-anthropic-v0.36.2
+    {py3.8,py3.11,py3.12}-anthropic-v0.56.0
+    {py3.9,py3.12,py3.13}-anthropic-v0.75.0
+
+    {py3.9,py3.10,py3.11}-cohere-v5.4.0
+    {py3.9,py3.11,py3.12}-cohere-v5.10.0
+    {py3.9,py3.11,py3.12}-cohere-v5.15.0
+    {py3.9,py3.11,py3.12}-cohere-v5.20.1
+
+    {py3.9,py3.12,py3.13}-google_genai-v1.29.0
+    {py3.9,py3.12,py3.13}-google_genai-v1.38.0
+    {py3.9,py3.13,py3.14,py3.14t}-google_genai-v1.47.0
+    {py3.10,py3.13,py3.14,py3.14t}-google_genai-v1.56.0
+
+    {py3.8,py3.10,py3.11}-huggingface_hub-v0.24.7
+    {py3.8,py3.12,py3.13}-huggingface_hub-v0.36.0
+    {py3.9,py3.13,py3.14,py3.14t}-huggingface_hub-v1.2.3
+
+    {py3.9,py3.12,py3.13}-litellm-v1.77.7
+    {py3.9,py3.12,py3.13}-litellm-v1.78.7
+    {py3.9,py3.12,py3.13}-litellm-v1.79.3
+    {py3.9,py3.12,py3.13}-litellm-v1.80.10
+
+    {py3.8,py3.11,py3.12}-openai-base-v1.0.1
+    {py3.8,py3.12,py3.13}-openai-base-v1.109.1
+    {py3.9,py3.13,py3.14,py3.14t}-openai-base-v2.14.0
+
+    {py3.8,py3.11,py3.12}-openai-notiktoken-v1.0.1
+    {py3.8,py3.12,py3.13}-openai-notiktoken-v1.109.1
+    {py3.9,py3.13,py3.14,py3.14t}-openai-notiktoken-v2.14.0
+
+
+    # ~~~ Cloud ~~~
+    {py3.6,py3.7}-boto3-v1.12.49
+    {py3.6,py3.9,py3.10}-boto3-v1.21.46
+    {py3.7,py3.11,py3.12}-boto3-v1.33.13
+    {py3.9,py3.13,py3.14,py3.14t}-boto3-v1.42.13
+
+    {py3.6,py3.7,py3.8}-chalice-v1.16.0
+    {py3.9,py3.12,py3.13}-chalice-v1.32.0
+
+
+    # ~~~ DBs ~~~
+    {py3.7,py3.8,py3.9}-asyncpg-v0.23.0
+    {py3.7,py3.9,py3.10}-asyncpg-v0.26.0
+    {py3.8,py3.11,py3.12}-asyncpg-v0.29.0
+    {py3.9,py3.13,py3.14,py3.14t}-asyncpg-v0.31.0
+
+    {py3.9,py3.13,py3.14}-clickhouse_driver-v0.2.10
+
+    {py3.6}-pymongo-v3.5.1
+    {py3.6,py3.10,py3.11}-pymongo-v3.13.0
+    {py3.9,py3.13,py3.14,py3.14t}-pymongo-v4.15.5
+
+    {py3.6}-redis-v2.10.6
+    {py3.6,py3.7,py3.8}-redis-v3.5.3
+    {py3.7,py3.10,py3.11}-redis-v4.6.0
+    {py3.8,py3.11,py3.12}-redis-v5.3.1
+    {py3.9,py3.12,py3.13}-redis-v6.4.0
+    {py3.10,py3.13,py3.14,py3.14t}-redis-v7.1.0
+
+    {py3.6}-redis_py_cluster_legacy-v1.3.6
+    {py3.6,py3.7,py3.8}-redis_py_cluster_legacy-v2.1.3
+
+    {py3.6,py3.8,py3.9}-sqlalchemy-v1.3.24
+    {py3.6,py3.11,py3.12}-sqlalchemy-v1.4.54
+    {py3.7,py3.12,py3.13}-sqlalchemy-v2.0.45
+
+
+    # ~~~ Flags ~~~
+    {py3.8,py3.12,py3.13}-launchdarkly-v9.8.1
+    {py3.9,py3.13,py3.14,py3.14t}-launchdarkly-v9.14.1
+
+    {py3.8,py3.13,py3.14,py3.14t}-openfeature-v0.7.5
+    {py3.9,py3.13,py3.14,py3.14t}-openfeature-v0.8.4
+
+    {py3.7,py3.13,py3.14}-statsig-v0.55.3
+    {py3.7,py3.13,py3.14}-statsig-v0.66.2
+
+    {py3.8,py3.12,py3.13}-unleash-v6.0.1
+    {py3.8,py3.12,py3.13}-unleash-v6.4.1
+
+
+    # ~~~ GraphQL ~~~
+    {py3.8,py3.10,py3.11}-ariadne-v0.20.1
+    {py3.9,py3.12,py3.13}-ariadne-v0.26.2
+
+    {py3.6,py3.9,py3.10}-gql-v3.4.1
+    {py3.9,py3.12,py3.13}-gql-v4.0.0
+    {py3.9,py3.12,py3.13}-gql-v4.2.0b0
+
+    {py3.6,py3.9,py3.10}-graphene-v3.3
+    {py3.8,py3.12,py3.13}-graphene-v3.4.3
+
+    {py3.8,py3.10,py3.11}-strawberry-v0.209.8
+    {py3.10,py3.13,py3.14,py3.14t}-strawberry-v0.287.3
+
+
+    # ~~~ Network ~~~
+    {py3.7,py3.8}-grpc-v1.32.0
+    {py3.7,py3.9,py3.10}-grpc-v1.47.5
+    {py3.7,py3.11,py3.12}-grpc-v1.62.3
+    {py3.9,py3.13,py3.14}-grpc-v1.76.0
 
-    # pure_eval
-    {py3.5,py3.6,py3.7,py3.8,py3.9,py3.10,py3.11}-pure_eval
+    {py3.6,py3.8,py3.9}-httpx-v0.16.1
+    {py3.6,py3.9,py3.10}-httpx-v0.20.0
+    {py3.7,py3.10,py3.11}-httpx-v0.24.1
+    {py3.9,py3.11,py3.12}-httpx-v0.28.1
 
-    # PyMongo (Mongo DB)
-    {py2.7,py3.6}-pymongo-v{3.1}
-    {py2.7,py3.6,py3.7,py3.8,py3.9}-pymongo-v{3.12}
-    {py3.6,py3.7,py3.8,py3.9,py3.10,py3.11}-pymongo-v{4.0}
-    {py3.7,py3.8,py3.9,py3.10,py3.11}-pymongo-v{4.1,4.2}
+    {py3.6}-requests-v2.12.5
+    {py3.9,py3.13,py3.14,py3.14t}-requests-v2.32.5
 
-    # Pyramid
-    {py2.7,py3.5,py3.6,py3.7,py3.8,py3.9,py3.10,py3.11}-pyramid-v{1.6,1.7,1.8,1.9,1.10}
 
-    # Quart
-    {py3.7,py3.8,py3.9,py3.10,py3.11}-quart-v{0.16,0.17,0.18}
-    {py3.8,py3.9,py3.10,py3.11}-quart-v{0.19}
+    # ~~~ Tasks ~~~
+    {py3.7,py3.9,py3.10}-arq-v0.23
+    {py3.8,py3.11,py3.12}-arq-v0.26.3
 
-    # Redis
-    {py2.7,py3.7,py3.8,py3.9,py3.10,py3.11}-redis
+    {py3.7}-beam-v2.14.0
+    {py3.10,py3.12,py3.13}-beam-v2.70.0
 
-    # Redis Cluster
-    {py2.7,py3.7,py3.8,py3.9}-rediscluster-v{1,2.1.0,2}
+    {py3.6,py3.7,py3.8}-celery-v4.4.7
+    {py3.9,py3.12,py3.13}-celery-v5.6.0
 
-    # Requests
-    {py2.7,py3.8,py3.9,py3.10,py3.11}-requests
+    {py3.6,py3.7}-dramatiq-v1.9.0
+    {py3.10,py3.13,py3.14,py3.14t}-dramatiq-v2.0.0
 
-    # RQ (Redis Queue)
-    {py2.7,py3.5,py3.6}-rq-v{0.6,0.7,0.8,0.9,0.10,0.11}
-    {py2.7,py3.5,py3.6,py3.7,py3.8,py3.9}-rq-v{0.12,0.13,1.0,1.1,1.2,1.3}
-    {py3.5,py3.6,py3.7,py3.8,py3.9,py3.10,py3.11}-rq-v{1.4,1.5}
+    {py3.6,py3.7}-huey-v2.1.3
+    {py3.6,py3.13,py3.14,py3.14t}-huey-v2.5.5
 
-    # Sanic
-    {py3.5,py3.6,py3.7}-sanic-v{0.8,18}
-    {py3.6,py3.7}-sanic-v{19}
-    {py3.6,py3.7,py3.8}-sanic-v{20}
-    {py3.7,py3.8,py3.9,py3.10,py3.11}-sanic-v{21}
-    {py3.7,py3.8,py3.9,py3.10,py3.11}-sanic-v{22}
-    {py3.8,py3.9,py3.10,py3.11}-sanic-latest
+    {py3.9,py3.10}-ray-v2.7.2
+    {py3.10,py3.12,py3.13}-ray-v2.52.1
 
-    # Starlette
-    {py3.7,py3.8,py3.9,py3.10,py3.11}-starlette-v{0.20,0.22,0.24,0.26,0.28}
+    {py3.6}-rq-v0.8.2
+    {py3.6,py3.7}-rq-v0.13.0
+    {py3.7,py3.11,py3.12}-rq-v1.16.2
+    {py3.9,py3.12,py3.13}-rq-v2.6.1
 
-    # Starlite
-    {py3.8,py3.9,py3.10,py3.11}-starlite
+    {py3.8,py3.9}-spark-v3.0.3
+    {py3.8,py3.10,py3.11}-spark-v3.5.7
+    {py3.10,py3.13,py3.14}-spark-v4.1.0
 
-    # SQL Alchemy
-    {py2.7,py3.7,py3.8,py3.9,py3.10,py3.11}-sqlalchemy-v{1.2,1.3,1.4}
-    {py3.7,py3.8,py3.9,py3.10,py3.11}-sqlalchemy-v{2.0}
 
-    # Strawberry
-    {py3.8,py3.9,py3.10,py3.11}-strawberry
+    # ~~~ Web 1 ~~~
+    {py3.6,py3.7}-django-v1.11.29
+    {py3.6,py3.8,py3.9}-django-v2.2.28
+    {py3.6,py3.9,py3.10}-django-v3.2.25
+    {py3.8,py3.11,py3.12}-django-v4.2.27
+    {py3.10,py3.13,py3.14,py3.14t}-django-v5.2.9
+    {py3.12,py3.13,py3.14,py3.14t}-django-v6.0
+
+    {py3.6,py3.7,py3.8}-flask-v1.1.4
+    {py3.8,py3.13,py3.14,py3.14t}-flask-v2.3.3
+    {py3.9,py3.13,py3.14,py3.14t}-flask-v3.1.2
+
+    {py3.6,py3.9,py3.10}-starlette-v0.16.0
+    {py3.7,py3.10,py3.11}-starlette-v0.27.0
+    {py3.8,py3.12,py3.13}-starlette-v0.38.6
+    {py3.10,py3.13,py3.14,py3.14t}-starlette-v0.50.0
+
+    {py3.6,py3.9,py3.10}-fastapi-v0.79.1
+    {py3.7,py3.10,py3.11}-fastapi-v0.94.1
+    {py3.8,py3.11,py3.12}-fastapi-v0.109.2
+    {py3.9,py3.13,py3.14,py3.14t}-fastapi-v0.125.0
+
+
+    # ~~~ Web 2 ~~~
+    {py3.7}-aiohttp-v3.4.4
+    {py3.7,py3.8,py3.9}-aiohttp-v3.7.4
+    {py3.8,py3.12,py3.13}-aiohttp-v3.10.11
+    {py3.9,py3.13,py3.14,py3.14t}-aiohttp-v3.13.2
+
+    {py3.6,py3.7}-bottle-v0.12.25
+    {py3.8,py3.12,py3.13}-bottle-v0.13.4
+
+    {py3.6}-falcon-v1.4.1
+    {py3.6,py3.7}-falcon-v2.0.0
+    {py3.6,py3.11,py3.12}-falcon-v3.1.3
+    {py3.9,py3.11,py3.12}-falcon-v4.2.0
+
+    {py3.8,py3.10,py3.11}-litestar-v2.0.1
+    {py3.8,py3.11,py3.12}-litestar-v2.6.4
+    {py3.8,py3.11,py3.12}-litestar-v2.12.1
+    {py3.8,py3.12,py3.13}-litestar-v2.19.0
+
+    {py3.6}-pyramid-v1.8.6
+    {py3.6,py3.8,py3.9}-pyramid-v1.10.8
+    {py3.6,py3.10,py3.11}-pyramid-v2.0.2
+
+    {py3.7,py3.9,py3.10}-quart-v0.16.3
+    {py3.9,py3.13,py3.14,py3.14t}-quart-v0.20.0
+
+    {py3.6}-sanic-v0.8.3
+    {py3.6,py3.8,py3.9}-sanic-v20.12.7
+    {py3.8,py3.10,py3.11}-sanic-v23.12.2
+    {py3.9,py3.12,py3.13}-sanic-v25.3.0
+
+    {py3.8,py3.10,py3.11}-starlite-v1.48.1
+    {py3.8,py3.10,py3.11}-starlite-v1.51.16
+
+    {py3.6,py3.7,py3.8}-tornado-v6.0.4
+    {py3.9,py3.12,py3.13}-tornado-v6.5.4
+
+
+    # ~~~ Misc ~~~
+    {py3.6,py3.12,py3.13}-loguru-v0.7.3
+
+    {py3.6,py3.7,py3.8}-pure_eval-v0.0.3
+    {py3.7,py3.12,py3.13}-pure_eval-v0.2.3
+
+    {py3.6}-trytond-v4.6.22
+    {py3.6}-trytond-v4.8.18
+    {py3.6,py3.7,py3.8}-trytond-v5.8.16
+    {py3.8,py3.10,py3.11}-trytond-v6.8.17
+    {py3.9,py3.12,py3.13}-trytond-v7.8.0
+
+    {py3.7,py3.12,py3.13}-typer-v0.15.4
+    {py3.8,py3.13,py3.14,py3.14t}-typer-v0.20.0
 
-    # Tornado
-    {py3.7,py3.8,py3.9}-tornado-v{5}
-    {py3.7,py3.8,py3.9,py3.10,py3.11}-tornado-v{6}
 
-    # Trytond
-    {py3.5,py3.6,py3.7,py3.8,py3.9}-trytond-v{4.6,5.0,5.2}
-    {py3.6,py3.7,py3.8,py3.9,py3.10,py3.11}-trytond-v{5.4}
 
 [testenv]
 deps =
-    # if you change test-requirements.txt and your change is not being reflected
+    # if you change requirements-testing.txt and your change is not being reflected
     # in what's installed by tox (when running tox locally), try running tox
     # with the -r flag
-    -r test-requirements.txt
+    -r requirements-testing.txt
 
-    py3.8-common: hypothesis
-
-    linters: -r linter-requirements.txt
+    linters: -r requirements-linting.txt
     linters: werkzeug<2.3.0
 
-    # Common
-    {py3.6,py3.7,py3.8,py3.9,py3.10,py3.11}-common: pytest-asyncio
-
-    # AIOHTTP
-    aiohttp-v3.4: aiohttp>=3.4.0,<3.5.0
-    aiohttp-v3.5: aiohttp>=3.5.0,<3.6.0
-    aiohttp: pytest-aiohttp
-
-    # Ariadne
-    ariadne: ariadne>=0.20
-    ariadne: fastapi
-    ariadne: flask
-    ariadne: httpx
+    # === Common ===
+    py3.8-common: hypothesis
+    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
+    {py3.6,py3.7}-common: pytest<7.0.0
+    {py3.8,py3.9,py3.10,py3.11,py3.12,py3.13,py3.14,py3.14t}-common: pytest
+    # coverage 7.11.1-7.11.3 makes some of our tests flake
+    {py3.14,py3.14t}-common: coverage==7.11.0
+
+    # === Gevent ===
+    {py3.6,py3.7,py3.8,py3.9,py3.10,py3.11}-gevent: gevent>=22.10.0, <22.11.0
+    {py3.12}-gevent: gevent
+    # 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
+    {py3.6,py3.7}-gevent: pytest<7.0.0
+    {py3.8,py3.9,py3.10,py3.11,py3.12}-gevent: pytest
+    gevent: pytest-asyncio
+    {py3.10,py3.11}-gevent: zope.event<5.0.0
+    {py3.10,py3.11}-gevent: zope.interface<8.0
+
+    # === Integration Deactivation ===
+    integration_deactivation: openai
+    integration_deactivation: anthropic
+    integration_deactivation: langchain
 
-    # Arq
-    arq: arq>=0.23.0
-    arq: fakeredis>=2.2.0,<2.8
-    arq: pytest-asyncio
-    arq: async-timeout
+    # === Integrations ===
 
     # Asgi
     asgi: pytest-asyncio
     asgi: async-asgi-testclient
 
-    # Asyncpg
-    asyncpg: pytest-asyncio
-    asyncpg: asyncpg
-
     # AWS Lambda
+    aws_lambda: aws-cdk-lib
+    aws_lambda: aws-sam-cli
     aws_lambda: boto3
+    aws_lambda: fastapi
+    aws_lambda: requests
+    aws_lambda: uvicorn
 
-    # Beam
-    beam-v2.12: apache-beam>=2.12.0, <2.13.0
-    beam-v2.13: apache-beam>=2.13.0, <2.14.0
-    beam-v2.32: apache-beam>=2.32.0, <2.33.0
-    beam-v2.33: apache-beam>=2.33.0, <2.34.0
-    beam-master: git+https://github.com/apache/beam#egg=apache-beam&subdirectory=sdks/python
-
-    # Boto3
-    boto3-v1.9: boto3>=1.9,<1.10
-    boto3-v1.10: boto3>=1.10,<1.11
-    boto3-v1.11: boto3>=1.11,<1.12
-    boto3-v1.12: boto3>=1.12,<1.13
-    boto3-v1.13: boto3>=1.13,<1.14
-    boto3-v1.14: boto3>=1.14,<1.15
-    boto3-v1.15: boto3>=1.15,<1.16
-    boto3-v1.16: boto3>=1.16,<1.17
-
-    # Bottle
-    bottle: Werkzeug<2.1.0
-    bottle-v0.12: bottle>=0.12,<0.13
-
-    # Celery
-    celery: redis
-    celery-v3: Celery>=3.1,<4.0
-    celery-v4.1: Celery>=4.1,<4.2
-    celery-v4.2: Celery>=4.2,<4.3
-    celery-v4.3: Celery>=4.3,<4.4
-    # https://github.com/celery/vine/pull/29#issuecomment-689498382
-    celery-4.3: vine<5.0.0
-    # https://github.com/celery/celery/issues/6153
-    celery-v4.4: Celery>=4.4,<4.5,!=4.4.4
-    celery-v5.0: Celery>=5.0,<5.1
-    celery-v5.1: Celery>=5.1,<5.2
-    celery-v5.2: Celery>=5.2,<5.3
-    celery-v5.3: Celery>=5.3,<5.4
-
-    {py3.5}-celery: newrelic<6.0.0
-    {py3.7}-celery: importlib-metadata<5.0
-    {py2.7,py3.6,py3.7,py3.8,py3.9,py3.10,py3.11}-celery: newrelic
+    # OpenTelemetry (OTel)
+    opentelemetry: opentelemetry-distro
 
-    # Chalice
-    chalice-v1.18: chalice>=1.18.0,<1.19.0
-    chalice-v1.20: chalice>=1.20.0,<1.21.0
-    chalice-v1.22: chalice>=1.22.0,<1.23.0
-    chalice-v1.24: chalice>=1.24.0,<1.25.0
-    chalice: pytest-chalice==0.0.5
+    # OpenTelemetry with OTLP
+    otlp: opentelemetry-distro[otlp]
+
+    # OpenTelemetry Experimental (POTel)
+    potel: -e .[opentelemetry-experimental]
+
+    # === Integrations - Auto-generated ===
+    # These come from the populate_tox.py script.
+
+    # ~~~ MCP ~~~
+    mcp-v1.15.0: mcp==1.15.0
+    mcp-v1.18.0: mcp==1.18.0
+    mcp-v1.21.2: mcp==1.21.2
+    mcp-v1.24.0: mcp==1.24.0
+    mcp: pytest-asyncio
+
+    fastmcp-v0.1.0: fastmcp==0.1.0
+    fastmcp-v0.4.1: fastmcp==0.4.1
+    fastmcp-v1.0: fastmcp==1.0
+    fastmcp-v2.14.1: fastmcp==2.14.1
+    fastmcp: pytest-asyncio
+
+
+    # ~~~ Agents ~~~
+    openai_agents-v0.0.19: openai-agents==0.0.19
+    openai_agents-v0.2.11: openai-agents==0.2.11
+    openai_agents-v0.4.2: openai-agents==0.4.2
+    openai_agents-v0.6.4: openai-agents==0.6.4
+    openai_agents: pytest-asyncio
+
+    pydantic_ai-v1.0.18: pydantic-ai==1.0.18
+    pydantic_ai-v1.12.0: pydantic-ai==1.12.0
+    pydantic_ai-v1.24.0: pydantic-ai==1.24.0
+    pydantic_ai-v1.36.0: pydantic-ai==1.36.0
+    pydantic_ai: pytest-asyncio
+
+
+    # ~~~ AI Workflow ~~~
+    langchain-base-v0.1.20: langchain==0.1.20
+    langchain-base-v0.3.27: langchain==0.3.27
+    langchain-base-v1.2.0: langchain==1.2.0
+    langchain-base: pytest-asyncio
+    langchain-base: openai
+    langchain-base: tiktoken
+    langchain-base: langchain-openai
+    langchain-base-v0.3.27: langchain-community
+    langchain-base-v1.2.0: langchain-community
+    langchain-base-v1.2.0: langchain-classic
+
+    langchain-notiktoken-v0.1.20: langchain==0.1.20
+    langchain-notiktoken-v0.3.27: langchain==0.3.27
+    langchain-notiktoken-v1.2.0: langchain==1.2.0
+    langchain-notiktoken: pytest-asyncio
+    langchain-notiktoken: openai
+    langchain-notiktoken: langchain-openai
+    langchain-notiktoken-v0.3.27: langchain-community
+    langchain-notiktoken-v1.2.0: langchain-community
+    langchain-notiktoken-v1.2.0: langchain-classic
+
+    langgraph-v0.6.11: langgraph==0.6.11
+    langgraph-v1.0.5: langgraph==1.0.5
+
+
+    # ~~~ AI ~~~
+    anthropic-v0.16.0: anthropic==0.16.0
+    anthropic-v0.36.2: anthropic==0.36.2
+    anthropic-v0.56.0: anthropic==0.56.0
+    anthropic-v0.75.0: anthropic==0.75.0
+    anthropic: pytest-asyncio
+    anthropic-v0.16.0: httpx<0.28.0
+    anthropic-v0.36.2: httpx<0.28.0
+
+    cohere-v5.4.0: cohere==5.4.0
+    cohere-v5.10.0: cohere==5.10.0
+    cohere-v5.15.0: cohere==5.15.0
+    cohere-v5.20.1: cohere==5.20.1
+
+    google_genai-v1.29.0: google-genai==1.29.0
+    google_genai-v1.38.0: google-genai==1.38.0
+    google_genai-v1.47.0: google-genai==1.47.0
+    google_genai-v1.56.0: google-genai==1.56.0
+    google_genai: pytest-asyncio
+
+    huggingface_hub-v0.24.7: huggingface_hub==0.24.7
+    huggingface_hub-v0.36.0: huggingface_hub==0.36.0
+    huggingface_hub-v1.2.3: huggingface_hub==1.2.3
+    huggingface_hub: responses
+    huggingface_hub: pytest-httpx
+
+    litellm-v1.77.7: litellm==1.77.7
+    litellm-v1.78.7: litellm==1.78.7
+    litellm-v1.79.3: litellm==1.79.3
+    litellm-v1.80.10: litellm==1.80.10
+
+    openai-base-v1.0.1: openai==1.0.1
+    openai-base-v1.109.1: openai==1.109.1
+    openai-base-v2.14.0: openai==2.14.0
+    openai-base: pytest-asyncio
+    openai-base: tiktoken
+    openai-base-v1.0.1: httpx<0.28
+
+    openai-notiktoken-v1.0.1: openai==1.0.1
+    openai-notiktoken-v1.109.1: openai==1.109.1
+    openai-notiktoken-v2.14.0: openai==2.14.0
+    openai-notiktoken: pytest-asyncio
+    openai-notiktoken-v1.0.1: httpx<0.28
+
+
+    # ~~~ Cloud ~~~
+    boto3-v1.12.49: boto3==1.12.49
+    boto3-v1.21.46: boto3==1.21.46
+    boto3-v1.33.13: boto3==1.33.13
+    boto3-v1.42.13: boto3==1.42.13
+    {py3.7,py3.8}-boto3: urllib3<2.0.0
+
+    chalice-v1.16.0: chalice==1.16.0
+    chalice-v1.32.0: chalice==1.32.0
+    chalice: pytest-chalice
+
+
+    # ~~~ DBs ~~~
+    asyncpg-v0.23.0: asyncpg==0.23.0
+    asyncpg-v0.26.0: asyncpg==0.26.0
+    asyncpg-v0.29.0: asyncpg==0.29.0
+    asyncpg-v0.31.0: asyncpg==0.31.0
+    asyncpg: pytest-asyncio
 
-    {py3.7}-chalice: botocore~=1.31
-    {py3.8}-chalice: botocore~=1.31
+    clickhouse_driver-v0.2.10: clickhouse-driver==0.2.10
 
-    # Clickhouse Driver
-    clickhouse_driver-v0.2.4: clickhouse_driver>=0.2.4,<0.2.5
-    clickhouse_driver-v0.2.5: clickhouse_driver>=0.2.5,<0.2.6
-    clickhouse_driver-v0.2.6: clickhouse_driver>=0.2.6,<0.2.7
+    pymongo-v3.5.1: pymongo==3.5.1
+    pymongo-v3.13.0: pymongo==3.13.0
+    pymongo-v4.15.5: pymongo==4.15.5
+    pymongo: mockupdb
 
-    # Django
-    django: psycopg2-binary
-    django: Werkzeug<2.1.0
-    django-v{1.11,2.0,2.1,2.2,3.0,3.1,3.2}: djangorestframework>=3.0.0,<4.0.0
-
-    {py3.7,py3.8,py3.9,py3.10,py3.11}-django-v{1.11,2.0,2.1,2.2,3.0,3.1,3.2}: pytest-asyncio
-    {py3.7,py3.8,py3.9,py3.10,py3.11}-django-v{1.11,2.0,2.1,2.2,3.0,3.1,3.2}: channels[daphne]>2
-
-    django-v{1.8,1.9,1.10,1.11,2.0,2.1}: pytest-django<4.0
-    django-v{2.2,3.0,3.1,3.2}: pytest-django>=4.0
-    django-v{2.2,3.0,3.1,3.2}: Werkzeug<2.0
-
-    django-v{4.0,4.1}: djangorestframework
-    django-v{4.0,4.1}: pytest-asyncio
-    django-v{4.0,4.1}: pytest-django
-    django-v{4.0,4.1}: Werkzeug
-
-    django-v1.8: Django>=1.8,<1.9
-    django-v1.9: Django>=1.9,<1.10
-    django-v1.10: Django>=1.10,<1.11
-    django-v1.11: Django>=1.11,<1.12
-    django-v2.0: Django>=2.0,<2.1
-    django-v2.1: Django>=2.1,<2.2
-    django-v2.2: Django>=2.2,<2.3
-    django-v3.0: Django>=3.0,<3.1
-    django-v3.1: Django>=3.1,<3.2
-    django-v3.2: Django>=3.2,<3.3
-    django-v4.0: Django>=4.0,<4.1
-    django-v4.1: Django>=4.1,<4.2
-
-    # Falcon
-    falcon-v1.4: falcon>=1.4,<1.5
-    falcon-v2.0: falcon>=2.0.0rc3,<3.0
-    falcon-v3.0: falcon>=3.0.0,<3.1.0
-
-    # FastAPI
-    fastapi: fastapi
-    fastapi: httpx
-    fastapi: anyio<4.0.0 # thats a dep of httpx
-    fastapi: pytest-asyncio
-    fastapi: python-multipart
-    fastapi: requests
+    redis-v2.10.6: redis==2.10.6
+    redis-v3.5.3: redis==3.5.3
+    redis-v4.6.0: redis==4.6.0
+    redis-v5.3.1: redis==5.3.1
+    redis-v6.4.0: redis==6.4.0
+    redis-v7.1.0: redis==7.1.0
+    redis: fakeredis!=1.7.4
+    redis: pytest<8.0.0
+    redis-v4.6.0: fakeredis<2.31.0
+    {py3.6,py3.7,py3.8}-redis: fakeredis<2.26.0
+    {py3.7,py3.8,py3.9,py3.10,py3.11,py3.12,py3.13}-redis: pytest-asyncio
 
-    # Flask
-    flask: flask-login
-    flask: Werkzeug<2.1.0
-    flask-v0.11: Flask>=0.11,<0.12
-    flask-v0.12: Flask>=0.12,<0.13
-    flask-v1.0: Flask>=1.0,<1.1
-    flask-v1.1: Flask>=1.1,<1.2
-    flask-v2.0: Flask>=2.0,<2.1
-
-    # Gevent
-    # See http://www.gevent.org/install.html#older-versions-of-python
-    # for justification of the versions pinned below
-    py3.5-gevent: gevent==20.9.0
-    # See https://stackoverflow.com/questions/51496550/runtime-warning-greenlet-greenlet-size-changed
-    # for justification why greenlet is pinned here
-    py3.5-gevent: greenlet==0.4.17
-    {py2.7,py3.6,py3.7,py3.8,py3.9,py3.10,py3.11}-gevent: gevent>=22.10.0, <22.11.0
-
-    # GQL
-    gql: gql[all]
-
-    # Graphene
-    graphene: graphene>=3.3
+    redis_py_cluster_legacy-v1.3.6: redis-py-cluster==1.3.6
+    redis_py_cluster_legacy-v2.1.3: redis-py-cluster==2.1.3
+
+    sqlalchemy-v1.3.24: sqlalchemy==1.3.24
+    sqlalchemy-v1.4.54: sqlalchemy==1.4.54
+    sqlalchemy-v2.0.45: sqlalchemy==2.0.45
+
+
+    # ~~~ Flags ~~~
+    launchdarkly-v9.8.1: launchdarkly-server-sdk==9.8.1
+    launchdarkly-v9.14.1: launchdarkly-server-sdk==9.14.1
+
+    openfeature-v0.7.5: openfeature-sdk==0.7.5
+    openfeature-v0.8.4: openfeature-sdk==0.8.4
+
+    statsig-v0.55.3: statsig==0.55.3
+    statsig-v0.66.2: statsig==0.66.2
+    statsig: typing_extensions
+
+    unleash-v6.0.1: UnleashClient==6.0.1
+    unleash-v6.4.1: UnleashClient==6.4.1
+
+
+    # ~~~ GraphQL ~~~
+    ariadne-v0.20.1: ariadne==0.20.1
+    ariadne-v0.26.2: ariadne==0.26.2
+    ariadne: fastapi
+    ariadne: flask
+    ariadne: httpx
+
+    gql-v3.4.1: gql[all]==3.4.1
+    gql-v4.0.0: gql[all]==4.0.0
+    gql-v4.2.0b0: gql[all]==4.2.0b0
+
+    graphene-v3.3: graphene==3.3
+    graphene-v3.4.3: graphene==3.4.3
     graphene: blinker
     graphene: fastapi
     graphene: flask
     graphene: httpx
+    {py3.6}-graphene: aiocontextvars
+
+    strawberry-v0.209.8: strawberry-graphql[fastapi,flask]==0.209.8
+    strawberry-v0.287.3: strawberry-graphql[fastapi,flask]==0.287.3
+    strawberry: httpx
+    strawberry-v0.209.8: pydantic<2.11
 
-    # Grpc
-    grpc-v1.40: grpcio-tools>=1.40.0,<1.41.0
-    grpc-v1.44: grpcio-tools>=1.44.0,<1.45.0
-    grpc-v1.48: grpcio-tools>=1.48.0,<1.49.0
-    grpc-v1.54: grpcio-tools>=1.54.0,<1.55.0
-    grpc-v1.56: grpcio-tools>=1.56.0,<1.57.0
-    grpc-v1.58: grpcio-tools>=1.58.0,<1.59.0
+
+    # ~~~ Network ~~~
+    grpc-v1.32.0: grpcio==1.32.0
+    grpc-v1.47.5: grpcio==1.47.5
+    grpc-v1.62.3: grpcio==1.62.3
+    grpc-v1.76.0: grpcio==1.76.0
     grpc: protobuf
     grpc: mypy-protobuf
     grpc: types-protobuf
+    grpc: pytest-asyncio
 
-    # HTTPX
-    httpx: pytest-httpx
-    httpx: anyio<4.0.0 # thats a dep of httpx
-    httpx-v0.16: httpx>=0.16,<0.17
-    httpx-v0.17: httpx>=0.17,<0.18
-    httpx-v0.18: httpx>=0.18,<0.19
-    httpx-v0.19: httpx>=0.19,<0.20
-    httpx-v0.20: httpx>=0.20,<0.21
-    httpx-v0.21: httpx>=0.21,<0.22
-    httpx-v0.22: httpx>=0.22,<0.23
-    httpx-v0.23: httpx>=0.23,<0.24
-
-    # Huey
-    huey-2: huey>=2.0
-
-    # Loguru
-    loguru-v0.5: loguru>=0.5.0,<0.6.0
-    loguru-v0.6: loguru>=0.6.0,<0.7.0
-    loguru-v0.7: loguru>=0.7.0,<0.8.0
+    httpx-v0.16.1: httpx==0.16.1
+    httpx-v0.20.0: httpx==0.20.0
+    httpx-v0.24.1: httpx==0.24.1
+    httpx-v0.28.1: httpx==0.28.1
+    httpx: anyio<4.0.0
+    httpx-v0.16.1: pytest-httpx==0.10.0
+    httpx-v0.20.0: pytest-httpx==0.14.0
+    httpx-v0.24.1: pytest-httpx==0.22.0
+    httpx-v0.28.1: pytest-httpx==0.35.0
 
-    # OpenTelemetry (OTel)
-    opentelemetry: opentelemetry-distro
+    requests-v2.12.5: requests==2.12.5
+    requests-v2.32.5: requests==2.32.5
 
-    # pure_eval
-    pure_eval: pure_eval
 
-    # PyMongo (MongoDB)
-    pymongo: mockupdb
-    pymongo-v3.1: pymongo>=3.1,<3.2
-    pymongo-v3.12: pymongo>=3.12,<4.0
-    pymongo-v4.0: pymongo>=4.0,<4.1
-    pymongo-v4.1: pymongo>=4.1,<4.2
-    pymongo-v4.2: pymongo>=4.2,<4.3
-
-    # Pyramid
-    pyramid: Werkzeug<2.1.0
-    pyramid-v1.6: pyramid>=1.6,<1.7
-    pyramid-v1.7: pyramid>=1.7,<1.8
-    pyramid-v1.8: pyramid>=1.8,<1.9
-    pyramid-v1.9: pyramid>=1.9,<1.10
-    pyramid-v1.10: pyramid>=1.10,<1.11
-
-    # Quart
-    quart: quart-auth
-    quart: pytest-asyncio
-    quart-v0.16: blinker<1.6
-    quart-v0.16: jinja2<3.1.0
-    quart-v0.16: Werkzeug<2.1.0
-    quart-v0.16: quart>=0.16.1,<0.17.0
-    quart-v0.17: Werkzeug<3.0.0
-    quart-v0.17: blinker<1.6
-    quart-v0.17: quart>=0.17.0,<0.18.0
-    quart-v0.18: Werkzeug<3.0.0
-    quart-v0.18: quart>=0.18.0,<0.19.0
-    quart-v0.19: Werkzeug>=3.0.0
-    quart-v0.19: quart>=0.19.0,<0.20.0
-
-    # Requests
-    requests: requests>=2.0
-
-    # Redis
-    redis: fakeredis!=1.7.4
-    {py3.7,py3.8,py3.9,py3.10,py3.11}-redis: pytest-asyncio
-
-    # Redis Cluster
-    rediscluster-v1: redis-py-cluster>=1.0.0,<2.0.0
-    rediscluster-v2.1.0: redis-py-cluster>=2.0.0,<2.1.1
-    rediscluster-v2: redis-py-cluster>=2.1.1,<3.0.0
-
-    # RQ (Redis Queue)
-    # https://github.com/jamesls/fakeredis/issues/245
-    rq-v{0.6,0.7,0.8,0.9,0.10,0.11,0.12}: fakeredis<1.0
-    rq-v{0.6,0.7,0.8,0.9,0.10,0.11,0.12}: redis<3.2.2
-    rq-v{0.13,1.0,1.1,1.2,1.3,1.4,1.5}: fakeredis>=1.0,<1.7.4
-
-    rq-v0.6: rq>=0.6,<0.7
-    rq-v0.7: rq>=0.7,<0.8
-    rq-v0.8: rq>=0.8,<0.9
-    rq-v0.9: rq>=0.9,<0.10
-    rq-v0.10: rq>=0.10,<0.11
-    rq-v0.11: rq>=0.11,<0.12
-    rq-v0.12: rq>=0.12,<0.13
-    rq-v0.13: rq>=0.13,<0.14
-    rq-v1.0: rq>=1.0,<1.1
-    rq-v1.1: rq>=1.1,<1.2
-    rq-v1.2: rq>=1.2,<1.3
-    rq-v1.3: rq>=1.3,<1.4
-    rq-v1.4: rq>=1.4,<1.5
-    rq-v1.5: rq>=1.5,<1.6
-
-    # Sanic
-    sanic-v0.8: sanic>=0.8,<0.9
-    sanic-v18: sanic>=18.0,<19.0
-    sanic-v19: sanic>=19.0,<20.0
-    sanic-v20: sanic>=20.0,<21.0
-    sanic-v21: sanic>=21.0,<22.0
-    sanic-v22: sanic>=22.0,<22.9.0
-
-    # Sanic is not using semver, so here we check the current latest version of Sanic. When this test breaks, we should
-    # determine whether it is because we need to fix something in our integration, or whether Sanic has simply dropped
-    # support for an older Python version. If Sanic has dropped support for an older python version, we should add a new
-    # line above to test for the newest Sanic version still supporting the old Python version, and we should update the
-    # line below so we test the latest Sanic version only using the Python versions that are supported.
-    sanic-latest: sanic>=23.6
+    # ~~~ Tasks ~~~
+    arq-v0.23: arq==0.23
+    arq-v0.26.3: arq==0.26.3
+    arq: async-timeout
+    arq: pytest-asyncio
+    arq: fakeredis>=2.2.0,<2.8
+    arq-v0.23: pydantic<2
 
-    sanic: websockets<11.0
-    sanic: aiohttp
-    sanic-v21: sanic_testing<22
-    sanic-v22: sanic_testing<22.9.0
-    sanic-latest: sanic_testing>=23.6
-    {py3.5,py3.6}-sanic: aiocontextvars==0.2.1
-    {py3.5}-sanic: ujson<4
+    beam-v2.14.0: apache-beam==2.14.0
+    beam-v2.70.0: apache-beam==2.70.0
+    beam: dill
+
+    celery-v4.4.7: celery==4.4.7
+    celery-v5.6.0: celery==5.6.0
+    celery: newrelic<10.17.0
+    celery: redis
+    {py3.7}-celery: importlib-metadata<5.0
 
-    # Starlette
+    dramatiq-v1.9.0: dramatiq==1.9.0
+    dramatiq-v2.0.0: dramatiq==2.0.0
+
+    huey-v2.1.3: huey==2.1.3
+    huey-v2.5.5: huey==2.5.5
+
+    ray-v2.7.2: ray==2.7.2
+    ray-v2.52.1: ray==2.52.1
+
+    rq-v0.8.2: rq==0.8.2
+    rq-v0.13.0: rq==0.13.0
+    rq-v1.16.2: rq==1.16.2
+    rq-v2.6.1: rq==2.6.1
+    rq: fakeredis<2.28.0
+    rq-v0.8.2: fakeredis<1.0
+    rq-v0.8.2: redis<3.2.2
+    rq-v0.13.0: fakeredis>=1.0,<1.7.4
+    {py3.6,py3.7}-rq: fakeredis!=2.26.0
+
+    spark-v3.0.3: pyspark==3.0.3
+    spark-v3.5.7: pyspark==3.5.7
+    spark-v4.1.0: pyspark==4.1.0
+
+
+    # ~~~ Web 1 ~~~
+    django-v1.11.29: django==1.11.29
+    django-v2.2.28: django==2.2.28
+    django-v3.2.25: django==3.2.25
+    django-v4.2.27: django==4.2.27
+    django-v5.2.9: django==5.2.9
+    django-v6.0: django==6.0
+    django: psycopg2-binary
+    django: djangorestframework
+    django: pytest-django
+    django: Werkzeug
+    django-v2.2.28: channels[daphne]
+    django-v3.2.25: channels[daphne]
+    django-v4.2.27: channels[daphne]
+    django-v5.2.9: channels[daphne]
+    django-v6.0: channels[daphne]
+    django-v2.2.28: six
+    django-v3.2.25: pytest-asyncio
+    django-v4.2.27: pytest-asyncio
+    django-v5.2.9: pytest-asyncio
+    django-v6.0: pytest-asyncio
+    django-v1.11.29: djangorestframework>=3.0,<4.0
+    django-v1.11.29: Werkzeug<2.1.0
+    django-v2.2.28: djangorestframework>=3.0,<4.0
+    django-v2.2.28: Werkzeug<2.1.0
+    django-v3.2.25: djangorestframework>=3.0,<4.0
+    django-v3.2.25: Werkzeug<2.1.0
+    django-v1.11.29: pytest-django<4.0
+    django-v2.2.28: pytest-django<4.0
+    {py3.14,py3.14t}-django: coverage==7.11.0
+
+    flask-v1.1.4: flask==1.1.4
+    flask-v2.3.3: flask==2.3.3
+    flask-v3.1.2: flask==3.1.2
+    flask: flask-login
+    flask: werkzeug
+    flask-v1.1.4: werkzeug<2.1.0
+    flask-v1.1.4: markupsafe<2.1.0
+
+    starlette-v0.16.0: starlette==0.16.0
+    starlette-v0.27.0: starlette==0.27.0
+    starlette-v0.38.6: starlette==0.38.6
+    starlette-v0.50.0: starlette==0.50.0
     starlette: pytest-asyncio
     starlette: python-multipart
     starlette: requests
-    starlette: httpx
-    starlette: anyio<4.0.0 # thats a dep of httpx
+    starlette: anyio<4.0.0
     starlette: jinja2
-    starlette-v0.20: starlette>=0.20.0,<0.21.0
-    starlette-v0.22: starlette>=0.22.0,<0.23.0
-    starlette-v0.24: starlette>=0.24.0,<0.25.0
-    starlette-v0.26: starlette>=0.26.0,<0.27.0
-    starlette-v0.28: starlette>=0.28.0,<0.29.0
+    starlette: httpx
+    starlette-v0.16.0: httpx<0.28.0
+    starlette-v0.27.0: httpx<0.28.0
+    {py3.6}-starlette: aiocontextvars
+
+    fastapi-v0.79.1: fastapi==0.79.1
+    fastapi-v0.94.1: fastapi==0.94.1
+    fastapi-v0.109.2: fastapi==0.109.2
+    fastapi-v0.125.0: fastapi==0.125.0
+    fastapi: httpx
+    fastapi: pytest-asyncio
+    fastapi: python-multipart
+    fastapi: requests
+    fastapi: anyio<4
+    fastapi-v0.79.1: httpx<0.28.0
+    fastapi-v0.94.1: httpx<0.28.0
+    fastapi-v0.109.2: httpx<0.28.0
+    {py3.6}-fastapi: aiocontextvars
+
+
+    # ~~~ Web 2 ~~~
+    aiohttp-v3.4.4: aiohttp==3.4.4
+    aiohttp-v3.7.4: aiohttp==3.7.4
+    aiohttp-v3.10.11: aiohttp==3.10.11
+    aiohttp-v3.13.2: aiohttp==3.13.2
+    aiohttp: pytest-aiohttp
+    aiohttp-v3.10.11: pytest-asyncio
+    aiohttp-v3.13.2: pytest-asyncio
+
+    bottle-v0.12.25: bottle==0.12.25
+    bottle-v0.13.4: bottle==0.13.4
+    bottle: werkzeug<2.1.0
+
+    falcon-v1.4.1: falcon==1.4.1
+    falcon-v2.0.0: falcon==2.0.0
+    falcon-v3.1.3: falcon==3.1.3
+    falcon-v4.2.0: falcon==4.2.0
+
+    litestar-v2.0.1: litestar==2.0.1
+    litestar-v2.6.4: litestar==2.6.4
+    litestar-v2.12.1: litestar==2.12.1
+    litestar-v2.19.0: litestar==2.19.0
+    litestar: pytest-asyncio
+    litestar: python-multipart
+    litestar: requests
+    litestar: cryptography
+    litestar: sniffio
+    litestar-v2.0.1: httpx<0.28
+    litestar-v2.6.4: httpx<0.28
+
+    pyramid-v1.8.6: pyramid==1.8.6
+    pyramid-v1.10.8: pyramid==1.10.8
+    pyramid-v2.0.2: pyramid==2.0.2
+    pyramid: werkzeug<2.1.0
+
+    quart-v0.16.3: quart==0.16.3
+    quart-v0.20.0: quart==0.20.0
+    quart: quart-auth
+    quart: pytest-asyncio
+    quart: Werkzeug
+    quart-v0.20.0: quart-flask-patch
+    quart-v0.16.3: blinker<1.6
+    quart-v0.16.3: jinja2<3.1.0
+    quart-v0.16.3: Werkzeug<2.3.0
+    quart-v0.16.3: hypercorn<0.15.0
+    {py3.8}-quart: taskgroup==0.0.0a4
+
+    sanic-v0.8.3: sanic==0.8.3
+    sanic-v20.12.7: sanic==20.12.7
+    sanic-v23.12.2: sanic==23.12.2
+    sanic-v25.3.0: sanic==25.3.0
+    sanic: websockets<11.0
+    sanic: aiohttp
+    sanic-v23.12.2: sanic-testing
+    sanic-v25.3.0: sanic-testing
+    {py3.6}-sanic: aiocontextvars==0.2.1
+    {py3.8}-sanic: tracerite<1.1.2
 
-    # Starlite
+    starlite-v1.48.1: starlite==1.48.1
+    starlite-v1.51.16: starlite==1.51.16
     starlite: pytest-asyncio
     starlite: python-multipart
     starlite: requests
     starlite: cryptography
     starlite: pydantic<2.0.0
-    starlite: starlite
-    {py3.8,py3.9}-starlite: typing-extensions==4.5.0  # this is used by pydantic, which is used by starlite. When the problem is fixed in here or pydantic, this can be removed
-
-    # SQLAlchemy
-    sqlalchemy-v1.2: sqlalchemy>=1.2,<1.3
-    sqlalchemy-v1.3: sqlalchemy>=1.3,<1.4
-    sqlalchemy-v1.4: sqlalchemy>=1.4,<2.0
-    sqlalchemy-v2.0: sqlalchemy>=2.0,<2.1
-
-    # Strawberry
-    strawberry: strawberry-graphql[fastapi,flask]
-    strawberry: fastapi
-    strawberry: flask
-    strawberry: httpx
+    starlite: httpx<0.28
+
+    tornado-v6.0.4: tornado==6.0.4
+    tornado-v6.5.4: tornado==6.5.4
+    tornado: pytest
+    tornado-v6.0.4: pytest<8.2
+    {py3.6}-tornado: aiocontextvars
+
 
-    # Tornado
-    tornado-v5: tornado>=5,<6
-    tornado-v6: tornado>=6.0a1
+    # ~~~ Misc ~~~
+    loguru-v0.7.3: loguru==0.7.3
+
+    pure_eval-v0.0.3: pure_eval==0.0.3
+    pure_eval-v0.2.3: pure_eval==0.2.3
+
+    trytond-v4.6.22: trytond==4.6.22
+    trytond-v4.8.18: trytond==4.8.18
+    trytond-v5.8.16: trytond==5.8.16
+    trytond-v6.8.17: trytond==6.8.17
+    trytond-v7.8.0: trytond==7.8.0
+    trytond: werkzeug
+    trytond-v4.6.22: werkzeug<1.0
+    trytond-v4.8.18: werkzeug<1.0
+
+    typer-v0.15.4: typer==0.15.4
+    typer-v0.20.0: typer==0.20.0
 
-    # Trytond
-    trytond-v5.4: trytond>=5.4,<5.5
-    trytond-v5.2: trytond>=5.2,<5.3
-    trytond-v5.0: trytond>=5.0,<5.1
-    trytond-v4.6: trytond>=4.6,<4.7
 
-    trytond-v{4.6,4.8,5.0,5.2,5.4}: werkzeug<2.0
 
 setenv =
     PYTHONDONTWRITEBYTECODE=1
+    OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES
+    COVERAGE_FILE=.coverage-sentry-{envname}
+    py3.6: COVERAGE_RCFILE=.coveragerc36
+    # Lowest version to support free-threading
+    # https://discuss.python.org/t/announcement-pip-24-1-release/56281
+    py3.14t: VIRTUALENV_PIP=24.1
+
+    django: DJANGO_SETTINGS_MODULE=tests.integrations.django.myapp.settings
+    spark-v{3.0.3,3.5.6}: JAVA_HOME=/usr/lib/jvm/temurin-11-jdk-amd64
+
+    # Avoid polluting test suite with imports
+    common: PYTEST_ADDOPTS="--ignore=tests/test_shadowed_module.py"
+    gevent: PYTEST_ADDOPTS="--ignore=tests/test_shadowed_module.py"
+
+    # TESTPATH definitions for test suites not managed by toxgen
     common: TESTPATH=tests
+    gevent: TESTPATH=tests
+    integration_deactivation: TESTPATH=tests/test_ai_integration_deactivation.py
+    shadowed_module: TESTPATH=tests/test_shadowed_module.py
+    asgi: TESTPATH=tests/integrations/asgi
+    aws_lambda: TESTPATH=tests/integrations/aws_lambda
+    cloud_resource_context: TESTPATH=tests/integrations/cloud_resource_context
+    gcp: TESTPATH=tests/integrations/gcp
+    opentelemetry: TESTPATH=tests/integrations/opentelemetry
+    otlp: TESTPATH=tests/integrations/otlp
+    potel: TESTPATH=tests/integrations/opentelemetry
+    socket: TESTPATH=tests/integrations/socket
+
+    # These TESTPATH definitions are auto-generated by toxgen
     aiohttp: TESTPATH=tests/integrations/aiohttp
+    anthropic: TESTPATH=tests/integrations/anthropic
     ariadne: TESTPATH=tests/integrations/ariadne
     arq: TESTPATH=tests/integrations/arq
-    asgi: TESTPATH=tests/integrations/asgi
     asyncpg: TESTPATH=tests/integrations/asyncpg
-    aws_lambda: TESTPATH=tests/integrations/aws_lambda
     beam: TESTPATH=tests/integrations/beam
     boto3: TESTPATH=tests/integrations/boto3
     bottle: TESTPATH=tests/integrations/bottle
     celery: TESTPATH=tests/integrations/celery
     chalice: TESTPATH=tests/integrations/chalice
     clickhouse_driver: TESTPATH=tests/integrations/clickhouse_driver
-    cloud_resource_context: TESTPATH=tests/integrations/cloud_resource_context
+    cohere: TESTPATH=tests/integrations/cohere
     django: TESTPATH=tests/integrations/django
+    dramatiq: TESTPATH=tests/integrations/dramatiq
     falcon: TESTPATH=tests/integrations/falcon
-    fastapi:  TESTPATH=tests/integrations/fastapi
+    fastapi: TESTPATH=tests/integrations/fastapi
+    fastmcp: TESTPATH=tests/integrations/fastmcp
     flask: TESTPATH=tests/integrations/flask
-    # run all tests with gevent
-    gevent: TESTPATH=tests
-    gcp: TESTPATH=tests/integrations/gcp
+    google_genai: TESTPATH=tests/integrations/google_genai
     gql: TESTPATH=tests/integrations/gql
     graphene: TESTPATH=tests/integrations/graphene
+    grpc: TESTPATH=tests/integrations/grpc
     httpx: TESTPATH=tests/integrations/httpx
     huey: TESTPATH=tests/integrations/huey
+    huggingface_hub: TESTPATH=tests/integrations/huggingface_hub
+    langchain-base: TESTPATH=tests/integrations/langchain
+    langchain-notiktoken: TESTPATH=tests/integrations/langchain
+    langgraph: TESTPATH=tests/integrations/langgraph
+    launchdarkly: TESTPATH=tests/integrations/launchdarkly
+    litellm: TESTPATH=tests/integrations/litellm
+    litestar: TESTPATH=tests/integrations/litestar
     loguru: TESTPATH=tests/integrations/loguru
-    opentelemetry: TESTPATH=tests/integrations/opentelemetry
+    mcp: TESTPATH=tests/integrations/mcp
+    openai-base: TESTPATH=tests/integrations/openai
+    openai-notiktoken: TESTPATH=tests/integrations/openai
+    openai_agents: TESTPATH=tests/integrations/openai_agents
+    openfeature: TESTPATH=tests/integrations/openfeature
     pure_eval: TESTPATH=tests/integrations/pure_eval
+    pydantic_ai: TESTPATH=tests/integrations/pydantic_ai
     pymongo: TESTPATH=tests/integrations/pymongo
     pyramid: TESTPATH=tests/integrations/pyramid
     quart: TESTPATH=tests/integrations/quart
+    ray: TESTPATH=tests/integrations/ray
     redis: TESTPATH=tests/integrations/redis
-    rediscluster: TESTPATH=tests/integrations/rediscluster
+    redis_py_cluster_legacy: TESTPATH=tests/integrations/redis_py_cluster_legacy
     requests: TESTPATH=tests/integrations/requests
     rq: TESTPATH=tests/integrations/rq
     sanic: TESTPATH=tests/integrations/sanic
+    spark: TESTPATH=tests/integrations/spark
+    sqlalchemy: TESTPATH=tests/integrations/sqlalchemy
     starlette: TESTPATH=tests/integrations/starlette
     starlite: TESTPATH=tests/integrations/starlite
-    sqlalchemy: TESTPATH=tests/integrations/sqlalchemy
+    statsig: TESTPATH=tests/integrations/statsig
     strawberry: TESTPATH=tests/integrations/strawberry
     tornado: TESTPATH=tests/integrations/tornado
     trytond: TESTPATH=tests/integrations/trytond
-    socket: TESTPATH=tests/integrations/socket
-    grpc: TESTPATH=tests/integrations/grpc
+    typer: TESTPATH=tests/integrations/typer
+    unleash: TESTPATH=tests/integrations/unleash
 
-    COVERAGE_FILE=.coverage-{envname}
 passenv =
-    SENTRY_PYTHON_TEST_AWS_ACCESS_KEY_ID
-    SENTRY_PYTHON_TEST_AWS_SECRET_ACCESS_KEY
-    SENTRY_PYTHON_TEST_AWS_IAM_ROLE
+    SENTRY_PYTHON_TEST_POSTGRES_HOST
     SENTRY_PYTHON_TEST_POSTGRES_USER
     SENTRY_PYTHON_TEST_POSTGRES_PASSWORD
     SENTRY_PYTHON_TEST_POSTGRES_NAME
-    SENTRY_PYTHON_TEST_POSTGRES_HOST
+
 usedevelop = True
+
 extras =
     bottle: bottle
     falcon: falcon
@@ -582,44 +896,35 @@ extras =
     pymongo: pymongo
 
 basepython =
-    py2.7: python2.7
-    py3.5: python3.5
     py3.6: python3.6
     py3.7: python3.7
     py3.8: python3.8
     py3.9: python3.9
     py3.10: python3.10
     py3.11: python3.11
+    py3.12: python3.12
+    py3.13: python3.13
+    py3.14: python3.14
+    py3.14t: python3.14t
 
-    # Python version is pinned here because flake8 actually behaves differently
-    # depending on which version is used. You can patch this out to point to
-    # some random Python 3 binary, but then you get guaranteed mismatches with
-    # CI. Other tools such as mypy and black have options that pin the Python
-    # version.
-    linters: python3.11
+    # Python version is pinned here for consistency across environments.
+    # Tools like ruff and mypy have options that pin the target Python
+    # version (configured in pyproject.toml), ensuring consistent behavior.
+    linters: python3.14
 
 commands =
     {py3.7,py3.8}-boto3: pip install urllib3<2.0.0
 
-    ; https://github.com/pytest-dev/pytest/issues/5532
-    {py3.5,py3.6,py3.7,py3.8,py3.9}-flask-v{0.11,0.12}: pip install pytest<5
-    {py3.6,py3.7,py3.8,py3.9}-flask-v{0.11}: pip install Werkzeug<2
     ; https://github.com/pallets/flask/issues/4455
-    {py3.7,py3.8,py3.9,py3.10,py3.11}-flask-v{0.11,0.12,1.0,1.1}: pip install "itsdangerous>=0.24,<2.0" "markupsafe<2.0.0" "jinja2<3.1.1"
-    ; https://github.com/more-itertools/more-itertools/issues/578
-    py3.5-flask-v{0.11,0.12}: pip install more-itertools<8.11.0
-
-    ; use old pytest for old Python versions:
-    {py2.7,py3.5}: pip install pytest-forked==1.1.3
+    {py3.7,py3.8,py3.9,py3.10,py3.11}-flask-v{1}: pip install "itsdangerous>=0.24,<2.0" "markupsafe<2.0.0" "jinja2<3.1.1"
 
-    ; Running `py.test` as an executable suffers from an import error
+    ; Running `pytest` as an executable suffers from an import error
     ; when loading tests in scenarios. In particular, django fails to
     ; load the settings from the test module.
-    {py2.7}: python -m pytest --ignore-glob='*py3.py' -rsx -s --durations=5 -vvv {env:TESTPATH} {posargs}
-    {py3.5,py3.6,py3.7,py3.8,py3.9,py3.10,py3.11}: python -m pytest -rsx -s --durations=5 -vvv {env:TESTPATH} {posargs}
+    python -m pytest -W error::pytest.PytestUnraisableExceptionWarning {env:TESTPATH} -o junit_suite_name={envname} {posargs}
 
 [testenv:linters]
 commands =
-    flake8 tests sentry_sdk
-    black --check tests sentry_sdk
+    ruff check tests sentry_sdk
+    ruff format --check tests sentry_sdk
     mypy sentry_sdk