From 85298d80d4eec003e1b02a6ce1c950fa1f74f24c Mon Sep 17 00:00:00 2001 From: cijothomas Date: Wed, 5 Aug 2026 13:46:42 -0700 Subject: [PATCH 1/2] Drop and count spans ended after SimpleSpanProcessor shutdown as already_shutdown Assisted-by: Claude Opus 4.8 --- .changelog/5512.changed | 1 + .../sdk/trace/export/__init__.py | 6 +++ .../tests/trace/export/test_export.py | 48 +++++++++++++++++++ 3 files changed, 55 insertions(+) create mode 100644 .changelog/5512.changed diff --git a/.changelog/5512.changed b/.changelog/5512.changed new file mode 100644 index 00000000000..f09b7517279 --- /dev/null +++ b/.changelog/5512.changed @@ -0,0 +1 @@ +`opentelemetry-sdk`: `SimpleSpanProcessor` now drops spans ended after `shutdown()` instead of passing them to the exporter, and counts them on `otel.sdk.processor.span.processed` with `error.type=already_shutdown`. diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/export/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/export/__init__.py index 7daaa638d78..1562fca1b90 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/export/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/export/__init__.py @@ -103,6 +103,7 @@ def __init__( meter_provider: MeterProvider | None = None, ): self.span_exporter = span_exporter + self._shutdown = False self._metrics = create_processor_metrics( "traces", OtelComponentTypeValues.SIMPLE_SPAN_PROCESSOR, @@ -123,6 +124,10 @@ def _on_ending(self, span: Span) -> None: def on_end(self, span: ReadableSpan) -> None: if not (span.context and span.context.trace_flags.sampled): return + if self._shutdown: + logger.warning("Processor is already shutdown, ignoring call") + self._metrics.drop_items(1, "already_shutdown") + return token = attach(set_value(_SUPPRESS_INSTRUMENTATION_KEY, True)) # Record on submission to the exporter. self._metrics.finish_items(1) @@ -135,6 +140,7 @@ def on_end(self, span: ReadableSpan) -> None: detach(token) def shutdown(self) -> None: + self._shutdown = True self.span_exporter.shutdown() def force_flush(self, timeout_millis: int = 30000) -> bool: diff --git a/opentelemetry-sdk/tests/trace/export/test_export.py b/opentelemetry-sdk/tests/trace/export/test_export.py index f974fb15c45..81788f4e6ee 100644 --- a/opentelemetry-sdk/tests/trace/export/test_export.py +++ b/opentelemetry-sdk/tests/trace/export/test_export.py @@ -189,6 +189,54 @@ def export_spans(_spans): ) self.assertIsNone(processed_data_point0.attributes.get("error.type")) + @mock.patch.dict( + "os.environ", {OTEL_PYTHON_SDK_INTERNAL_METRICS_ENABLED: "true"} + ) + def test_metrics_already_shutdown(self): + metric_reader = InMemoryMetricReader() + meter_provider = MeterProvider(metric_readers=[metric_reader]) + + exporter = mock.MagicMock() + exporter.export.return_value = export.SpanExportResult.SUCCESS + span_processor = export.SimpleSpanProcessor( + exporter, meter_provider=meter_provider + ) + provider = trace.TracerProvider() + tracer = provider.get_tracer(__name__) + provider.add_span_processor(span_processor) + + # Ended before shutdown: submitted to the exporter and counted as + # successfully processed. + with tracer.start_as_current_span("foo"): + pass + span_processor.shutdown() + + # Ended after shutdown: dropped and counted as already_shutdown. + with tracer.start_as_current_span("bar"): + pass + + metrics_data = metric_reader.get_metrics_data() + scope_metrics = metrics_data.resource_metrics[0].scope_metrics[0] + processed = next( + m + for m in scope_metrics.metrics + if m.name == "otel.sdk.processor.span.processed" + ) + data_points = sorted( + processed.data.data_points, + key=lambda dp: dp.attributes.get("error.type", ""), + ) + self.assertEqual(len(data_points), 2) + self.assertEqual(data_points[0].value, 1) + self.assertIsNone(data_points[0].attributes.get("error.type")) + self.assertEqual(data_points[1].value, 1) + self.assertEqual( + data_points[1].attributes.get("error.type"), "already_shutdown" + ) + # Only the pre-shutdown span is submitted to the exporter; the + # post-shutdown span is dropped, not exported. + self.assertEqual(exporter.export.call_count, 1) + # Many more test cases for the BatchSpanProcessor exist under # opentelemetry-sdk/tests/shared_internal/test_batch_processor.py. From 66b21f9552393bba7444362ea399e3b7ae8f16d8 Mon Sep 17 00:00:00 2001 From: cijothomas Date: Fri, 7 Aug 2026 14:06:20 -0700 Subject: [PATCH 2/2] Apply ruff format to test_export.py Assisted-by: Claude Opus 4.8 --- .../tests/trace/export/test_export.py | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/opentelemetry-sdk/tests/trace/export/test_export.py b/opentelemetry-sdk/tests/trace/export/test_export.py index f5768265f85..6f7a303ae04 100644 --- a/opentelemetry-sdk/tests/trace/export/test_export.py +++ b/opentelemetry-sdk/tests/trace/export/test_export.py @@ -174,18 +174,14 @@ def export_spans(_spans): self.assertTrue(processed_data_point0.attributes["otel.component.name"].startswith("simple_span_processor/")) self.assertIsNone(processed_data_point0.attributes.get("error.type")) - @mock.patch.dict( - "os.environ", {OTEL_PYTHON_SDK_INTERNAL_METRICS_ENABLED: "true"} - ) + @mock.patch.dict("os.environ", {OTEL_PYTHON_SDK_INTERNAL_METRICS_ENABLED: "true"}) def test_metrics_already_shutdown(self): metric_reader = InMemoryMetricReader() meter_provider = MeterProvider(metric_readers=[metric_reader]) exporter = mock.MagicMock() exporter.export.return_value = export.SpanExportResult.SUCCESS - span_processor = export.SimpleSpanProcessor( - exporter, meter_provider=meter_provider - ) + span_processor = export.SimpleSpanProcessor(exporter, meter_provider=meter_provider) provider = trace.TracerProvider() tracer = provider.get_tracer(__name__) provider.add_span_processor(span_processor) @@ -202,11 +198,7 @@ def test_metrics_already_shutdown(self): metrics_data = metric_reader.get_metrics_data() scope_metrics = metrics_data.resource_metrics[0].scope_metrics[0] - processed = next( - m - for m in scope_metrics.metrics - if m.name == "otel.sdk.processor.span.processed" - ) + processed = next(m for m in scope_metrics.metrics if m.name == "otel.sdk.processor.span.processed") data_points = sorted( processed.data.data_points, key=lambda dp: dp.attributes.get("error.type", ""), @@ -215,9 +207,7 @@ def test_metrics_already_shutdown(self): self.assertEqual(data_points[0].value, 1) self.assertIsNone(data_points[0].attributes.get("error.type")) self.assertEqual(data_points[1].value, 1) - self.assertEqual( - data_points[1].attributes.get("error.type"), "already_shutdown" - ) + self.assertEqual(data_points[1].attributes.get("error.type"), "already_shutdown") # Only the pre-shutdown span is submitted to the exporter; the # post-shutdown span is dropped, not exported. self.assertEqual(exporter.export.call_count, 1)