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 82ffbd0b0cb..525334173d8 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/export/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/export/__init__.py @@ -99,6 +99,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, @@ -115,6 +116,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) @@ -127,6 +132,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 55345c89903..6f7a303ae04 100644 --- a/opentelemetry-sdk/tests/trace/export/test_export.py +++ b/opentelemetry-sdk/tests/trace/export/test_export.py @@ -174,6 +174,44 @@ 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"}) + 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.