Repository: getsentry/sentry-python

Issue:
Opentelemetry integration does not set the Transaction/Span status
### How do you use Sentry?

Self-hosted/on-premise

### Version

1.22.2

### Steps to Reproduce

I use the Opentelemetry integration to send my Opentelemetry spans to Sentry. My code is manually instrumented with the opentelemetry sdk.

I noticed that if an otel span has an error status, the status is not propagated to Sentry's transaction/span trace context.

Below is a script that demonstrates the issue. It needs the following requirements to work:
```
opentelemetry-api==1.17.0
opentelemetry-exporter-jaeger==1.17.0
opentelemetry-sdk==1.17.0
sentry-sdk==1.22.2
```

The script reports two transactions to sentry: one with opentelemetry sdk, and another one with sentry sdk. 
My expectations are that the transactions' trace context would be similar, however the opentelemetry-created transaction does not have a status set.

```python
import json

import sentry_sdk
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.propagate import set_global_textmap
from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessor
from opentelemetry.trace import Status, StatusCode
from sentry_sdk.integrations.opentelemetry import SentryPropagator, SentrySpanProcessor


tracer = trace.get_tracer("main")


class ConsoleTransport(sentry_sdk.Transport):
    """
    A sentry transport that stores events in memory and sends them to stdout
    instead of sending them to Sentry
    """

    def __init__(self):
        super().__init__()
        self.transactions = []

    def capture_envelope(self, envelope):
        for item in envelope.items:
            if item.data_category != "transaction":
                continue

            transaction = item.get_transaction_event()

            print("captured transaction:")
            print(json.dumps(transaction, indent=4))

            self.transactions.append(item.get_transaction_event())

    def find_transaction(self, name):
        for transaction in self.transactions:
            if transaction.get("transaction") == name:
                return transaction


def configure_sentry(transport):
    sentry_sdk.init(
        # fake dsn to enable tracing
        dsn="https://86b3b511153cdac55b3331da87366e18@example.com/1",
        traces_sample_rate=1.0,
        transport=transport,
        instrumenter="otel",
    )


def configure_opentelemetry():
    provider = TracerProvider()

    # Sentry integration
    provider.add_span_processor(SentrySpanProcessor())
    set_global_textmap(SentryPropagator())

    # Console exporter to verify that the otel span has an error status
    exporter = ConsoleSpanExporter(
        formatter=lambda span: f"opentelemetry span:\n {span.to_json()}\n"
    )
    processor = SimpleSpanProcessor(exporter)
    provider.add_span_processor(processor)

    trace.set_tracer_provider(provider)


def create_otel_transaction():
    """
    Capture an example transaction with opentelemetry sdk
    :return:
    """
    with tracer.start_as_current_span(name="otel_transaction") as span:
        try:
            raise ValueError("from otel transaction")
        except ValueError as exc:
            span.record_exception(exc)
            span.set_status(Status(StatusCode.ERROR))
            # At this point I expect that sentry's transaction
            # would also have an error status


def create_sentry_transaction():
    """
    Capture an example transaction with sentry sdk
    """
    with sentry_sdk.start_transaction(
        name="sentry_transaction", instrumenter="otel"
    ) as transaction:
        try:
            raise ValueError("from sentry transaction")
        except ValueError:
            transaction.set_status("unknown_error")


def main():
    sentry_transport = ConsoleTransport()

    configure_sentry(sentry_transport)
    configure_opentelemetry()

    create_otel_transaction()
    create_sentry_transaction()

    otel_transaction = sentry_transport.find_transaction("otel_transaction")
    sentry_transaction = sentry_transport.find_transaction("sentry_transaction")
    sentry_transaction_status = sentry_transaction["contexts"]["trace"].get("status")

    otel_transaction_status = otel_transaction["contexts"]["trace"].get("status")

    print(f"Sentry-created transaction status: {sentry_transaction_status}")
    print(f"Otel-created transaction status: {otel_transaction_status}")


if __name__ == "__main__":
    main()
```


### Expected Result

Sentry's Opentelemetry integration propagates the span status to the Sentry transaction trace context, and Sentry classifies the transaction as an error in the user interface.

### Actual Result

The transaction created from an opentelemetry span does not have a status set.

Example script output:
```
captured sentry transaction:
{
    "type": "transaction",
    "transaction": "otel_transaction",
    "transaction_info": {
        "source": "custom"
    },
    "contexts": {
        "otel": {
            "resource": {
                "telemetry.sdk.language": "python",
                "telemetry.sdk.name": "opentelemetry",
                "telemetry.sdk.version": "1.17.0",
                "service.name": "unknown_service"
            }
        },
        "trace": {
            "trace_id": "3c27001c81f1e5f2be8fabebb94ef671",
            "span_id": "d251cd410abfd884",
            "parent_span_id": null,
            "op": "otel_transaction",
            "description": null
        },
        "runtime": {
            "name": "CPython",
            "version": "3.11.1",
            "build": "3.11.1 (main, Jan  2 2023, 14:37:00) [Clang 14.0.0 (clang-1400.0.29.202)]"
        }
    },
    "tags": {},
    "timestamp": "2023-05-13T09:39:13.480122Z",
    "start_timestamp": "2023-05-13T09:39:13.471644Z",
    "spans": [],
    "measurements": {},
    "event_id": "7031c540e1384cd2ba48699bcf95a075",
    "extra": {
        "sys.argv": [
            "/Users/danielkono/personal/python/sentry-transaction-mrrp/main2.py"
        ]
    },
    "environment": "production",
    "server_name": "danielkono-o",
    "sdk": {
        "name": "sentry.python",
        "version": "1.22.2",
        "packages": [
            {
                "name": "pypi:sentry-sdk",
                "version": "1.22.2"
            }
        ],
        "integrations": [
            "argv",
            "atexit",
            "dedupe",
            "excepthook",
            "logging",
            "modules",
            "redis",
            "stdlib",
            "threading"
        ]
    },
    "platform": "python"
}
opentelemetry span:
 {
    "name": "otel_transaction",
    "context": {
        "trace_id": "0x3c27001c81f1e5f2be8fabebb94ef671",
        "span_id": "0xd251cd410abfd884",
        "trace_state": "[]"
    },
    "kind": "SpanKind.INTERNAL",
    "parent_id": null,
    "start_time": "2023-05-13T07:39:13.471644Z",
    "end_time": "2023-05-13T07:39:13.480122Z",
    "status": {
        "status_code": "ERROR"
    },
    "attributes": {},
    "events": [
        {
            "name": "exception",
            "timestamp": "2023-05-13T07:39:13.480099Z",
            "attributes": {
                "exception.type": "ValueError",
                "exception.message": "from otel transaction",
                "exception.stacktrace": "Traceback (most recent call last):\n  File \"/Users/danielkono/personal/python/sentry-transaction-mrrp/main2.py\", line 85, in create_otel_transaction\n    raise ValueError(\"from otel transaction\")\nValueError: from otel transaction\n",
                "exception.escaped": "False"
            }
        }
    ],
    "links": [],
    "resource": {
        "attributes": {
            "telemetry.sdk.language": "python",
            "telemetry.sdk.name": "opentelemetry",
            "telemetry.sdk.version": "1.17.0",
            "service.name": "unknown_service"
        },
        "schema_url": ""
    }
}
captured sentry transaction:
{
    "type": "transaction",
    "transaction": "sentry_transaction",
    "transaction_info": {
        "source": "custom"
    },
    "contexts": {
        "trace": {
            "trace_id": "dd7179ed52b84d58bb59bacec4d07e36",
            "span_id": "b2d81c64dc73e927",
            "parent_span_id": null,
            "op": null,
            "description": null,
            "status": "internal_error"
        },
        "runtime": {
            "name": "CPython",
            "version": "3.11.1",
            "build": "3.11.1 (main, Jan  2 2023, 14:37:00) [Clang 14.0.0 (clang-1400.0.29.202)]"
        }
    },
    "tags": {},
    "timestamp": "2023-05-13T07:39:13.483194Z",
    "start_timestamp": "2023-05-13T07:39:13.483116Z",
    "spans": [],
    "measurements": {},
    "event_id": "892dda351c974b0fa73f8baaa17cf8a1",
    "extra": {
        "sys.argv": [
            "/Users/danielkono/personal/python/sentry-transaction-mrrp/main2.py"
        ]
    },
    "environment": "production",
    "server_name": "danielkono-o",
    "sdk": {
        "name": "sentry.python",
        "version": "1.22.2",
        "packages": [
            {
                "name": "pypi:sentry-sdk",
                "version": "1.22.2"
            }
        ],
        "integrations": [
            "argv",
            "atexit",
            "dedupe",
            "excepthook",
            "logging",
            "modules",
            "redis",
            "stdlib",
            "threading"
        ]
    },
    "platform": "python"
}
Sentry transaction status: internal_error
Otel transaction status: None
```


Discussion:
Hey @daniil-konovalenko !

Thanks for the report and the code to reproduce this. This will be a big help in fixing this!

Resolving patch:
diff --git a/sentry_sdk/integrations/opentelemetry/span_processor.py b/sentry_sdk/integrations/opentelemetry/span_processor.py
--- a/sentry_sdk/integrations/opentelemetry/span_processor.py
+++ b/sentry_sdk/integrations/opentelemetry/span_processor.py
@@ -162,6 +162,8 @@ def on_end(self, otel_span):
 
         sentry_span.op = otel_span.name
 
+        self._update_span_with_otel_status(sentry_span, otel_span)
+
         if isinstance(sentry_span, Transaction):
             sentry_span.name = otel_span.name
             sentry_span.set_context(
@@ -234,6 +236,20 @@ def _get_trace_data(self, otel_span, parent_context):
 
         return trace_data
 
+    def _update_span_with_otel_status(self, sentry_span, otel_span):
+        # type: (SentrySpan, OTelSpan) -> None
+        """
+        Set the Sentry span status from the OTel span
+        """
+        if otel_span.status.is_unset:
+            return
+
+        if otel_span.status.is_ok:
+            sentry_span.set_status("ok")
+            return
+
+        sentry_span.set_status("internal_error")
+
     def _update_span_with_otel_data(self, sentry_span, otel_span):
         # type: (SentrySpan, OTelSpan) -> None
         """


Test patch:
diff --git a/tests/integrations/opentelemetry/test_span_processor.py b/tests/integrations/opentelemetry/test_span_processor.py
--- a/tests/integrations/opentelemetry/test_span_processor.py
+++ b/tests/integrations/opentelemetry/test_span_processor.py
@@ -1,5 +1,6 @@
 from datetime import datetime
 import time
+import pytest
 
 try:
     from unittest import mock  # python 3.3 and above
@@ -14,7 +15,7 @@
 )
 from sentry_sdk.tracing import Span, Transaction
 
-from opentelemetry.trace import SpanKind, SpanContext
+from opentelemetry.trace import SpanKind, SpanContext, Status, StatusCode
 from sentry_sdk.tracing_utils import extract_sentrytrace_data
 
 
@@ -218,6 +219,28 @@ def test_update_span_with_otel_data_http_method():
     assert sentry_span._data["http.target"] == "/"
 
 
+@pytest.mark.parametrize(
+    "otel_status, expected_status",
+    [
+        pytest.param(Status(StatusCode.UNSET), None, id="unset"),
+        pytest.param(Status(StatusCode.OK), "ok", id="ok"),
+        pytest.param(Status(StatusCode.ERROR), "internal_error", id="error"),
+    ],
+)
+def test_update_span_with_otel_status(otel_status, expected_status):
+    sentry_span = Span()
+
+    otel_span = MagicMock()
+    otel_span.name = "Test OTel Span"
+    otel_span.kind = SpanKind.INTERNAL
+    otel_span.status = otel_status
+
+    span_processor = SentrySpanProcessor()
+    span_processor._update_span_with_otel_status(sentry_span, otel_span)
+
+    assert sentry_span.get_trace_context().get("status") == expected_status
+
+
 def test_update_span_with_otel_data_http_method2():
     sentry_span = Span()
 
@@ -394,6 +417,7 @@ def test_on_end_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),
@@ -414,6 +438,7 @@ def test_on_end_sentry_transaction():
 
     fake_sentry_span.set_context.assert_called_once()
     span_processor._update_span_with_otel_data.assert_not_called()
+    fake_sentry_span.set_status.assert_called_once_with("ok")
     fake_sentry_span.finish.assert_called_once()
 
 
@@ -424,6 +449,7 @@ def test_on_end_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),
@@ -446,6 +472,7 @@ def test_on_end_sentry_span():
     span_processor._update_span_with_otel_data.assert_called_once_with(
         fake_sentry_span, otel_span
     )
+    fake_sentry_span.set_status.assert_called_once_with("ok")
     fake_sentry_span.finish.assert_called_once()