diff --git a/packages/aws-durable-execution-sdk-python-examples/src/otel/otel_logger_example.py b/packages/aws-durable-execution-sdk-python-examples/src/otel/otel_logger_example.py index a727bf73..fe74d7e3 100644 --- a/packages/aws-durable-execution-sdk-python-examples/src/otel/otel_logger_example.py +++ b/packages/aws-durable-execution-sdk-python-examples/src/otel/otel_logger_example.py @@ -16,7 +16,11 @@ from typing import Any -from aws_durable_execution_sdk_python_otel import InvocationOtelPlugin +from aws_durable_execution_sdk_python_otel import ( + InvocationOtelPlugin, + OtelPluginConfig, + ProviderSource, +) from aws_durable_execution_sdk_python import StepContext from aws_durable_execution_sdk_python.context import ( @@ -44,7 +48,11 @@ def greet_in_child(child_context: DurableContext, name: str) -> str: return result -@durable_execution(plugins=[InvocationOtelPlugin()]) +@durable_execution( + plugins=[ + InvocationOtelPlugin(OtelPluginConfig(provider_source=ProviderSource.GLOBAL)) + ] +) def handler(_event: Any, context: DurableContext) -> str: # Logged at the top level: enriched with the invocation span_id. context.logger.info("Workflow started") diff --git a/packages/aws-durable-execution-sdk-python-examples/src/plugin/execution_with_otel.py b/packages/aws-durable-execution-sdk-python-examples/src/plugin/execution_with_otel.py index 3d001d46..b1cff3c5 100644 --- a/packages/aws-durable-execution-sdk-python-examples/src/plugin/execution_with_otel.py +++ b/packages/aws-durable-execution-sdk-python-examples/src/plugin/execution_with_otel.py @@ -2,7 +2,11 @@ from typing import Any -from aws_durable_execution_sdk_python_otel import InvocationOtelPlugin +from aws_durable_execution_sdk_python_otel import ( + InvocationOtelPlugin, + OtelPluginConfig, + ProviderSource, +) from aws_durable_execution_sdk_python import StepContext from aws_durable_execution_sdk_python.config import Duration @@ -32,7 +36,11 @@ def add_numbers_in_child(child_context: DurableContext, a: int, b: int): return result -@durable_execution(plugins=[InvocationOtelPlugin()]) +@durable_execution( + plugins=[ + InvocationOtelPlugin(OtelPluginConfig(provider_source=ProviderSource.GLOBAL)) + ] +) def handler(_event: Any, context: DurableContext) -> int: result = 0 for i in range(3): diff --git a/packages/aws-durable-execution-sdk-python-otel/README.md b/packages/aws-durable-execution-sdk-python-otel/README.md index a6de879d..62a687ad 100644 --- a/packages/aws-durable-execution-sdk-python-otel/README.md +++ b/packages/aws-durable-execution-sdk-python-otel/README.md @@ -168,21 +168,25 @@ See the [ADOT sampling configuration](https://aws-otel.github.io/docs/getting-st ```python from aws_durable_execution_sdk_python_otel import ( InvocationOtelPlugin, + OtelPluginConfig, xray_context_extractor, ) plugin = InvocationOtelPlugin( - # Provide your own TracerProvider if you already have one configured. - # Defaults to the globally configured tracer provider. - trace_provider=None, - # Use a custom context extractor (default: xray_context_extractor). - context_extractor=xray_context_extractor, - # Custom instrumentation scope name - # (default: "aws-durable-execution-sdk-python"). - instrument_name="my-service", - # Install a root-logger filter that stamps trace context onto every - # log record (default: True). - enrich_logger=True, + OtelPluginConfig( + # Provide your own TracerProvider if you already have one configured. + # When omitted, an OTLP provider is auto-configured (like ExecutionOtelPlugin); + # set use_default_tracer_provider=True to use the global (e.g. ADOT) provider. + tracer_provider=None, + # Use a custom context extractor (default: xray_context_extractor). + context_extractor=xray_context_extractor, + # Custom instrumentation scope name + # (default: "aws-durable-execution-sdk-python"). + instrument_name="my-service", + # Install a root-logger filter that stamps trace context onto every + # log record (default: True). + enrich_logger=True, + ) ) ``` @@ -193,15 +197,16 @@ The plugin supports multiple strategies for extracting upstream trace context: ```python from aws_durable_execution_sdk_python_otel import ( InvocationOtelPlugin, + OtelPluginConfig, w3c_client_context_extractor, xray_context_extractor, ) # Default: X-Ray trace header (recommended for most Lambda deployments) -InvocationOtelPlugin(context_extractor=xray_context_extractor) +InvocationOtelPlugin(OtelPluginConfig(context_extractor=xray_context_extractor)) # W3C Trace Context via clientContext (requires backend propagation support) -InvocationOtelPlugin(context_extractor=w3c_client_context_extractor) +InvocationOtelPlugin(OtelPluginConfig(context_extractor=w3c_client_context_extractor)) ``` ### Log Correlation @@ -251,10 +256,15 @@ The main plugin class. Implements `DurableInstrumentationPlugin` from `aws_durab ```python InvocationOtelPlugin( - trace_provider=None, - context_extractor=None, - instrument_name="aws-durable-execution-sdk-python", - enrich_logger=True, + OtelPluginConfig( + tracer_provider=None, + context_extractor=None, + instrument_name="aws-durable-execution-sdk-python", + enrich_logger=True, + workflow_span_name="Workflow", + # ...and the rest of OtelPluginConfig (use_default_tracer_provider, + # enable_http_instrumentation, exporter_config, propagators). + ) ) ``` diff --git a/packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/__init__.py b/packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/__init__.py index d44dfb56..b9f1257e 100644 --- a/packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/__init__.py +++ b/packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/__init__.py @@ -17,6 +17,7 @@ from aws_durable_execution_sdk_python_otel.otel_plugin_config import ( OtelPluginConfig, ExporterConfig, + ProviderSource, ) from aws_durable_execution_sdk_python_otel.instrumentations import ( register_standalone_instrumentations, @@ -44,6 +45,7 @@ "InvocationOtelPlugin", "OtelContextLogFilter", "ProviderResult", + "ProviderSource", "create_tracer_provider", "derive_workflow_span_id", "install_log_filter", diff --git a/packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/execution_plugin.py b/packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/execution_plugin.py index 354b5ccf..bc829db6 100644 --- a/packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/execution_plugin.py +++ b/packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/execution_plugin.py @@ -62,6 +62,7 @@ ) from aws_durable_execution_sdk_python_otel.otel_plugin_config import ( OtelPluginConfig, + ProviderSource, ) from aws_durable_execution_sdk_python_otel.instrumentations import ( register_standalone_instrumentations, @@ -92,7 +93,8 @@ class ExecutionOtelPlugin(DurableInstrumentationPlugin): Args: config: Shared plugin configuration. When omitted, defaults are used - (auto-configured provider, X-Ray extractor, "Workflow" root span). + (globally configured provider, X-Ray extractor, "Workflow" root + span). """ def __init__(self, config: OtelPluginConfig | None = None) -> None: @@ -101,16 +103,17 @@ def __init__(self, config: OtelPluginConfig | None = None) -> None: self._config.context_extractor or xray_context_extractor ) self._workflow_span_name = self._config.workflow_span_name - self._use_default = bool(self._config.use_default_tracer_provider) self._id_generator = DeterministicIdGenerator() result = create_tracer_provider( self._config, id_generator=self._id_generator, - default_use_global=False, ) self._provider = result.tracer_provider - self._owns_provider = result.owns_provider + # GLOBAL (ADOT) mode parents the Invocation span to the ambient Lambda + # invocation span instead of the Workflow span (see + # _start_invocation_span). + self._provider_source = result.source # Deterministic stitching requires an SDK provider exposing id_generator. from opentelemetry.sdk.trace import TracerProvider as SdkTracerProvider @@ -129,12 +132,7 @@ def __init__(self, config: OtelPluginConfig | None = None) -> None: self._tracer: Tracer = self._provider.get_tracer(self._config.instrument_name) try: - register_standalone_instrumentations( - self._config, - self._provider if self._owns_provider else None, - owns_provider=self._owns_provider, - use_default_tracer_provider=self._use_default, - ) + register_standalone_instrumentations(self._config, result) except Exception: logger.exception("Failed to register standalone instrumentations") @@ -244,7 +242,7 @@ def _start_workflow_span(self, info: InvocationStartInfo) -> None: def _start_invocation_span(self, info: InvocationStartInfo) -> None: self._id_generator.set_next_span_id(None) attributes: dict[str, Any] - if self._use_default: + if self._provider_source is ProviderSource.GLOBAL: # Default-provider mode: parent the Invocation span to the ambient # Lambda invocation span (from the ADOT layer or other # auto-instrumentation), which is still the active context here (the diff --git a/packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/instrumentations.py b/packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/instrumentations.py index a9813808..5629ae56 100644 --- a/packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/instrumentations.py +++ b/packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/instrumentations.py @@ -3,7 +3,7 @@ Mirrors the JS ``registerStandaloneInstrumentations``: * A custom (explicit) provider skips ALL instrumentation registration. -* When the global provider is in use (``use_default_tracer_provider``), only the +* When the global provider is in use (``ProviderSource.GLOBAL``), only the AWS SDK instrumentation is registered (not HTTP). * When the plugin owns an auto-configured provider, both AWS SDK and (optionally) HTTP instrumentation are registered against that provider. @@ -22,13 +22,14 @@ import os from typing import TYPE_CHECKING, Any +from aws_durable_execution_sdk_python_otel.otel_plugin_config import ProviderSource -if TYPE_CHECKING: - from opentelemetry.trace import TracerProvider +if TYPE_CHECKING: from aws_durable_execution_sdk_python_otel.otel_plugin_config import ( OtelPluginConfig, ) + from aws_durable_execution_sdk_python_otel.provider import ProviderResult logger = logging.getLogger(__name__) @@ -98,31 +99,25 @@ def request_hook(span, pool, request_info) -> None: # noqa: ANN001 def register_standalone_instrumentations( config: OtelPluginConfig, - tracer_provider: TracerProvider | None, - *, - owns_provider: bool, - use_default_tracer_provider: bool, + result: ProviderResult, ) -> None: - """Register AWS SDK and HTTP instrumentations per the shared policy. + """Register AWS SDK and HTTP instrumentations per the resolved source. Args: config: Shared plugin configuration. - tracer_provider: The resolved provider (may be the global provider). - owns_provider: True when the plugin created/owns the provider. - use_default_tracer_provider: True when the global provider is in use. + result: The resolved provider and its :class:`ProviderSource`. """ - # A custom, explicitly-supplied provider means the caller manages their own - # instrumentation: skip everything. - if config.tracer_provider is not None: + if result.source is ProviderSource.EXPLICIT: + # Caller manages their own instrumentation: skip everything. return - if use_default_tracer_provider: + if result.source is ProviderSource.GLOBAL: # Global provider: register AWS instrumentation only. _register_aws_instrumentation(None) return - # Auto-configured, plugin-owned provider: AWS SDK always; HTTP unless - # explicitly disabled. - _register_aws_instrumentation(tracer_provider) + # AUTO_OTLP: auto-configured, plugin-owned provider -> AWS SDK always; HTTP + # unless explicitly disabled. + _register_aws_instrumentation(result.tracer_provider) if config.enable_http_instrumentation: - _register_http_instrumentation(tracer_provider) + _register_http_instrumentation(result.tracer_provider) diff --git a/packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/invocation_plugin.py b/packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/invocation_plugin.py index 4f2744d5..4f42ca32 100644 --- a/packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/invocation_plugin.py +++ b/packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/invocation_plugin.py @@ -45,7 +45,11 @@ ) from aws_durable_execution_sdk_python_otel.log_filter import install_log_filter from aws_durable_execution_sdk_python_otel.otel_plugin_config import ( - DEFAULT_WORKFLOW_SPAN_NAME, + OtelPluginConfig, +) +from aws_durable_execution_sdk_python_otel.provider import create_tracer_provider +from aws_durable_execution_sdk_python_otel.instrumentations import ( + register_standalone_instrumentations, ) @@ -80,42 +84,56 @@ class InvocationOtelPlugin(DurableInstrumentationPlugin): original logical operation. Args: - trace_provider: OpenTelemetry tracer provider used to create spans. - Optional; when omitted, the globally configured tracer provider - (``opentelemetry.trace.get_tracer_provider()``) is used. - context_extractor: Optional extractor for upstream context. Defaults to - AWS X-Ray header extraction. - instrument_name: Instrumentation scope name registered with the tracer. + config: Shared plugin configuration (the same OtelPluginConfig accepted + by ExecutionOtelPlugin). When omitted, defaults are used (X-Ray + extractor, "Workflow" span name, log enrichment on). Like + ExecutionOtelPlugin and the JS SDK plugins, the default + ``provider_source`` is ``GLOBAL``: the plugin uses the globally + configured tracer provider (e.g. the ADOT Lambda layer). Set + ``provider_source=ProviderSource.AUTO_OTLP`` on the config to have + the plugin build and own an auto-configured OTLP provider instead. """ DEFAULT_INSTRUMENT_NAME = "aws-durable-execution-sdk-python" - def __init__( - self, - trace_provider: SdkTracerProvider | None = None, - context_extractor: ContextExtractor | None = None, - instrument_name: str = DEFAULT_INSTRUMENT_NAME, - enrich_logger: bool = True, - workflow_span_name: str = DEFAULT_WORKFLOW_SPAN_NAME, - ) -> None: - """Initialize the plugin with an OpenTelemetry tracer provider. + def __init__(self, config: OtelPluginConfig | None = None) -> None: + """Initialize the plugin from a shared OtelPluginConfig. + + Accepts the same OtelPluginConfig as ExecutionOtelPlugin so both plugins + share one configuration surface (context extractor, instrumentation + name, provider selection, exporter/propagator settings, log + enrichment). Like ExecutionOtelPlugin and the JS SDK plugins, the + default ``provider_source`` is ``GLOBAL``: it uses the globally + configured (e.g. ADOT) provider. Pass + ``provider_source=ProviderSource.AUTO_OTLP`` to have the plugin build + and own an auto-configured OTLP provider instead. The tracer provider is configured with this plugin's deterministic ID generator so spans for a durable execution share stable trace and - logical operation identifiers. When no provider is supplied, the - globally configured tracer provider is used. + logical operation identifiers. - When enrich_logger is enabled (default), the plugin installs a logging - filter on the root logger at invocation start that stamps the active - OTel trace context onto every emitted log record. + When ``enrich_logger`` is enabled (default), the plugin installs a + logging filter that stamps the active OTel trace context onto every + emitted log record. """ - self._enrich_logger = enrich_logger - self._workflow_span_name = workflow_span_name + self._config = config or OtelPluginConfig() self._context_extractor: ContextExtractor = ( - context_extractor or xray_context_extractor + self._config.context_extractor or xray_context_extractor ) + self._workflow_span_name = self._config.workflow_span_name + self._enrich_logger = self._config.enrich_logger + + # Like ExecutionOtelPlugin (and the JS SDK plugins), InvocationOtelPlugin + # defaults to provider_source=GLOBAL (the globally configured, e.g. ADOT, + # provider); set provider_source=ProviderSource.AUTO_OTLP on the config + # to build and own an auto-configured OTLP provider instead. + self._id_generator = DeterministicIdGenerator() + result = create_tracer_provider( + self._config, + id_generator=self._id_generator, + ) + self._provider = result.tracer_provider - self._provider = trace_provider or trace.get_tracer_provider() # Deterministic trace stitching requires the SDK TracerProvider, which # exposes id_generator/sampler. The API's default ProxyTracerProvider # (returned before an SDK provider is configured) does not. Rather than @@ -128,16 +146,20 @@ def __init__( self._provider ) else: - self._id_generator = DeterministicIdGenerator() logger.warning( "InvocationOtelPlugin expected an SDK TracerProvider " "(opentelemetry.sdk.trace.TracerProvider) but got %s. Spans will " "not use deterministic IDs. " "Ensure the OpenTelemetry SDK is configured (e.g. via the ADOT " - "Lambda layer) or pass an explicit trace_provider.", + "Lambda layer) or pass an explicit tracer_provider.", type(self._provider).__name__, ) - self._tracer: Tracer = self._provider.get_tracer(instrument_name) + self._tracer: Tracer = self._provider.get_tracer(self._config.instrument_name) + + try: + register_standalone_instrumentations(self._config, result) + except Exception: + logger.exception("Failed to register standalone instrumentations") # per invocation status: self._execution_arn = "" diff --git a/packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/otel_plugin_config.py b/packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/otel_plugin_config.py index 76dc8843..cfbb0181 100644 --- a/packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/otel_plugin_config.py +++ b/packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/otel_plugin_config.py @@ -8,6 +8,7 @@ from __future__ import annotations from dataclasses import dataclass, field +from enum import Enum from typing import TYPE_CHECKING, Sequence @@ -27,6 +28,18 @@ DEFAULT_OTLP_ENDPOINT = "http://localhost:4318" +class ProviderSource(Enum): + """Which tracer-provider tier an :class:`OtelPluginConfig` selects. + + The single value that drives provider construction (``create_tracer_provider``) + and the plugins' instrumentation, span-parenting and flush decisions. + """ + + EXPLICIT = "explicit" # use config.tracer_provider as-is + GLOBAL = "global" # default: use the global provider (trace.get_tracer_provider()) + AUTO_OTLP = "auto_otlp" # plugin builds and owns an OTLP provider + + @dataclass class ExporterConfig: """OTLP exporter configuration for the auto-configured TracerProvider.""" @@ -43,11 +56,15 @@ class OtelPluginConfig: are ignored without error by :class:`InvocationOtelPlugin`. Attributes: - tracer_provider: Explicit provider to use as-is. Highest priority; when - set, the plugin does not own or modify it and skips instrumentation - registration. - use_default_tracer_provider: When True (and no explicit provider), - resolve the globally configured provider via ``trace.get_tracer_provider()``. + provider_source: Selects how the tracer provider is obtained + (:class:`ProviderSource`). Defaults to ``GLOBAL`` (uses the globally + configured provider, e.g. the ADOT Lambda layer, via + ``trace.get_tracer_provider()``). ``AUTO_OTLP`` makes the plugin + build and own an OTLP provider. ``EXPLICIT`` uses ``tracer_provider`` + as-is and skips instrumentation registration. + tracer_provider: The provider used when ``provider_source`` is + ``EXPLICIT``. Required in that case and must be left unset for + ``GLOBAL`` / ``AUTO_OTLP``. context_extractor: Upstream trace-context extractor. Defaults to the X-Ray extractor when omitted. instrument_name: Instrumentation scope name. @@ -60,8 +77,8 @@ class OtelPluginConfig: enrich_logger: Install the root-logger OTel context filter. """ + provider_source: ProviderSource = ProviderSource.GLOBAL tracer_provider: SdkTracerProvider | None = None - use_default_tracer_provider: bool | None = None context_extractor: ContextExtractor | None = None instrument_name: str = DEFAULT_INSTRUMENT_NAME enable_http_instrumentation: bool = True @@ -69,3 +86,22 @@ class OtelPluginConfig: propagators: Sequence[TextMapPropagator] | None = None workflow_span_name: str = DEFAULT_WORKFLOW_SPAN_NAME enrich_logger: bool = True + + def __post_init__(self) -> None: + """Validate that each provider source has the fields it requires. + + The config is fully driven by :attr:`provider_source`; ``tracer_provider`` + is the one source-specific field, so it must be present for ``EXPLICIT`` + and absent for ``GLOBAL`` / ``AUTO_OTLP`` (where it would be silently + ignored). + """ + if self.provider_source is ProviderSource.EXPLICIT: + if self.tracer_provider is None: + raise ValueError("provider_source=EXPLICIT requires a tracer_provider.") + elif self.tracer_provider is not None: + raise ValueError( + "tracer_provider is only valid with provider_source=EXPLICIT; " + f"got provider_source={self.provider_source.name}. Set " + "ProviderSource.EXPLICIT, or drop tracer_provider for " + "GLOBAL / AUTO_OTLP." + ) diff --git a/packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/provider.py b/packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/provider.py index a0533b4e..e310c438 100644 --- a/packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/provider.py +++ b/packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/provider.py @@ -1,13 +1,13 @@ """Shared TracerProvider factory for the durable-execution OTel plugins. -Implements the 3-level provider resolution used by both plugins: - -1. An explicit ``tracer_provider`` in config is used as-is (``owns_provider=False``). -2. Otherwise, when ``use_default_tracer_provider`` resolves to True, the globally - configured provider is used (``owns_provider=False``). -3. Otherwise a fully auto-configured SDK provider is created with an OTLP - exporter, batch processor, sampler and Lambda resource attributes - (``owns_provider=True``). +Builds the tracer provider selected by the config's +:class:`~aws_durable_execution_sdk_python_otel.otel_plugin_config.ProviderSource`: + +1. ``EXPLICIT`` - the config's ``tracer_provider`` is used as-is. +2. ``GLOBAL`` - the globally configured provider is used (e.g. ADOT layer). +3. ``AUTO_OTLP`` - a fully auto-configured SDK provider is created with an OTLP + exporter, batch processor, sampler and Lambda resource attributes; this is + the only tier the plugin owns/flushes. """ from __future__ import annotations @@ -23,6 +23,7 @@ from aws_durable_execution_sdk_python_otel.otel_plugin_config import ( DEFAULT_OTLP_ENDPOINT, OtelPluginConfig, + ProviderSource, ) @@ -40,10 +41,15 @@ @dataclass class ProviderResult: - """Result of provider resolution: the provider and whether we own it.""" + """Result of provider resolution: the provider and how it was chosen. + + ``source`` is the single value callers key their instrumentation/flush + decisions off; the plugin owns (and flushes) the provider only when it is + :attr:`ProviderSource.AUTO_OTLP`. + """ tracer_provider: TracerProvider - owns_provider: bool + source: ProviderSource def _resolve_endpoint(config: OtelPluginConfig) -> str: @@ -151,31 +157,37 @@ def create_tracer_provider( config: OtelPluginConfig, *, id_generator: IdGenerator | None = None, - default_use_global: bool = False, ) -> ProviderResult: - """Resolve a TracerProvider using the shared 3-level priority. + """Resolve a TracerProvider from the config's :attr:`provider_source`. + + A straight switch on ``config.provider_source``; the chosen tier is reported + back as :class:`ProviderSource` so callers make the instrumentation/flush + decision off a single value: + + 1. ``EXPLICIT`` -> ``config.tracer_provider`` used as-is + 2. ``GLOBAL`` -> the globally configured provider + 3. ``AUTO_OTLP`` -> a plugin-owned, auto-configured OTLP provider Args: config: Shared plugin configuration. id_generator: Deterministic ID generator injected into an auto-configured provider so cross-invocation trace stitching works. - default_use_global: The value ``use_default_tracer_provider`` defaults to - when it is unset in config. ``ExecutionOtelPlugin`` passes ``False``; - ``InvocationOtelPlugin`` passes ``True`` to preserve its historical - behaviour of using the global provider. Returns: A :class:`ProviderResult`. """ - if config.tracer_provider is not None: - # Explicit provider: use as-is, never wrap/modify. - return ProviderResult(config.tracer_provider, False) - - use_default = config.use_default_tracer_provider - if use_default is None: - use_default = default_use_global - - if use_default: - return ProviderResult(trace.get_tracer_provider(), False) - - return ProviderResult(_create_auto_provider(config, id_generator), True) + source = config.provider_source + + if source is ProviderSource.EXPLICIT: + # Explicit provider: use as-is, never wrap/modify. OtelPluginConfig + # validation guarantees tracer_provider is set for EXPLICIT. + assert config.tracer_provider is not None + provider: TracerProvider = config.tracer_provider + elif source is ProviderSource.GLOBAL: + provider = trace.get_tracer_provider() + elif source is ProviderSource.AUTO_OTLP: + provider = _create_auto_provider(config, id_generator) + else: # pragma: no cover - exhaustive over ProviderSource + raise ValueError(f"unknown provider_source: {source!r}") + + return ProviderResult(provider, source) diff --git a/packages/aws-durable-execution-sdk-python-otel/tests/test_execution_plugin.py b/packages/aws-durable-execution-sdk-python-otel/tests/test_execution_plugin.py index 8b7aedd1..75488662 100644 --- a/packages/aws-durable-execution-sdk-python-otel/tests/test_execution_plugin.py +++ b/packages/aws-durable-execution-sdk-python-otel/tests/test_execution_plugin.py @@ -35,6 +35,7 @@ from aws_durable_execution_sdk_python_otel.execution_plugin import ExecutionOtelPlugin from aws_durable_execution_sdk_python_otel.otel_plugin_config import ( OtelPluginConfig, + ProviderSource, ) @@ -64,6 +65,7 @@ def _create_plugin() -> tuple[ExecutionOtelPlugin, InMemorySpanExporter]: provider.add_span_processor(SimpleSpanProcessor(exporter)) plugin = ExecutionOtelPlugin( OtelPluginConfig( + provider_source=ProviderSource.EXPLICIT, tracer_provider=provider, context_extractor=lambda _: Context(), enrich_logger=False, @@ -389,19 +391,22 @@ def test_step_attempt_span_omits_operation_status(): # --------------------------------------------------------------------------- # Default-provider mode: invocation span # --------------------------------------------------------------------------- -def _create_default_mode_plugin() -> tuple[ExecutionOtelPlugin, InMemorySpanExporter]: - """ExecutionOtelPlugin in default-provider mode wired to an in-memory exporter. - - Passing an explicit ``tracer_provider`` lets the test capture spans while - ``use_default_tracer_provider=True`` still selects the default-mode code path. +def _create_default_mode_plugin( + monkeypatch, +) -> tuple[ExecutionOtelPlugin, InMemorySpanExporter]: + """ExecutionOtelPlugin in GLOBAL (ADOT) mode wired to an in-memory exporter. + + The capture provider is installed as the global provider so + ``provider_source=GLOBAL`` resolves to it, letting the test assert spans + while exercising the ambient-parenting path. """ exporter = InMemorySpanExporter() provider = TracerProvider() provider.add_span_processor(SimpleSpanProcessor(exporter)) + monkeypatch.setattr(trace, "get_tracer_provider", lambda: provider) plugin = ExecutionOtelPlugin( OtelPluginConfig( - tracer_provider=provider, - use_default_tracer_provider=True, + provider_source=ProviderSource.GLOBAL, context_extractor=lambda _: Context(), enrich_logger=False, ) @@ -409,8 +414,8 @@ def _create_default_mode_plugin() -> tuple[ExecutionOtelPlugin, InMemorySpanExpo return plugin, exporter -def test_default_mode_creates_invocation_span(): - plugin, exporter = _create_default_mode_plugin() +def test_default_mode_creates_invocation_span(monkeypatch): + plugin, exporter = _create_default_mode_plugin(monkeypatch) plugin.on_invocation_start(_invocation_start_info()) plugin.on_invocation_end(_invocation_end_info()) @@ -423,8 +428,8 @@ def test_default_mode_creates_invocation_span(): assert invocation.attributes["durable.invocation.first"] is True -def test_default_mode_invocation_span_parented_to_ambient_span(): - plugin, exporter = _create_default_mode_plugin() +def test_default_mode_invocation_span_parented_to_ambient_span(monkeypatch): + plugin, exporter = _create_default_mode_plugin(monkeypatch) # Simulate the ambient Lambda invocation span from the ADOT layer. ambient = plugin._provider.get_tracer("ambient").start_span("lambda-invocation") diff --git a/packages/aws-durable-execution-sdk-python-otel/tests/test_execution_plugin_integration.py b/packages/aws-durable-execution-sdk-python-otel/tests/test_execution_plugin_integration.py index 3d2f6820..98f450c5 100644 --- a/packages/aws-durable-execution-sdk-python-otel/tests/test_execution_plugin_integration.py +++ b/packages/aws-durable-execution-sdk-python-otel/tests/test_execution_plugin_integration.py @@ -4,9 +4,9 @@ InMemorySpanExporter for the two deployment shapes: * Community collector layer: the plugin owns its provider - (``use_default_tracer_provider=False``); the Workflow span is the trace root. + (``provider_source=AUTO_OTLP`` / ``EXPLICIT``); the Workflow span is the trace root. * ADOT layer: the ADOT Lambda layer supplies the global provider and the ambient - Lambda invocation span (``use_default_tracer_provider=True``); the plugin's + Lambda invocation span (``provider_source=GLOBAL``); the plugin's Invocation span parents to that ambient span. """ @@ -42,7 +42,10 @@ operation_id_to_span_id, ) from aws_durable_execution_sdk_python_otel.execution_plugin import ExecutionOtelPlugin -from aws_durable_execution_sdk_python_otel.otel_plugin_config import OtelPluginConfig +from aws_durable_execution_sdk_python_otel.otel_plugin_config import ( + OtelPluginConfig, + ProviderSource, +) START_TIME = datetime(2024, 1, 2, 3, 4, 5, tzinfo=UTC) @@ -162,8 +165,8 @@ def test_community_layer_full_lifecycle_is_workflow_rooted(): provider, exporter = _provider() plugin = ExecutionOtelPlugin( OtelPluginConfig( + provider_source=ProviderSource.EXPLICIT, tracer_provider=provider, - use_default_tracer_provider=False, context_extractor=lambda _: Context(), enrich_logger=False, ) @@ -209,12 +212,13 @@ def test_community_layer_full_lifecycle_is_workflow_rooted(): # --------------------------------------------------------------------------- # ADOT layer (default provider; ambient invocation span) # --------------------------------------------------------------------------- -def test_adot_layer_full_lifecycle_parents_to_ambient_span(): +def test_adot_layer_full_lifecycle_parents_to_ambient_span(monkeypatch): provider, exporter = _provider() + # Simulate the ADOT layer having configured the global TracerProvider. + monkeypatch.setattr(trace, "get_tracer_provider", lambda: provider) plugin = ExecutionOtelPlugin( OtelPluginConfig( - tracer_provider=provider, - use_default_tracer_provider=True, + provider_source=ProviderSource.GLOBAL, context_extractor=lambda _: Context(), enrich_logger=False, ) @@ -255,9 +259,9 @@ def test_adot_layer_full_lifecycle_parents_to_ambient_span(): def test_second_plugin_configures_cached_tracer_generator(monkeypatch): """A second handler's Workflow span uses its deterministic trace ID.""" provider, exporter = _provider() + monkeypatch.setattr(trace, "get_tracer_provider", lambda: provider) config = OtelPluginConfig( - tracer_provider=provider, - use_default_tracer_provider=True, + provider_source=ProviderSource.GLOBAL, context_extractor=lambda _: Context(), enrich_logger=False, ) diff --git a/packages/aws-durable-execution-sdk-python-otel/tests/test_invocation_plugin.py b/packages/aws-durable-execution-sdk-python-otel/tests/test_invocation_plugin.py index c24f091e..d6cf063a 100644 --- a/packages/aws-durable-execution-sdk-python-otel/tests/test_invocation_plugin.py +++ b/packages/aws-durable-execution-sdk-python-otel/tests/test_invocation_plugin.py @@ -36,6 +36,10 @@ operation_id_to_span_id, ) from aws_durable_execution_sdk_python_otel.invocation_plugin import InvocationOtelPlugin +from aws_durable_execution_sdk_python_otel.otel_plugin_config import ( + OtelPluginConfig, + ProviderSource, +) START_TIME = datetime(2024, 1, 2, 3, 4, 5, tzinfo=UTC) @@ -63,8 +67,11 @@ def _create_plugin() -> tuple[InvocationOtelPlugin, InMemorySpanExporter]: trace_provider = TracerProvider() trace_provider.add_span_processor(SimpleSpanProcessor(exporter)) plugin = InvocationOtelPlugin( - trace_provider=trace_provider, - context_extractor=lambda _: Context(), + OtelPluginConfig( + provider_source=ProviderSource.EXPLICIT, + tracer_provider=trace_provider, + context_extractor=lambda _: Context(), + ) ) return plugin, exporter @@ -1083,9 +1090,12 @@ def test_workflow_span_name_is_configurable(): trace_provider = TracerProvider() trace_provider.add_span_processor(SimpleSpanProcessor(exporter)) plugin = InvocationOtelPlugin( - trace_provider=trace_provider, - context_extractor=lambda _: Context(), - workflow_span_name="MyExecution", + OtelPluginConfig( + provider_source=ProviderSource.EXPLICIT, + tracer_provider=trace_provider, + context_extractor=lambda _: Context(), + workflow_span_name="MyExecution", + ) ) plugin.on_invocation_start(_invocation_start_info()) plugin.on_invocation_end(_invocation_end_info()) diff --git a/packages/aws-durable-execution-sdk-python-otel/tests/test_invocation_plugin_integration.py b/packages/aws-durable-execution-sdk-python-otel/tests/test_invocation_plugin_integration.py index 7b7d5c87..c1c6d4b1 100644 --- a/packages/aws-durable-execution-sdk-python-otel/tests/test_invocation_plugin_integration.py +++ b/packages/aws-durable-execution-sdk-python-otel/tests/test_invocation_plugin_integration.py @@ -45,6 +45,10 @@ operation_id_to_span_id, ) from aws_durable_execution_sdk_python_otel.invocation_plugin import InvocationOtelPlugin +from aws_durable_execution_sdk_python_otel.otel_plugin_config import ( + OtelPluginConfig, + ProviderSource, +) START_TIME = datetime(2024, 1, 2, 3, 4, 5, tzinfo=UTC) @@ -188,9 +192,12 @@ def _assert_hierarchy(exporter: InMemorySpanExporter) -> None: def test_community_layer_full_lifecycle_uses_supplied_provider(): provider, exporter = _provider() plugin = InvocationOtelPlugin( - trace_provider=provider, - context_extractor=lambda _: Context(), - enrich_logger=False, + OtelPluginConfig( + provider_source=ProviderSource.EXPLICIT, + tracer_provider=provider, + context_extractor=lambda _: Context(), + enrich_logger=False, + ) ) plugin.on_invocation_start(_invocation_start()) @@ -209,8 +216,11 @@ def test_adot_layer_full_lifecycle_uses_global_provider(monkeypatch): monkeypatch.setattr(trace, "get_tracer_provider", lambda: provider) plugin = InvocationOtelPlugin( - context_extractor=lambda _: Context(), - enrich_logger=False, + OtelPluginConfig( + provider_source=ProviderSource.GLOBAL, + context_extractor=lambda _: Context(), + enrich_logger=False, + ) ) plugin.on_invocation_start(_invocation_start()) @@ -225,14 +235,20 @@ def test_second_plugin_configures_cached_tracer_generator(monkeypatch): """A second handler's Workflow span uses its deterministic trace ID.""" provider, exporter = _provider() first_plugin = InvocationOtelPlugin( - trace_provider=provider, - context_extractor=lambda _: Context(), - enrich_logger=False, + OtelPluginConfig( + provider_source=ProviderSource.EXPLICIT, + tracer_provider=provider, + context_extractor=lambda _: Context(), + enrich_logger=False, + ) ) target_plugin = InvocationOtelPlugin( - trace_provider=provider, - context_extractor=lambda _: Context(), - enrich_logger=False, + OtelPluginConfig( + provider_source=ProviderSource.EXPLICIT, + tracer_provider=provider, + context_extractor=lambda _: Context(), + enrich_logger=False, + ) ) monkeypatch.setenv("_X_AMZN_TRACE_ID", XRAY_TRACE_HEADER) diff --git a/packages/aws-durable-execution-sdk-python-otel/tests/test_log_filter.py b/packages/aws-durable-execution-sdk-python-otel/tests/test_log_filter.py index 39933338..f419f9ad 100644 --- a/packages/aws-durable-execution-sdk-python-otel/tests/test_log_filter.py +++ b/packages/aws-durable-execution-sdk-python-otel/tests/test_log_filter.py @@ -23,6 +23,10 @@ install_log_filter, ) from aws_durable_execution_sdk_python_otel.invocation_plugin import InvocationOtelPlugin +from aws_durable_execution_sdk_python_otel.otel_plugin_config import ( + OtelPluginConfig, + ProviderSource, +) START_TIME = datetime(2024, 1, 2, 3, 4, 5, tzinfo=UTC) @@ -37,9 +41,12 @@ def _create_plugin( trace_provider = TracerProvider() trace_provider.add_span_processor(SimpleSpanProcessor(exporter)) plugin = InvocationOtelPlugin( - trace_provider=trace_provider, - context_extractor=lambda _: Context(), - enrich_logger=enrich_logger, + OtelPluginConfig( + provider_source=ProviderSource.EXPLICIT, + tracer_provider=trace_provider, + context_extractor=lambda _: Context(), + enrich_logger=enrich_logger, + ) ) return plugin, exporter diff --git a/packages/aws-durable-execution-sdk-python-otel/tests/test_provider.py b/packages/aws-durable-execution-sdk-python-otel/tests/test_provider.py index dd06a6f2..e792c2ff 100644 --- a/packages/aws-durable-execution-sdk-python-otel/tests/test_provider.py +++ b/packages/aws-durable-execution-sdk-python-otel/tests/test_provider.py @@ -10,6 +10,7 @@ from aws_durable_execution_sdk_python_otel.otel_plugin_config import ( OtelPluginConfig, ExporterConfig, + ProviderSource, ) from aws_durable_execution_sdk_python_otel.provider import ( SAMPLING_RATIO_ENV, @@ -20,40 +21,59 @@ ) -def test_explicit_provider_is_used_and_not_owned(): +def test_explicit_provider_is_used(): provider = TracerProvider() - result = create_tracer_provider(OtelPluginConfig(tracer_provider=provider)) + result = create_tracer_provider( + OtelPluginConfig( + provider_source=ProviderSource.EXPLICIT, tracer_provider=provider + ) + ) assert result.tracer_provider is provider - assert result.owns_provider is False + assert result.source is ProviderSource.EXPLICIT -def test_use_default_provider_returns_global_and_not_owned(): - result = create_tracer_provider(OtelPluginConfig(use_default_tracer_provider=True)) +def test_global_source_returns_global_provider(): + result = create_tracer_provider( + OtelPluginConfig(provider_source=ProviderSource.GLOBAL) + ) assert result.tracer_provider is trace.get_tracer_provider() - assert result.owns_provider is False + assert result.source is ProviderSource.GLOBAL -def test_default_use_global_flag_applies_when_unset(): - # InvocationOtelPlugin passes default_use_global=True so an unset config - # resolves to the global provider. - result = create_tracer_provider(OtelPluginConfig(), default_use_global=True) +def test_unset_config_defaults_to_global_provider(): + # The default: no provider_source given -> use the global provider. + result = create_tracer_provider(OtelPluginConfig()) + assert result.source is ProviderSource.GLOBAL assert result.tracer_provider is trace.get_tracer_provider() - assert result.owns_provider is False -def test_auto_configured_provider_is_owned_sdk_provider(): - result = create_tracer_provider(OtelPluginConfig()) - assert result.owns_provider is True +def test_auto_otlp_source_builds_sdk_provider(): + result = create_tracer_provider( + OtelPluginConfig(provider_source=ProviderSource.AUTO_OTLP) + ) + assert result.source is ProviderSource.AUTO_OTLP assert isinstance(result.tracer_provider, TracerProvider) -def test_explicit_provider_takes_precedence_over_use_default(): - provider = TracerProvider() - result = create_tracer_provider( - OtelPluginConfig(tracer_provider=provider, use_default_tracer_provider=True) - ) - assert result.tracer_provider is provider - assert result.owns_provider is False +# --------------------------------------------------------------------------- +# Config validation (each source has the fields it needs) +# --------------------------------------------------------------------------- +def test_explicit_source_requires_tracer_provider(): + with pytest.raises(ValueError, match="requires a tracer_provider"): + OtelPluginConfig(provider_source=ProviderSource.EXPLICIT) + + +def test_tracer_provider_without_explicit_source_raises(): + # Default source is GLOBAL; a stray tracer_provider would be ignored. + with pytest.raises(ValueError, match="only valid with provider_source=EXPLICIT"): + OtelPluginConfig(tracer_provider=TracerProvider()) + + +def test_global_source_rejects_tracer_provider(): + with pytest.raises(ValueError, match="only valid with provider_source=EXPLICIT"): + OtelPluginConfig( + provider_source=ProviderSource.GLOBAL, tracer_provider=TracerProvider() + ) # ---------------------------------------------------------------------------