diff --git a/.github/workflows/test-integrations-ai.yml b/.github/workflows/test-integrations-ai.yml index 481f64b9bd..fe5acb973a 100644 --- a/.github/workflows/test-integrations-ai.yml +++ b/.github/workflows/test-integrations-ai.yml @@ -73,7 +73,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.0.2 + uses: codecov/codecov-action@v5.0.7 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -94,7 +94,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.7","3.9","3.11","3.12","3.13"] + python-version: ["3.8","3.9","3.11","3.12","3.13"] os: [ubuntu-latest] steps: - uses: actions/checkout@v4.2.2 @@ -140,7 +140,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.0.2 + uses: codecov/codecov-action@v5.0.7 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-aws.yml b/.github/workflows/test-integrations-aws.yml index 7cc17bbbfb..9493e2fc4d 100644 --- a/.github/workflows/test-integrations-aws.yml +++ b/.github/workflows/test-integrations-aws.yml @@ -88,7 +88,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.0.2 + uses: codecov/codecov-action@v5.0.7 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-cloud.yml b/.github/workflows/test-integrations-cloud.yml index 87fdededdb..fd0659348f 100644 --- a/.github/workflows/test-integrations-cloud.yml +++ b/.github/workflows/test-integrations-cloud.yml @@ -68,7 +68,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.0.2 + uses: codecov/codecov-action@v5.0.7 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -130,7 +130,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.0.2 + uses: codecov/codecov-action@v5.0.7 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-common.yml b/.github/workflows/test-integrations-common.yml index 7ef9979142..5790c5aa8e 100644 --- a/.github/workflows/test-integrations-common.yml +++ b/.github/workflows/test-integrations-common.yml @@ -53,7 +53,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.0.2 + uses: codecov/codecov-action@v5.0.7 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-dbs.yml b/.github/workflows/test-integrations-dbs.yml index abb4b58815..b413f1ce90 100644 --- a/.github/workflows/test-integrations-dbs.yml +++ b/.github/workflows/test-integrations-dbs.yml @@ -97,7 +97,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.0.2 + uses: codecov/codecov-action@v5.0.7 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -188,7 +188,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.0.2 + uses: codecov/codecov-action@v5.0.7 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-graphql.yml b/.github/workflows/test-integrations-graphql.yml index 54625fcda0..6a234cd797 100644 --- a/.github/workflows/test-integrations-graphql.yml +++ b/.github/workflows/test-integrations-graphql.yml @@ -68,7 +68,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.0.2 + uses: codecov/codecov-action@v5.0.7 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -130,7 +130,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.0.2 + uses: codecov/codecov-action@v5.0.7 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-misc.yml b/.github/workflows/test-integrations-misc.yml index 18380a4104..30dcf16155 100644 --- a/.github/workflows/test-integrations-misc.yml +++ b/.github/workflows/test-integrations-misc.yml @@ -83,7 +83,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.0.2 + uses: codecov/codecov-action@v5.0.7 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -160,7 +160,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.0.2 + uses: codecov/codecov-action@v5.0.7 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-network.yml b/.github/workflows/test-integrations-network.yml index d3559ab78f..aba38a2ec0 100644 --- a/.github/workflows/test-integrations-network.yml +++ b/.github/workflows/test-integrations-network.yml @@ -68,7 +68,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.0.2 + uses: codecov/codecov-action@v5.0.7 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -130,7 +130,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.0.2 + uses: codecov/codecov-action@v5.0.7 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-tasks.yml b/.github/workflows/test-integrations-tasks.yml index a29ad2b7d0..406a646f61 100644 --- a/.github/workflows/test-integrations-tasks.yml +++ b/.github/workflows/test-integrations-tasks.yml @@ -90,7 +90,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.0.2 + uses: codecov/codecov-action@v5.0.7 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -174,7 +174,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.0.2 + uses: codecov/codecov-action@v5.0.7 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-web-1.yml b/.github/workflows/test-integrations-web-1.yml index 794a37bbda..cad0a84dd5 100644 --- a/.github/workflows/test-integrations-web-1.yml +++ b/.github/workflows/test-integrations-web-1.yml @@ -86,7 +86,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.0.2 + uses: codecov/codecov-action@v5.0.7 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -166,7 +166,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.0.2 + uses: codecov/codecov-action@v5.0.7 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-web-2.yml b/.github/workflows/test-integrations-web-2.yml index 3ee632ae68..b24b8eaf0b 100644 --- a/.github/workflows/test-integrations-web-2.yml +++ b/.github/workflows/test-integrations-web-2.yml @@ -98,7 +98,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.0.2 + uses: codecov/codecov-action@v5.0.7 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -190,7 +190,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.0.2 + uses: codecov/codecov-action@v5.0.7 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/pytest.ini b/pytest.ini index c03752b039..7edd6127b9 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,6 +1,7 @@ [pytest] 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.) diff --git a/scripts/split-tox-gh-actions/templates/test_group.jinja b/scripts/split-tox-gh-actions/templates/test_group.jinja index 395489ccee..f807422309 100644 --- a/scripts/split-tox-gh-actions/templates/test_group.jinja +++ b/scripts/split-tox-gh-actions/templates/test_group.jinja @@ -82,7 +82,7 @@ - name: Upload coverage to Codecov if: {% raw %}${{ !cancelled() }}{% endraw %} - uses: codecov/codecov-action@v5.0.2 + uses: codecov/codecov-action@v5.0.7 with: token: {% raw %}${{ secrets.CODECOV_TOKEN }}{% endraw %} files: coverage.xml diff --git a/sentry_sdk/integrations/opentelemetry/integration.py b/sentry_sdk/integrations/opentelemetry/integration.py index 016ce91b52..231bb4f32b 100644 --- a/sentry_sdk/integrations/opentelemetry/integration.py +++ b/sentry_sdk/integrations/opentelemetry/integration.py @@ -6,8 +6,8 @@ from sentry_sdk.integrations import DidNotEnable, Integration from sentry_sdk.integrations.opentelemetry.propagator import SentryPropagator -from sentry_sdk.integrations.opentelemetry.potel_span_processor import ( - PotelSentrySpanProcessor, +from sentry_sdk.integrations.opentelemetry.span_processor import ( + SentrySpanProcessor, ) from sentry_sdk.integrations.opentelemetry.contextvars_context import ( SentryContextVarsRuntimeContext, @@ -79,7 +79,7 @@ def _setup_scope_context_management(): def _setup_sentry_tracing(): # type: () -> None provider = TracerProvider(sampler=SentrySampler()) - provider.add_span_processor(PotelSentrySpanProcessor()) + provider.add_span_processor(SentrySpanProcessor()) trace.set_tracer_provider(provider) set_global_textmap(SentryPropagator()) diff --git a/sentry_sdk/integrations/opentelemetry/potel_span_processor.py b/sentry_sdk/integrations/opentelemetry/potel_span_processor.py deleted file mode 100644 index 14636b9e37..0000000000 --- a/sentry_sdk/integrations/opentelemetry/potel_span_processor.py +++ /dev/null @@ -1,273 +0,0 @@ -from collections import deque, defaultdict -from typing import cast - -from opentelemetry.trace import ( - format_trace_id, - format_span_id, - get_current_span, - INVALID_SPAN, - Span as AbstractSpan, -) -from opentelemetry.context import Context -from opentelemetry.sdk.trace import Span, ReadableSpan, SpanProcessor - -from sentry_sdk import capture_event -from sentry_sdk.consts import SPANDATA -from sentry_sdk.tracing import DEFAULT_SPAN_ORIGIN -from sentry_sdk.utils import get_current_thread_meta -from sentry_sdk.profiler.continuous_profiler import ( - try_autostart_continuous_profiler, - get_profiler_id, -) -from sentry_sdk.profiler.transaction_profiler import Profile -from sentry_sdk.integrations.opentelemetry.utils import ( - is_sentry_span, - convert_from_otel_timestamp, - extract_span_attributes, - extract_span_data, - extract_transaction_name_source, - get_trace_context, - get_profile_context, - get_sentry_meta, - set_sentry_meta, -) -from sentry_sdk.integrations.opentelemetry.consts import ( - OTEL_SENTRY_CONTEXT, - SentrySpanAttribute, -) -from sentry_sdk._types import TYPE_CHECKING - -if TYPE_CHECKING: - from typing import Optional, List, Any, Deque, DefaultDict - from sentry_sdk._types import Event - - -class PotelSentrySpanProcessor(SpanProcessor): - """ - Converts OTel spans into Sentry spans so they can be sent to the Sentry backend. - """ - - def __new__(cls): - # type: () -> PotelSentrySpanProcessor - if not hasattr(cls, "instance"): - cls.instance = super().__new__(cls) - - return cls.instance - - def __init__(self): - # type: () -> None - self._children_spans = defaultdict( - list - ) # type: DefaultDict[int, List[ReadableSpan]] - - def on_start(self, span, parent_context=None): - # type: (Span, Optional[Context]) -> None - if is_sentry_span(span): - return - - self._add_root_span(span, get_current_span(parent_context)) - self._start_profile(span) - - def on_end(self, span): - # type: (ReadableSpan) -> None - if is_sentry_span(span): - return - - is_root_span = not span.parent or span.parent.is_remote - if is_root_span: - # if have a root span ending, we build a transaction and send it - self._flush_root_span(span) - else: - self._children_spans[span.parent.span_id].append(span) - - # TODO-neel-potel not sure we need a clear like JS - def shutdown(self): - # type: () -> None - pass - - # TODO-neel-potel change default? this is 30 sec - # TODO-neel-potel call this in client.flush - def force_flush(self, timeout_millis=30000): - # type: (int) -> bool - return True - - def _add_root_span(self, span, parent_span): - # type: (Span, AbstractSpan) -> None - """ - This is required to make POTelSpan.root_span work - since we can't traverse back to the root purely with otel efficiently. - """ - if parent_span != INVALID_SPAN and not parent_span.get_span_context().is_remote: - # child span points to parent's root or parent - parent_root_span = get_sentry_meta(parent_span, "root_span") - set_sentry_meta(span, "root_span", parent_root_span or parent_span) - else: - # root span points to itself - set_sentry_meta(span, "root_span", span) - - def _start_profile(self, span): - # type: (Span) -> None - try_autostart_continuous_profiler() - profiler_id = get_profiler_id() - thread_id, thread_name = get_current_thread_meta() - - if profiler_id: - span.set_attribute(SPANDATA.PROFILER_ID, profiler_id) - if thread_id: - span.set_attribute(SPANDATA.THREAD_ID, str(thread_id)) - if thread_name: - span.set_attribute(SPANDATA.THREAD_NAME, thread_name) - - is_root_span = not span.parent or span.parent.is_remote - sampled = span.context and span.context.trace_flags.sampled - - if is_root_span and sampled: - # profiler uses time.perf_counter_ns() so we cannot use the - # unix timestamp that is on span.start_time - # setting it to 0 means the profiler will internally measure time on start - profile = Profile(sampled, 0) - # TODO-neel-potel sampling context?? - profile._set_initial_sampling_decision(sampling_context={}) - profile.__enter__() - set_sentry_meta(span, "profile", profile) - - def _flush_root_span(self, span): - # type: (ReadableSpan) -> None - transaction_event = self._root_span_to_transaction_event(span) - if not transaction_event: - return - - spans = [] - for child in self._collect_children(span): - span_json = self._span_to_json(child) - if span_json: - spans.append(span_json) - transaction_event["spans"] = spans - # TODO-neel-potel sort and cutoff max spans - - capture_event(transaction_event) - - def _collect_children(self, span): - # type: (ReadableSpan) -> List[ReadableSpan] - if not span.context: - return [] - - children = [] - bfs_queue = deque() # type: Deque[int] - bfs_queue.append(span.context.span_id) - - while bfs_queue: - parent_span_id = bfs_queue.popleft() - node_children = self._children_spans.pop(parent_span_id, []) - children.extend(node_children) - bfs_queue.extend( - [child.context.span_id for child in node_children if child.context] - ) - - return children - - # we construct the event from scratch here - # and not use the current Transaction class for easier refactoring - def _root_span_to_transaction_event(self, span): - # type: (ReadableSpan) -> Optional[Event] - if not span.context: - return None - - event = self._common_span_transaction_attributes_as_json(span) - if event is None: - return None - - transaction_name, transaction_source = extract_transaction_name_source(span) - span_data = extract_span_data(span) - trace_context = get_trace_context(span, span_data=span_data) - contexts = {"trace": trace_context} - - profile_context = get_profile_context(span) - if profile_context: - contexts["profile"] = profile_context - - (_, description, _, http_status, _) = span_data - - if http_status: - contexts["response"] = {"status_code": http_status} - - if span.resource.attributes: - contexts[OTEL_SENTRY_CONTEXT] = {"resource": dict(span.resource.attributes)} - - event.update( - { - "type": "transaction", - "transaction": transaction_name or description, - "transaction_info": {"source": transaction_source or "custom"}, - "contexts": contexts, - } - ) - - profile = cast("Optional[Profile]", get_sentry_meta(span, "profile")) - if profile: - profile.__exit__(None, None, None) - if profile.valid(): - event["profile"] = profile - set_sentry_meta(span, "profile", None) - - return event - - def _span_to_json(self, span): - # type: (ReadableSpan) -> Optional[dict[str, Any]] - if not span.context: - return None - - # This is a safe cast because dict[str, Any] is a superset of Event - span_json = cast( - "dict[str, Any]", self._common_span_transaction_attributes_as_json(span) - ) - if span_json is None: - return None - - trace_id = format_trace_id(span.context.trace_id) - span_id = format_span_id(span.context.span_id) - parent_span_id = format_span_id(span.parent.span_id) if span.parent else None - - (op, description, status, _, origin) = extract_span_data(span) - - span_json.update( - { - "trace_id": trace_id, - "span_id": span_id, - "op": op, - "description": description, - "status": status, - "origin": origin or DEFAULT_SPAN_ORIGIN, - } - ) - - if status: - span_json.setdefault("tags", {})["status"] = status - - if parent_span_id: - span_json["parent_span_id"] = parent_span_id - - if span.attributes: - span_json["data"] = dict(span.attributes) - - return span_json - - def _common_span_transaction_attributes_as_json(self, span): - # type: (ReadableSpan) -> Optional[Event] - if not span.start_time or not span.end_time: - return None - - common_json = { - "start_timestamp": convert_from_otel_timestamp(span.start_time), - "timestamp": convert_from_otel_timestamp(span.end_time), - } # type: Event - - measurements = extract_span_attributes(span, SentrySpanAttribute.MEASUREMENT) - if measurements: - common_json["measurements"] = measurements - - tags = extract_span_attributes(span, SentrySpanAttribute.TAG) - if tags: - common_json["tags"] = tags - - return common_json diff --git a/sentry_sdk/integrations/opentelemetry/propagator.py b/sentry_sdk/integrations/opentelemetry/propagator.py index b84d582d6e..0c6eda27a2 100644 --- a/sentry_sdk/integrations/opentelemetry/propagator.py +++ b/sentry_sdk/integrations/opentelemetry/propagator.py @@ -1,7 +1,10 @@ +from typing import cast + from opentelemetry import trace from opentelemetry.context import ( Context, get_current, + get_value, set_value, ) from opentelemetry.propagators.textmap import ( @@ -21,9 +24,7 @@ from sentry_sdk.integrations.opentelemetry.consts import ( SENTRY_BAGGAGE_KEY, SENTRY_TRACE_KEY, -) -from sentry_sdk.integrations.opentelemetry.span_processor import ( - SentrySpanProcessor, + SENTRY_SCOPES_KEY, ) from sentry_sdk.tracing import ( BAGGAGE_HEADER_NAME, @@ -35,6 +36,7 @@ if TYPE_CHECKING: from typing import Optional, Set + from sentry_sdk.integrations.opentelemetry.scope import PotelScope class SentryPropagator(TextMapPropagator): @@ -47,6 +49,7 @@ def extract(self, carrier, context=None, getter=default_getter): if context is None: context = get_current() + # TODO-neel-potel cleanup with continue_trace / isolation_scope sentry_trace = getter.get(carrier, SENTRY_TRACE_HEADER_NAME) if not sentry_trace: return context @@ -89,27 +92,15 @@ def inject(self, carrier, context=None, setter=default_setter): if context is None: context = get_current() - current_span = trace.get_current_span(context) - current_span_context = current_span.get_span_context() - - if not current_span_context.is_valid: - return - - span_id = trace.format_span_id(current_span_context.span_id) - - span_map = SentrySpanProcessor().otel_span_map - sentry_span = span_map.get(span_id, None) - if not sentry_span: - return - - setter.set(carrier, SENTRY_TRACE_HEADER_NAME, sentry_span.to_traceparent()) + scopes = get_value(SENTRY_SCOPES_KEY, context) + if scopes: + scopes = cast("tuple[PotelScope, PotelScope]", scopes) + (current_scope, _) = scopes - if sentry_span.containing_transaction: - baggage = sentry_span.containing_transaction.get_baggage() - if baggage: - baggage_data = baggage.serialize() - if baggage_data: - setter.set(carrier, BAGGAGE_HEADER_NAME, baggage_data) + # TODO-neel-potel check trace_propagation_targets + # TODO-neel-potel test propagator works with twp + for (key, value) in current_scope.iter_trace_propagation_headers(): + setter.set(carrier, key, value) @property def fields(self): diff --git a/sentry_sdk/integrations/opentelemetry/span_processor.py b/sentry_sdk/integrations/opentelemetry/span_processor.py index e33a6afca3..37f27d8cba 100644 --- a/sentry_sdk/integrations/opentelemetry/span_processor.py +++ b/sentry_sdk/integrations/opentelemetry/span_processor.py @@ -1,62 +1,45 @@ -from datetime import datetime, timezone -from time import time -from typing import TYPE_CHECKING, cast +from collections import deque, defaultdict +from typing import cast -from opentelemetry.context import get_value -from opentelemetry.sdk.trace import SpanProcessor, ReadableSpan as OTelSpan from opentelemetry.trace import ( - format_span_id, format_trace_id, + format_span_id, get_current_span, + INVALID_SPAN, + Span as AbstractSpan, ) -from opentelemetry.trace.span import ( - INVALID_SPAN_ID, - INVALID_TRACE_ID, -) -from sentry_sdk.integrations.opentelemetry.consts import ( - SENTRY_BAGGAGE_KEY, - SENTRY_TRACE_KEY, - OTEL_SENTRY_CONTEXT, - SPAN_ORIGIN, +from opentelemetry.context import Context +from opentelemetry.sdk.trace import Span, ReadableSpan, SpanProcessor + +import sentry_sdk +from sentry_sdk.consts import SPANDATA +from sentry_sdk.tracing import DEFAULT_SPAN_ORIGIN +from sentry_sdk.utils import get_current_thread_meta +from sentry_sdk.profiler.continuous_profiler import ( + try_autostart_continuous_profiler, + get_profiler_id, ) +from sentry_sdk.profiler.transaction_profiler import Profile from sentry_sdk.integrations.opentelemetry.utils import ( is_sentry_span, + convert_from_otel_timestamp, + extract_span_attributes, extract_span_data, + extract_transaction_name_source, + get_trace_context, + get_profile_context, + get_sentry_meta, + set_sentry_meta, ) -from sentry_sdk.scope import add_global_event_processor -from sentry_sdk.tracing import Transaction, Span as SentrySpan - +from sentry_sdk.integrations.opentelemetry.consts import ( + OTEL_SENTRY_CONTEXT, + SentrySpanAttribute, +) +from sentry_sdk._types import TYPE_CHECKING if TYPE_CHECKING: - from typing import Any, Optional, Union - from opentelemetry import context as context_api - from sentry_sdk._types import Event, Hint - -SPAN_MAX_TIME_OPEN_MINUTES = 10 - - -def link_trace_context_to_error_event(event, otel_span_map): - # type: (Event, dict[str, Union[Transaction, SentrySpan]]) -> Event - if hasattr(event, "type") and event["type"] == "transaction": - return event - - otel_span = get_current_span() - if not otel_span: - return event - - ctx = otel_span.get_span_context() - - if ctx.trace_id == INVALID_TRACE_ID or ctx.span_id == INVALID_SPAN_ID: - return event - - sentry_span = otel_span_map.get(format_span_id(ctx.span_id), None) - if not sentry_span: - return event - - contexts = event.setdefault("contexts", {}) - contexts.setdefault("trace", {}).update(sentry_span.get_trace_context()) - - return event + from typing import Optional, List, Any, Deque, DefaultDict + from sentry_sdk._types import Event class SentrySpanProcessor(SpanProcessor): @@ -64,12 +47,6 @@ 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]] - - # The currently open spans. Elements will be discarded after SPAN_MAX_TIME_OPEN_MINUTES - open_spans = {} # type: dict[int, set[str]] - def __new__(cls): # type: () -> SentrySpanProcessor if not hasattr(cls, "instance"): @@ -79,200 +56,218 @@ def __new__(cls): def __init__(self): # type: () -> None - @add_global_event_processor - def global_event_processor(event, hint): - # type: (Event, Hint) -> Event - return link_trace_context_to_error_event(event, self.otel_span_map) + self._children_spans = defaultdict( + list + ) # type: DefaultDict[int, List[ReadableSpan]] - def _prune_old_spans(self): - # type: (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, parent_context=None): - # type: (OTelSpan, Optional[context_api.Context]) -> None - from sentry_sdk import get_client, start_transaction - - client = get_client() - - if not client.dsn: + def on_start(self, span, parent_context=None): + # type: (Span, Optional[Context]) -> None + if is_sentry_span(span): return - if not otel_span.get_span_context().is_valid: - return + self._add_root_span(span, get_current_span(parent_context)) + self._start_profile(span) - if is_sentry_span(otel_span): + def on_end(self, span): + # type: (ReadableSpan) -> None + if is_sentry_span(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) 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"], - name=otel_span.name, - start_timestamp=start_timestamp, - origin=SPAN_ORIGIN, - ) + is_root_span = not span.parent or span.parent.is_remote + if is_root_span: + # if have a root span ending, we build a transaction and send it + self._flush_root_span(span) else: - 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=start_timestamp, - origin=SPAN_ORIGIN, - ) - - self.otel_span_map[trace_data["span_id"]] = sentry_span + self._children_spans[span.parent.span_id].append(span) - 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"] - ) - - self._prune_old_spans() + # TODO-neel-potel not sure we need a clear like JS + def shutdown(self): + # type: () -> None + pass - def on_end(self, otel_span): - # type: (OTelSpan) -> None - span_context = otel_span.get_span_context() - if not span_context.is_valid: - return + # TODO-neel-potel change default? this is 30 sec + # TODO-neel-potel call this in client.flush + def force_flush(self, timeout_millis=30000): + # type: (int) -> bool + return True - span_id = format_span_id(span_context.span_id) - sentry_span = self.otel_span_map.pop(span_id, None) - if not sentry_span: + def _add_root_span(self, span, parent_span): + # type: (Span, AbstractSpan) -> None + """ + This is required to make POTelSpan.root_span work + since we can't traverse back to the root purely with otel efficiently. + """ + if parent_span != INVALID_SPAN and not parent_span.get_span_context().is_remote: + # child span points to parent's root or parent + parent_root_span = get_sentry_meta(parent_span, "root_span") + set_sentry_meta(span, "root_span", parent_root_span or parent_span) + else: + # root span points to itself + set_sentry_meta(span, "root_span", span) + + def _start_profile(self, span): + # type: (Span) -> None + try_autostart_continuous_profiler() + profiler_id = get_profiler_id() + thread_id, thread_name = get_current_thread_meta() + + if profiler_id: + span.set_attribute(SPANDATA.PROFILER_ID, profiler_id) + if thread_id: + span.set_attribute(SPANDATA.THREAD_ID, str(thread_id)) + if thread_name: + span.set_attribute(SPANDATA.THREAD_NAME, thread_name) + + is_root_span = not span.parent or span.parent.is_remote + sampled = span.context and span.context.trace_flags.sampled + + if is_root_span and sampled: + # profiler uses time.perf_counter_ns() so we cannot use the + # unix timestamp that is on span.start_time + # setting it to 0 means the profiler will internally measure time on start + profile = Profile(sampled, 0) + # TODO-neel-potel sampling context?? + profile._set_initial_sampling_decision(sampling_context={}) + profile.__enter__() + set_sentry_meta(span, "profile", profile) + + def _flush_root_span(self, span): + # type: (ReadableSpan) -> None + transaction_event = self._root_span_to_transaction_event(span) + if not transaction_event: return - sentry_span.op = otel_span.name - - if isinstance(sentry_span, Transaction): - sentry_span.name = otel_span.name - sentry_span.set_context( - OTEL_SENTRY_CONTEXT, self._get_otel_context(otel_span) + spans = [] + for child in self._collect_children(span): + span_json = self._span_to_json(child) + if span_json: + spans.append(span_json) + transaction_event["spans"] = spans + # TODO-neel-potel sort and cutoff max spans + + sentry_sdk.capture_event(transaction_event) + + def _collect_children(self, span): + # type: (ReadableSpan) -> List[ReadableSpan] + if not span.context: + return [] + + children = [] + bfs_queue = deque() # type: Deque[int] + bfs_queue.append(span.context.span_id) + + while bfs_queue: + parent_span_id = bfs_queue.popleft() + node_children = self._children_spans.pop(parent_span_id, []) + children.extend(node_children) + bfs_queue.extend( + [child.context.span_id for child in node_children if child.context] ) - self._update_transaction_with_otel_data(sentry_span, otel_span) - - else: - self._update_span_with_otel_data(sentry_span, otel_span) - 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 + return children - sentry_span.finish(end_timestamp=end_timestamp) + # we construct the event from scratch here + # and not use the current Transaction class for easier refactoring + def _root_span_to_transaction_event(self, span): + # type: (ReadableSpan) -> Optional[Event] + if not span.context: + return None - 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) + event = self._common_span_transaction_attributes_as_json(span) + if event is None: + return None - self._prune_old_spans() + transaction_name, transaction_source = extract_transaction_name_source(span) + span_data = extract_span_data(span) + trace_context = get_trace_context(span, span_data=span_data) + contexts = {"trace": trace_context} - def _get_otel_context(self, otel_span): - # type: (OTelSpan) -> dict[str, Any] - """ - Returns the OTel context for Sentry. - See: https://develop.sentry.dev/sdk/performance/opentelemetry/#step-5-add-opentelemetry-context - """ - ctx = {} + profile_context = get_profile_context(span) + if profile_context: + contexts["profile"] = profile_context - if otel_span.attributes: - ctx["attributes"] = dict(otel_span.attributes) + (_, description, _, http_status, _) = span_data - if otel_span.resource.attributes: - ctx["resource"] = dict(otel_span.resource.attributes) - - return ctx + if http_status: + contexts["response"] = {"status_code": http_status} + + if span.resource.attributes: + contexts[OTEL_SENTRY_CONTEXT] = {"resource": dict(span.resource.attributes)} + + event.update( + { + "type": "transaction", + "transaction": transaction_name or description, + "transaction_info": {"source": transaction_source or "custom"}, + "contexts": contexts, + } + ) - def _get_trace_data(self, otel_span, parent_context): - # type: (OTelSpan, Optional[context_api.Context]) -> dict[str, Any] - """ - Extracts tracing information from one OTel span and its parent OTel context. - """ - trace_data = {} # type: dict[str, Any] - span_context = otel_span.get_span_context() + profile = cast("Optional[Profile]", get_sentry_meta(span, "profile")) + if profile: + profile.__exit__(None, None, None) + if profile.valid(): + event["profile"] = profile + set_sentry_meta(span, "profile", None) - span_id = format_span_id(span_context.span_id) - trace_data["span_id"] = span_id + return event - trace_id = format_trace_id(span_context.trace_id) - trace_data["trace_id"] = trace_id + def _span_to_json(self, span): + # type: (ReadableSpan) -> Optional[dict[str, Any]] + if not span.context: + return None - parent_span_id = ( - format_span_id(otel_span.parent.span_id) if otel_span.parent else None + # This is a safe cast because dict[str, Any] is a superset of Event + span_json = cast( + "dict[str, Any]", self._common_span_transaction_attributes_as_json(span) ) - 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 + if span_json is None: + return None + + trace_id = format_trace_id(span.context.trace_id) + span_id = format_span_id(span.context.span_id) + parent_span_id = format_span_id(span.parent.span_id) if span.parent else None + + (op, description, status, _, origin) = extract_span_data(span) + + span_json.update( + { + "trace_id": trace_id, + "span_id": span_id, + "op": op, + "description": description, + "status": status, + "origin": origin or DEFAULT_SPAN_ORIGIN, + } ) - baggage = get_value(SENTRY_BAGGAGE_KEY, parent_context) - trace_data["baggage"] = baggage + if status: + span_json.setdefault("tags", {})["status"] = status - return trace_data + if parent_span_id: + span_json["parent_span_id"] = parent_span_id - def _update_span_with_otel_data(self, sentry_span, otel_span): - # type: (SentrySpan, OTelSpan) -> None - """ - Convert OTel span data and update the Sentry span with it. - This should eventually happen on the server when ingesting the spans. - """ - sentry_span.set_data("otel.kind", otel_span.kind) + if span.attributes: + span_json["data"] = dict(span.attributes) - if otel_span.attributes is not None: - for key, val in otel_span.attributes.items(): - sentry_span.set_data(key, val) + return span_json - (op, description, status, http_status, _) = extract_span_data(otel_span) - sentry_span.op = op - sentry_span.description = description + def _common_span_transaction_attributes_as_json(self, span): + # type: (ReadableSpan) -> Optional[Event] + if not span.start_time or not span.end_time: + return None - if http_status: - sentry_span.set_http_status(http_status) - elif status: - sentry_span.set_status(status) + common_json = { + "start_timestamp": convert_from_otel_timestamp(span.start_time), + "timestamp": convert_from_otel_timestamp(span.end_time), + } # type: Event - def _update_transaction_with_otel_data(self, sentry_span, otel_span): - # type: (SentrySpan, OTelSpan) -> None - (op, _, status, http_status, _) = extract_span_data(otel_span) - sentry_span.op = op + measurements = extract_span_attributes(span, SentrySpanAttribute.MEASUREMENT) + if measurements: + common_json["measurements"] = measurements - if http_status: - sentry_span.set_http_status(http_status) - elif status: - sentry_span.set_status(status) + tags = extract_span_attributes(span, SentrySpanAttribute.TAG) + if tags: + common_json["tags"] = tags + + return common_json diff --git a/tests/integrations/asyncio/test_asyncio.py b/tests/integrations/asyncio/test_asyncio.py index f122f1b804..2ae71f8f43 100644 --- a/tests/integrations/asyncio/test_asyncio.py +++ b/tests/integrations/asyncio/test_asyncio.py @@ -15,8 +15,8 @@ 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" ) @@ -38,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. @@ -57,12 +49,11 @@ 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, @@ -76,10 +67,10 @@ async def test_create_task( with sentry_sdk.start_span(name="test_transaction_for_create_task"): with sentry_sdk.start_span(op="root", name="not so important"): - tasks = [event_loop.create_task(foo()), event_loop.create_task(bar())] + tasks = [asyncio.create_task(foo()), asyncio.create_task(bar())] await asyncio.wait(tasks, return_when=asyncio.FIRST_EXCEPTION) - sentry_sdk.flush() + sentry_sdk.flush() (transaction_event,) = events @@ -101,8 +92,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, @@ -121,7 +112,7 @@ async def test_gather( 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 @@ -143,12 +134,11 @@ 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, @@ -163,10 +153,10 @@ async def test_exception( with sentry_sdk.start_span(name="test_exception"): sentry_sdk.get_isolation_scope().set_transaction_name("test_exception") with sentry_sdk.start_span(op="root", name="not so important"): - tasks = [event_loop.create_task(boom()), event_loop.create_task(bar())] + 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 @@ -178,8 +168,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=[ @@ -195,7 +185,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+ @@ -224,7 +214,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): """ @@ -243,7 +233,7 @@ def test_patch_asyncio(mock_get_running_loop): assert callable(sentry_task_factory) -@minimum_python_37 +@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 @@ -272,7 +262,7 @@ def test_sentry_task_factory_no_factory(MockTask, mock_get_running_loop): # noq assert task_kwargs["loop"] == mock_loop -@minimum_python_37 +@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 @@ -362,12 +352,11 @@ def test_sentry_task_factory_context_with_factory(mock_get_running_loop): assert task_factory_kwargs["context"] == mock_context -@minimum_python_37 -@pytest.mark.asyncio +@minimum_python_38 +@pytest.mark.asyncio(loop_scope="module") async def test_span_origin( sentry_init, capture_events, - event_loop, ): sentry_init( integrations=[AsyncioIntegration()], @@ -378,11 +367,11 @@ async def test_span_origin( with sentry_sdk.start_span(name="something"): tasks = [ - event_loop.create_task(foo()), + asyncio.create_task(foo()), ] await asyncio.wait(tasks, return_when=asyncio.FIRST_EXCEPTION) - sentry_sdk.flush() + sentry_sdk.flush() (event,) = events diff --git a/tests/integrations/grpc/test_grpc_aio.py b/tests/integrations/grpc/test_grpc_aio.py index fff22626d9..9ce9aef6a5 100644 --- a/tests/integrations/grpc/test_grpc_aio.py +++ b/tests/integrations/grpc/test_grpc_aio.py @@ -21,22 +21,14 @@ AIO_PORT += os.getpid() % 100 # avoid port conflicts when running tests in parallel -@pytest.fixture(scope="function") -def event_loop(request): - """Create an instance of the default event loop for each test case.""" - loop = asyncio.new_event_loop() - yield loop - loop.close() - - @pytest_asyncio.fixture(scope="function") -async def grpc_server(sentry_init, event_loop): +async def grpc_server(sentry_init): sentry_init(traces_sample_rate=1.0, integrations=[GRPCIntegration()]) server = grpc.aio.server() server.add_insecure_port("[::]:{}".format(AIO_PORT)) add_gRPCTestServiceServicer_to_server(TestService, server) - await event_loop.create_task(server.start()) + await asyncio.create_task(server.start()) try: yield server @@ -45,12 +37,12 @@ async def grpc_server(sentry_init, event_loop): @pytest.mark.asyncio -async def test_noop_for_unimplemented_method(event_loop, sentry_init, capture_events): +async def test_noop_for_unimplemented_method(sentry_init, capture_events): sentry_init(traces_sample_rate=1.0, integrations=[GRPCIntegration()]) server = grpc.aio.server() server.add_insecure_port("[::]:{}".format(AIO_PORT)) - await event_loop.create_task(server.start()) + await asyncio.create_task(server.start()) events = capture_events() try: diff --git a/tests/integrations/opentelemetry/test_propagator.py b/tests/integrations/opentelemetry/test_propagator.py index d999b0bb2b..b318dccdf7 100644 --- a/tests/integrations/opentelemetry/test_propagator.py +++ b/tests/integrations/opentelemetry/test_propagator.py @@ -3,14 +3,10 @@ from unittest import mock from unittest.mock import MagicMock -from opentelemetry.context import get_current -from opentelemetry.trace import ( - SpanContext, - TraceFlags, - set_span_in_context, -) from opentelemetry.trace.propagation import get_current_span +from opentelemetry.propagators.textmap import DefaultSetter +import sentry_sdk from sentry_sdk.integrations.opentelemetry.consts import ( SENTRY_BAGGAGE_KEY, SENTRY_TRACE_KEY, @@ -123,178 +119,44 @@ 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. - So there is no sentry_span to be found in inject() - and the function is returned early and no setters are called. - """ - carrier = None - context = get_current() - setter = MagicMock() - setter.set = MagicMock() - - span_context = SpanContext( - trace_id=int("1234567890abcdef1234567890abcdef", 16), - span_id=int("1234567890abcdef", 16), - trace_flags=TraceFlags(TraceFlags.SAMPLED), - is_remote=True, - ) - span = MagicMock() - span.get_span_context.return_value = span_context - - 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_not_called() - - -@pytest.mark.forked -def test_inject_sentry_span_no_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=None) - - 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_continue_trace(sentry_init, SortedBaggage): + sentry_init(traces_sample_rate=1.0) + carrier = {} + setter = DefaultSetter() -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, + trace_id = "771a43a4192642f0b136d5159a501700" + sentry_trace = "771a43a4192642f0b136d5159a501700-1234567890abcdef-1" + baggage = ( + "sentry-trace_id=771a43a4192642f0b136d5159a501700," + "sentry-public_key=frontendpublickey," + "sentry-sample_rate=0.01337," + "sentry-sampled=true," + "sentry-release=myfrontend," + "sentry-environment=bird," + "sentry-transaction=bar" ) - span = MagicMock() - span.get_span_context.return_value = span_context + incoming_headers = { + "HTTP_SENTRY_TRACE": sentry_trace, + "HTTP_BAGGAGE": baggage, + } - 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", - ) + with sentry_sdk.continue_trace(incoming_headers): + with sentry_sdk.start_span(name="foo") as span: + SentryPropagator().inject(carrier, setter=setter) + assert(carrier["sentry-trace"]) == f"{trace_id}-{span.span_id}-1" + assert(carrier["baggage"]) == SortedBaggage(baggage) -def test_inject_sentry_span_baggage(): - """ - Inject a sentry span with 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 +def test_inject_head_sdk(sentry_init, SortedBaggage): + sentry_init(traces_sample_rate=1.0, release="release") - sentry_span = MagicMock() - sentry_span.to_traceparent = mock.Mock( - return_value="1234567890abcdef1234567890abcdef-1234567890abcdef-1" - ) - sentry_items = { - "sentry-trace_id": "771a43a4192642f0b136d5159a501700", - "sentry-public_key": "49d0f7386ad645858ae85020e393bef3", - "sentry-sample_rate": 0.01337, - "sentry-user_id": "Amélie", - } - baggage = Baggage(sentry_items=sentry_items) - sentry_span.containing_transaction.get_baggage = MagicMock(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_any_call( - carrier, - "sentry-trace", - "1234567890abcdef1234567890abcdef-1234567890abcdef-1", - ) + carrier = {} + setter = DefaultSetter() - setter.set.assert_any_call( - carrier, - "baggage", - baggage.serialize(), + with sentry_sdk.start_span(name="foo") as span: + SentryPropagator().inject(carrier, setter=setter) + assert(carrier["sentry-trace"]) == f"{span.trace_id}-{span.span_id}-1" + assert(carrier["baggage"]) == SortedBaggage( + f"sentry-transaction=foo,sentry-release=release,sentry-environment=production,sentry-trace_id={span.trace_id},sentry-sample_rate=1.0,sentry-sampled=true" ) diff --git a/tests/integrations/opentelemetry/test_span_processor.py b/tests/integrations/opentelemetry/test_span_processor.py deleted file mode 100644 index f37f4a619d..0000000000 --- a/tests/integrations/opentelemetry/test_span_processor.py +++ /dev/null @@ -1,576 +0,0 @@ -import time -from datetime import datetime, timezone -from unittest import mock -from unittest.mock import MagicMock - -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.integrations.opentelemetry.utils import is_sentry_span -from sentry_sdk.tracing import Span, Transaction -from sentry_sdk.tracing_utils import extract_sentrytrace_data - - -def test_is_sentry_span(): - otel_span = MagicMock() - - assert not is_sentry_span(otel_span) - - client = MagicMock() - client.dsn = "https://1234567890abcdef@o123456.ingest.sentry.io/123456" - sentry_sdk.get_global_scope().set_client(client) - - assert not is_sentry_span(otel_span) - - otel_span.attributes = { - "http.url": "https://example.com", - } - assert not is_sentry_span(otel_span) - - otel_span.attributes = { - "http.url": "https://o123456.ingest.sentry.io/api/123/envelope", - } - assert is_sentry_span(otel_span) - - -def test_get_otel_context(): - otel_span = MagicMock() - otel_span.attributes = {"foo": "bar"} - otel_span.resource = MagicMock() - otel_span.resource.attributes = {"baz": "qux"} - - span_processor = SentrySpanProcessor() - otel_context = span_processor._get_otel_context(otel_span) - - assert otel_context == { - "attributes": {"foo": "bar"}, - "resource": {"baz": "qux"}, - } - - -def test_get_trace_data_with_span_and_trace(): - otel_span = MagicMock() - 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 = None - - parent_context = {} - - span_processor = SentrySpanProcessor() - sentry_trace_data = span_processor._get_trace_data(otel_span, parent_context) - assert sentry_trace_data["trace_id"] == "1234567890abcdef1234567890abcdef" - assert sentry_trace_data["span_id"] == "1234567890abcdef" - assert sentry_trace_data["parent_span_id"] is None - assert sentry_trace_data["parent_sampled"] is None - assert sentry_trace_data["baggage"] is None - - -def test_get_trace_data_with_span_and_trace_and_parent(): - otel_span = MagicMock() - 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 = {} - - span_processor = SentrySpanProcessor() - sentry_trace_data = span_processor._get_trace_data(otel_span, parent_context) - assert sentry_trace_data["trace_id"] == "1234567890abcdef1234567890abcdef" - assert sentry_trace_data["span_id"] == "1234567890abcdef" - assert sentry_trace_data["parent_span_id"] == "abcdef1234567890" - assert sentry_trace_data["parent_sampled"] is None - assert sentry_trace_data["baggage"] is None - - -def test_get_trace_data_with_sentry_trace(): - otel_span = MagicMock() - 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 = {} - - with mock.patch( - "sentry_sdk.integrations.opentelemetry.span_processor.get_value", - side_effect=[ - extract_sentrytrace_data( - "1234567890abcdef1234567890abcdef-1234567890abcdef-1" - ), - None, - ], - ): - span_processor = SentrySpanProcessor() - sentry_trace_data = span_processor._get_trace_data(otel_span, parent_context) - assert sentry_trace_data["trace_id"] == "1234567890abcdef1234567890abcdef" - assert sentry_trace_data["span_id"] == "1234567890abcdef" - assert sentry_trace_data["parent_span_id"] == "abcdef1234567890" - assert sentry_trace_data["parent_sampled"] is True - assert sentry_trace_data["baggage"] is None - - with mock.patch( - "sentry_sdk.integrations.opentelemetry.span_processor.get_value", - side_effect=[ - extract_sentrytrace_data( - "1234567890abcdef1234567890abcdef-1234567890abcdef-0" - ), - None, - ], - ): - span_processor = SentrySpanProcessor() - sentry_trace_data = span_processor._get_trace_data(otel_span, parent_context) - assert sentry_trace_data["trace_id"] == "1234567890abcdef1234567890abcdef" - assert sentry_trace_data["span_id"] == "1234567890abcdef" - assert sentry_trace_data["parent_span_id"] == "abcdef1234567890" - assert sentry_trace_data["parent_sampled"] is False - assert sentry_trace_data["baggage"] is None - - -def test_get_trace_data_with_sentry_trace_and_baggage(): - otel_span = MagicMock() - 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 = {} - - baggage = ( - "sentry-trace_id=771a43a4192642f0b136d5159a501700," - "sentry-public_key=49d0f7386ad645858ae85020e393bef3," - "sentry-sample_rate=0.01337,sentry-user_id=Am%C3%A9lie" - ) - - with mock.patch( - "sentry_sdk.integrations.opentelemetry.span_processor.get_value", - side_effect=[ - extract_sentrytrace_data( - "1234567890abcdef1234567890abcdef-1234567890abcdef-1" - ), - baggage, - ], - ): - span_processor = SentrySpanProcessor() - sentry_trace_data = span_processor._get_trace_data(otel_span, parent_context) - assert sentry_trace_data["trace_id"] == "1234567890abcdef1234567890abcdef" - assert sentry_trace_data["span_id"] == "1234567890abcdef" - assert sentry_trace_data["parent_span_id"] == "abcdef1234567890" - assert sentry_trace_data["parent_sampled"] - assert sentry_trace_data["baggage"] == baggage - - -def test_update_span_with_otel_data_http_method(): - sentry_span = Span() - - otel_span = MagicMock() - otel_span.name = "Test OTel Span" - otel_span.kind = SpanKind.CLIENT - otel_span.attributes = { - "http.method": "GET", - "http.status_code": 429, - "http.status_text": "xxx", - "http.user_agent": "curl/7.64.1", - "net.peer.name": "example.com", - "http.target": "/", - } - - span_processor = SentrySpanProcessor() - span_processor._update_span_with_otel_data(sentry_span, otel_span) - - assert sentry_span.op == "http.client" - assert sentry_span.description == "GET /" - assert sentry_span.status == "resource_exhausted" - - assert sentry_span._data["http.method"] == "GET" - assert sentry_span._data["http.response.status_code"] == 429 - assert sentry_span._data["http.status_text"] == "xxx" - assert sentry_span._data["http.user_agent"] == "curl/7.64.1" - assert sentry_span._data["net.peer.name"] == "example.com" - assert sentry_span._data["http.target"] == "/" - - -def test_update_span_with_otel_data_http_method2(): - sentry_span = Span() - - otel_span = MagicMock() - otel_span.name = "Test OTel Span" - otel_span.kind = SpanKind.SERVER - otel_span.attributes = { - "http.method": "GET", - "http.status_code": 429, - "http.status_text": "xxx", - "http.user_agent": "curl/7.64.1", - "http.url": "https://example.com/status/403?password=123&username=test@example.com&author=User123&auth=1234567890abcdef", - } - - span_processor = SentrySpanProcessor() - span_processor._update_span_with_otel_data(sentry_span, otel_span) - - assert sentry_span.op == "http.server" - assert sentry_span.description == "GET https://example.com/status/403" - assert sentry_span.status == "resource_exhausted" - - assert sentry_span._data["http.method"] == "GET" - assert sentry_span._data["http.response.status_code"] == 429 - assert sentry_span._data["http.status_text"] == "xxx" - assert sentry_span._data["http.user_agent"] == "curl/7.64.1" - assert ( - sentry_span._data["http.url"] - == "https://example.com/status/403?password=123&username=test@example.com&author=User123&auth=1234567890abcdef" - ) - - -def test_update_span_with_otel_data_db_query(): - sentry_span = Span() - - otel_span = MagicMock() - otel_span.name = "Test OTel Span" - otel_span.attributes = { - "db.system": "postgresql", - "db.statement": "SELECT * FROM table where pwd = '123456'", - } - - span_processor = SentrySpanProcessor() - span_processor._update_span_with_otel_data(sentry_span, otel_span) - - assert sentry_span.op == "db" - assert sentry_span.description == "SELECT * FROM table where pwd = '123456'" - - assert sentry_span._data["db.system"] == "postgresql" - assert ( - sentry_span._data["db.statement"] == "SELECT * FROM table where pwd = '123456'" - ) - - -def test_on_start_transaction(): - 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_start_transaction = MagicMock() - - fake_client = MagicMock() - fake_client.dsn = "https://1234567890abcdef@o123456.ingest.sentry.io/123456" - sentry_sdk.get_global_scope().set_client(fake_client) - - with mock.patch( - "sentry_sdk.integrations.opentelemetry.span_processor.start_transaction", - fake_start_transaction, - ): - span_processor = SentrySpanProcessor() - span_processor.on_start(otel_span, parent_context) - - 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, timezone.utc - ), - origin="auto.otel", - ) - - assert len(span_processor.otel_span_map.keys()) == 1 - assert list(span_processor.otel_span_map.keys())[0] == "1234567890abcdef" - - -def test_on_start_child(): - 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.dsn = "https://1234567890abcdef@o123456.ingest.sentry.io/123456" - sentry_sdk.get_global_scope().set_client(fake_client) - - 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", - name="Sample OTel Span", - start_timestamp=datetime.fromtimestamp( - otel_span.start_time / 1e9, timezone.utc - ), - 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() - - -def test_on_end_no_sentry_span(): - """ - If on_end is called on a span that is not in the otel_span_map, it should be a no-op. - """ - otel_span = MagicMock() - otel_span.name = "Sample OTel Span" - otel_span.end_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 - - span_processor = SentrySpanProcessor() - span_processor.otel_span_map = {} - span_processor._get_otel_context = MagicMock() - span_processor._update_span_with_otel_data = MagicMock() - - span_processor.on_end(otel_span) - - span_processor._get_otel_context.assert_not_called() - span_processor._update_span_with_otel_data.assert_not_called() - - -def test_on_end_sentry_transaction(): - """ - Test on_end for a sentry Transaction. - """ - otel_span = MagicMock() - otel_span.name = "Sample OTel Span" - otel_span.end_time = time.time_ns() - otel_span.status = Status(StatusCode.OK) - 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 - - fake_client = MagicMock() - 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() - - span_processor = SentrySpanProcessor() - span_processor._get_otel_context = MagicMock() - span_processor._update_span_with_otel_data = MagicMock() - span_processor._update_transaction_with_otel_data = MagicMock() - span_processor.otel_span_map["1234567890abcdef"] = fake_sentry_span - - span_processor.on_end(otel_span) - - fake_sentry_span.set_context.assert_called_once() - span_processor._update_span_with_otel_data.assert_not_called() - span_processor._update_transaction_with_otel_data.assert_called_once() - fake_sentry_span.finish.assert_called_once() - - -def test_on_end_sentry_span(): - """ - Test on_end for a sentry Span. - """ - otel_span = MagicMock() - otel_span.name = "Sample OTel Span" - otel_span.end_time = time.time_ns() - otel_span.status = Status(StatusCode.OK) - 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 - - fake_client = MagicMock() - 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["1234567890abcdef"] = fake_sentry_span - - span_processor.on_end(otel_span) - - fake_sentry_span.set_context.assert_not_called() - span_processor._update_span_with_otel_data.assert_called_once_with( - fake_sentry_span, otel_span - ) - fake_sentry_span.finish.assert_called_once() - - -def test_link_trace_context_to_error_event(): - """ - Test that the trace context is added to the error event. - """ - fake_client = MagicMock() - sentry_sdk.get_global_scope().set_client(fake_client) - - span_id = "1234567890abcdef" - trace_id = "1234567890abcdef1234567890abcdef" - - fake_trace_context = { - "bla": "blub", - "foo": "bar", - "baz": 123, - } - - sentry_span = MagicMock() - sentry_span.get_trace_context = MagicMock(return_value=fake_trace_context) - - otel_span_map = { - span_id: sentry_span, - } - - span_context = SpanContext( - trace_id=int(trace_id, 16), - span_id=int(span_id, 16), - is_remote=True, - ) - otel_span = MagicMock() - otel_span.get_span_context = MagicMock(return_value=span_context) - - fake_event = {"event_id": "1234567890abcdef1234567890abcdef"} - - with mock.patch( - "sentry_sdk.integrations.opentelemetry.span_processor.get_current_span", - return_value=otel_span, - ): - event = link_trace_context_to_error_event(fake_event, otel_span_map) - - assert event - assert event == fake_event # the event is changed in place inside the function - 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 = {"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() - 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/tox.ini b/tox.ini index 668247d5b3..60036adf98 100644 --- a/tox.ini +++ b/tox.ini @@ -33,7 +33,7 @@ envlist = {py3.8,py3.12,py3.13}-aiohttp-latest # Anthropic - {py3.7,py3.11,py3.12}-anthropic-v{0.16,0.25} + {py3.8,py3.11,py3.12}-anthropic-v{0.16,0.28,0.40} {py3.7,py3.11,py3.12}-anthropic-latest # Ariadne @@ -164,15 +164,14 @@ envlist = # Langchain {py3.9,py3.11,py3.12}-langchain-v0.1 + {py3.9,py3.11,py3.12}-langchain-v0.3 {py3.9,py3.11,py3.12}-langchain-latest {py3.9,py3.11,py3.12}-langchain-notiktoken # Litestar - # litestar 2.0.0 is the earliest version that supports Python < 3.12 {py3.8,py3.11}-litestar-v{2.0} - # litestar 2.3.0 is the earliest version that supports Python 3.12 - {py3.12}-litestar-v{2.3} - {py3.8,py3.11,py3.12}-litestar-v{2.5} + {py3.8,py3.11,py3.12}-litestar-v{2.6} + {py3.8,py3.11,py3.12}-litestar-v{2.12} {py3.8,py3.11,py3.12}-litestar-latest # Loguru @@ -180,7 +179,9 @@ envlist = {py3.7,py3.12,py3.13}-loguru-latest # OpenAI - {py3.9,py3.11,py3.12}-openai-v1 + {py3.9,py3.11,py3.12}-openai-v1.0 + {py3.9,py3.11,py3.12}-openai-v1.22 + {py3.9,py3.11,py3.12}-openai-v1.55 {py3.9,py3.11,py3.12}-openai-latest {py3.9,py3.11,py3.12}-openai-notiktoken @@ -255,8 +256,8 @@ envlist = # Starlette {py3.7,py3.10}-starlette-v{0.19} - {py3.7,py3.11}-starlette-v{0.20,0.24,0.28} - {py3.8,py3.11,py3.12}-starlette-v{0.32,0.36} + {py3.7,py3.11}-starlette-v{0.24,0.28} + {py3.8,py3.11,py3.12}-starlette-v{0.32,0.36,0.40} {py3.8,py3.12,py3.13}-starlette-latest # Starlite @@ -324,8 +325,10 @@ deps = # Anthropic anthropic: pytest-asyncio - anthropic-v0.25: anthropic~=0.25.0 + anthropic-v{0.16,0.28}: httpx<0.28.0 anthropic-v0.16: anthropic~=0.16.0 + anthropic-v0.28: anthropic~=0.28.0 + anthropic-v0.40: anthropic~=0.40.0 anthropic-latest: anthropic # Ariadne @@ -402,6 +405,7 @@ deps = django: psycopg2-binary django-v{1.11,2.0,2.1,2.2,3.0,3.1,3.2}: djangorestframework>=3.0.0,<4.0.0 django-v{2.0,2.2,3.0,3.2,4.0,4.1,4.2,5.0,5.1}: channels[daphne] + django-v{2.2,3.0}: six django-v{1.11,2.0,2.2,3.0,3.2}: Werkzeug<2.1.0 django-v{1.11,2.0,2.2,3.0}: pytest-django<4.0 django-v{3.2,4.0,4.1,4.2,5.0,5.1}: pytest-django @@ -515,22 +519,25 @@ deps = langchain-v0.1: openai~=1.0.0 langchain-v0.1: langchain~=0.1.11 langchain-v0.1: tiktoken~=0.6.0 - langchain-latest: langchain - langchain-latest: langchain-openai - langchain-latest: openai>=1.6.1 + langchain-v0.1: httpx<0.28.0 + langchain-v0.3: langchain~=0.3.0 + langchain-v0.3: langchain-community + langchain-v0.3: tiktoken + langchain-v0.3: openai + langchain-{latest,notiktoken}: langchain + langchain-{latest,notiktoken}: langchain-openai + langchain-{latest,notiktoken}: openai>=1.6.1 langchain-latest: tiktoken~=0.6.0 - langchain-notiktoken: langchain - langchain-notiktoken: langchain-openai - langchain-notiktoken: openai>=1.6.1 # Litestar litestar: pytest-asyncio litestar: python-multipart litestar: requests litestar: cryptography + litestar-v{2.0,2.6}: httpx<0.28 litestar-v2.0: litestar~=2.0.0 - litestar-v2.3: litestar~=2.3.0 - litestar-v2.5: litestar~=2.5.0 + litestar-v2.6: litestar~=2.6.0 + litestar-v2.12: litestar~=2.12.0 litestar-latest: litestar # Loguru @@ -539,8 +546,14 @@ deps = # OpenAI openai: pytest-asyncio - openai-v1: openai~=1.0.0 - openai-v1: tiktoken~=0.6.0 + openai-v1.0: openai~=1.0.0 + openai-v1.0: tiktoken + openai-v1.0: httpx<0.28.0 + openai-v1.22: openai~=1.22.0 + openai-v1.22: tiktoken + openai-v1.22: httpx<0.28.0 + openai-v1.55: openai~=1.55.0 + openai-v1.55: tiktoken openai-latest: openai openai-latest: tiktoken~=0.6.0 openai-notiktoken: openai @@ -649,16 +662,18 @@ deps = starlette: pytest-asyncio starlette: python-multipart starlette: requests - starlette: httpx # (this is a dependency of httpx) starlette: anyio<4.0.0 starlette: jinja2 + starlette-v{0.19,0.24,0.28,0.32,0.36}: httpx<0.28.0 + starlette-v0.40: httpx + starlette-latest: httpx starlette-v0.19: starlette~=0.19.0 - starlette-v0.20: starlette~=0.20.0 starlette-v0.24: starlette~=0.24.0 starlette-v0.28: starlette~=0.28.0 starlette-v0.32: starlette~=0.32.0 starlette-v0.36: starlette~=0.36.0 + starlette-v0.40: starlette~=0.40.0 starlette-latest: starlette # Starlite @@ -667,6 +682,7 @@ deps = starlite: requests starlite: cryptography starlite: pydantic<2.0.0 + starlite: httpx<0.28 starlite-v{1.48}: starlite~=1.48.0 starlite-v{1.51}: starlite~=1.51.0