Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions converter/converter/converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,14 @@
)
from converter.logging_config import configure_logging, LoggingKeys
from converter.database import init_db, get_db
from converter.tracing import configure_tracing, tag_current_span

configure_logging()

app = Flask(__name__)
# Configure tracing BEFORE init_db: PymongoInstrumentor patches MongoClient.__init__, so it
# must run before init_db() creates the client, otherwise MongoDB commands emit no spans.
configure_tracing(app)
init_db(app)

multiproc_dir = os.getenv("PROMETHEUS_MULTIPROC_DIR")
Expand Down Expand Up @@ -80,6 +84,9 @@ def convert():
except Exception:
pass

# Label the trace span independently, so a malformed field above never prevents tagging.
tag_current_span(edxl_json)

if not source_version or not target_version or not edxl_json:
return raise_error(
f"Missing required fields: sourceVersion={source_version}, targetVersion={target_version}, edxl present={edxl_json is not None}"
Expand Down
77 changes: 77 additions & 0 deletions converter/converter/tracing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import logging
import os

logger = logging.getLogger(__name__)

# Span attributes, kept in sync with the dispatcher's labels so a message can be
# followed across both services.
CASE_ID_ATTRIBUTE = "hubsante.case_id"
SENDER_ATTRIBUTE = "hubsante.sender"
RECIPIENT_ATTRIBUTE = "hubsante.recipient"
USE_CASE_ATTRIBUTE = "hubsante.use_case"


def is_tracing_enabled() -> bool:
return os.getenv("OTEL_SDK_DISABLED", "false").strip().lower() != "true"


def tag_current_span(edxl_json) -> None:
from opentelemetry import trace

from converter.utils import (
extract_case_id,
extract_message_content,
extract_message_type_from_message_content,
get_recipient,
get_sender,
)

span = trace.get_current_span()

def use_case():
return extract_message_type_from_message_content(
extract_message_content(edxl_json)
)

for attribute, getter in (
(SENDER_ATTRIBUTE, lambda: get_sender(edxl_json)),
(RECIPIENT_ATTRIBUTE, lambda: get_recipient(edxl_json)),
(USE_CASE_ATTRIBUTE, use_case),
(CASE_ID_ATTRIBUTE, lambda: extract_case_id(edxl_json)),
):
try:
value = getter()
except Exception:
value = None
if value:
span.set_attribute(attribute, value)


def configure_tracing(app) -> None:
if not is_tracing_enabled():
logger.info("OpenTelemetry tracing disabled via OTEL_SDK_DISABLED")
return

from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.flask import FlaskInstrumentor
from opentelemetry.instrumentation.pymongo import PymongoInstrumentor
from opentelemetry.sdk.resources import SERVICE_NAME, SERVICE_VERSION, Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor

resource = Resource.create(
{
SERVICE_NAME: os.getenv("OTEL_SERVICE_NAME", "converter"),
SERVICE_VERSION: os.getenv("CONVERTER_VERSION", "unknown"),
}
)
provider = TracerProvider(resource=resource)
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
trace.set_tracer_provider(provider)

FlaskInstrumentor().instrument_app(app)
PymongoInstrumentor().instrument()
logger.info(
f"OpenTelemetry tracing configured for service '{resource.attributes.get(SERVICE_NAME)}'"
)
15 changes: 15 additions & 0 deletions converter/converter/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,21 @@ def extract_message_type_from_message_content(
return filtered_keys[0]


def extract_case_id(edxl_json) -> Optional[str]:
"""Best-effort extraction of the business caseId, used as a span label.

The caseId lives directly under the use-case object (e.g. ``resourcesInfo.caseId``).
Returns ``None`` when the message carries no caseId.
"""
try:
message_content = extract_message_content(edxl_json)
message_type = extract_message_type_from_message_content(message_content)
case_id = message_content.get(message_type, {}).get("caseId")
return case_id if isinstance(case_id, str) and case_id else None
except Exception:
return None


def delete_paths(data: Dict[str, Any], paths: List[str]) -> None:
"""
Safely deletes keys in a dictionary based on dot-separated paths.
Expand Down
4 changes: 4 additions & 0 deletions converter/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ dependencies = [
"pyyaml>=6.0.2",
"typing-extensions>=4.15.0",
"werkzeug>=3.1.3",
"opentelemetry-sdk>=1.43.0",
"opentelemetry-exporter-otlp-proto-http>=1.43.0",
"opentelemetry-instrumentation-flask>=0.64b0",
"opentelemetry-instrumentation-pymongo>=0.64b0",
]

[tool.pytest.ini_options]
Expand Down
5 changes: 5 additions & 0 deletions converter/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import os
from unittest.mock import MagicMock, patch

# Disable OpenTelemetry tracing during tests (no exporter connections, no Flask
# instrumentation). Must be set before the app module is imported below.
os.environ.setdefault("OTEL_SDK_DISABLED", "true")

# Patch init_db before the app module is imported, so it doesn't attempt
# to connect to MongoDB during tests.
with patch("converter.database.init_db") as mock_init_db:
Expand Down
24 changes: 24 additions & 0 deletions converter/tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
translate_key_words,
update_json_value,
set_value,
extract_case_id,
extract_message_type_from_message_content,
switch_field_name,
)
Expand Down Expand Up @@ -71,6 +72,29 @@ def test_format_object_nested():
assert result == expected


def _edxl_with_message(message):
return {"content": [{"jsonContent": {"embeddedJsonContent": {"message": message}}}]}


def test_extract_case_id_present():
edxl = _edxl_with_message({"resourcesInfo": {"caseId": "case-123"}})
assert extract_case_id(edxl) == "case-123"


def test_extract_case_id_absent():
edxl = _edxl_with_message({"technicalNoreq": {"optionalStringField": "value"}})
assert extract_case_id(edxl) is None


def test_extract_case_id_empty_string():
edxl = _edxl_with_message({"resourcesInfo": {"caseId": ""}})
assert extract_case_id(edxl) is None


def test_extract_case_id_malformed_payload():
assert extract_case_id({}) is None


class TestDeletePaths(unittest.TestCase):
def test_delete_paths(self):
data = {"a": {"b": {"c": 1, "d": 2}, "e": 3}, "f": 4}
Expand Down
Loading
Loading