Files
comet-ml--opik/sdks/python/tests/unit/integrations/otel/test_processor.py
T
wehub-resource-sync 5a558eb09e
TypeScript SDK Compatibility V1.x E2E Tests / Select Node version matrix (push) Has been cancelled
TypeScript SDK Compatibility V1.x E2E Tests / TypeScript SDK Compatibility V1.x E2E Tests Node ${{matrix.node_version}} (push) Has been cancelled
TypeScript SDK E2E Tests / TypeScript SDK E2E Tests Node ${{matrix.node_version}} (push) Has been cancelled
Opik Optimizer - E2E Tests / build-opik (push) Has been cancelled
TypeScript SDK Compatibility V1.x E2E Tests / build-opik (push) Has been cancelled
Python SDK E2E Tests / Select Python version matrix (push) Has been cancelled
Python SDK E2E Tests / Python SDK E2E Tests ${{matrix.python_version}} (push) Has been cancelled
Python SDK E2E Tests / build-opik (push) Has been cancelled
Python SDK Compatibility V1.x E2E Tests / Select Python version matrix (push) Has been cancelled
Python SDK Compatibility V1.x E2E Tests / Python SDK Compatibility V1.x E2E Tests ${{matrix.python_version}} (push) Has been cancelled
Python SDK Compatibility V1.x E2E Tests / build-opik (push) Has been cancelled
TypeScript SDK E2E Tests / Select Node version matrix (push) Has been cancelled
TypeScript SDK E2E Tests / build-opik (push) Has been cancelled
Opik Optimizer - E2E Tests / Opik Optimizer E2E Tests Python ${{matrix.python_version}} (push) Has been cancelled
Opik Optimizer - E2E Tests / Opik Optimizer Integration Smoke Tests (push) Has been cancelled
🐙 Code Quality / detect (push) Has been cancelled
🐙 Code Quality / lint (${{ matrix.leg.name }}) (push) Has been cancelled
🐙 Code Quality / summary (push) Has been cancelled
TypeScript SDK Library Integration Tests / Check Secrets (push) Has been cancelled
TypeScript SDK Library Integration Tests / opik-vercel (Vercel AI SDK / eve) (push) Has been cancelled
SDK Library Integration Tests Runner / Check Secrets (push) Has been cancelled
SDK Library Integration Tests Runner / Missed OpenAI API Key Warning (push) Has been cancelled
SDK Library Integration Tests Runner / Build (push) Has been cancelled
SDK Library Integration Tests Runner / openai_tests (push) Has been cancelled
SDK Library Integration Tests Runner / langchain_tests (push) Has been cancelled
SDK Library Integration Tests Runner / langchain_legacy_tests (push) Has been cancelled
SDK Library Integration Tests Runner / llama_index_tests (push) Has been cancelled
SDK Library Integration Tests Runner / anthropic_tests (push) Has been cancelled
SDK Library Integration Tests Runner / mistral_tests (push) Has been cancelled
SDK Library Integration Tests Runner / groq_tests (push) Has been cancelled
SDK Library Integration Tests Runner / aisuite_tests (push) Has been cancelled
SDK Library Integration Tests Runner / haystack_tests (push) Has been cancelled
SDK Library Integration Tests Runner / dspy_tests (push) Has been cancelled
SDK Library Integration Tests Runner / crewai_v0_tests (push) Has been cancelled
SDK Library Integration Tests Runner / crewai_v1_tests (push) Has been cancelled
SDK Library Integration Tests Runner / genai_tests (push) Has been cancelled
SDK Library Integration Tests Runner / adk_tests (push) Has been cancelled
SDK Library Integration Tests Runner / adk_legacy_1_3_0_tests (push) Has been cancelled
SDK Library Integration Tests Runner / evaluation_metrics_tests (push) Has been cancelled
SDK Library Integration Tests Runner / bedrock_tests (push) Has been cancelled
SDK Library Integration Tests Runner / litellm_tests (push) Has been cancelled
SDK Library Integration Tests Runner / harbor_tests (push) Has been cancelled
SDK Library Integration Tests Runner / Slack Notification (push) Has been cancelled
Lint Opik Helm Chart / render-equality (push) Has been cancelled
Opik Optimizer - Unit Tests / Opik Optimizer Unit Tests Python ${{matrix.python_version}} (push) Has been cancelled
Python BE E2E Tests / Python BE E2E (push) Has been cancelled
Python Backend Tests / run-python-backend-tests (push) Has been cancelled
Python SDK Unit Tests / Python SDK Unit Tests ${{matrix.python_version}} (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
SDK E2E Libraries Integration Tests / Check Secrets (push) Has been cancelled
SDK E2E Libraries Integration Tests / Missed OpenAI API Key Warning (push) Has been cancelled
SDK E2E Libraries Integration Tests / build-opik (push) Has been cancelled
SDK E2E Libraries Integration Tests / E2E Lib Integration Python ${{matrix.python_version}} (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-gemini) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-langchain) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-openai) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-otel) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-vercel) (push) Has been cancelled
TypeScript SDK Build & Publish / build-and-publish (push) Has been cancelled
TypeScript SDK Unit Tests / Test on Node ${{ matrix.node-version }} (push) Has been cancelled
Backend Tests / discover-tests (push) Has been cancelled
Backend Tests / ${{ matrix.name }} (push) Has been cancelled
Build and Publish SDK / build-and-publish (push) Has been cancelled
Build Opik Docker Images / set-version (push) Has been cancelled
Build Opik Docker Images / build-backend (push) Has been cancelled
Build Opik Docker Images / build-sandbox-executor-python (push) Has been cancelled
Build Opik Docker Images / build-python-backend (push) Has been cancelled
Build Opik Docker Images / build-frontend (push) Has been cancelled
Build Opik Docker Images / create-git-tag (push) Has been cancelled
ClickHouse Migration Cluster Check / validate-clickhouse-migrations (push) Has been cancelled
Docs - Publish / run (push) Has been cancelled
E2E Tests - Post Merge (v2) / 🧪 E2E v2 Tests (${{ github.event.inputs.tier || 't1' }}) (push) Has been cancelled
E2E Tests - Post Merge (v2) / 📢 Slack Notification (push) Has been cancelled
Frontend Unit Tests / Test on Node 20 (push) Has been cancelled
Guardrails E2E Tests / Select Python version matrix (push) Has been cancelled
Guardrails E2E Tests / Guardrails E2E Tests ${{matrix.python_version}} (push) Has been cancelled
Guardrails E2E Tests / 📢 Slack Notification (push) Has been cancelled
Guardrails Backend Unit Tests / Guardrails Backend Unit Tests (push) Has been cancelled
Guardrails Backend Unit Tests / 📢 Slack Notification (push) Has been cancelled
Lint Opik Helm Chart / lint-helm-chart (Helm v3.21.0) (push) Has been cancelled
Lint Opik Helm Chart / lint-helm-chart (Helm v4.2.0) (push) Has been cancelled
Lint Opik Helm Chart / unittest-helm-chart (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:25:44 +08:00

424 lines
17 KiB
Python

import asyncio
import threading
import pytest
from opentelemetry import baggage, context as context_api
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import (
InMemorySpanExporter,
)
import opik
from opik import id_helpers, opik_context
from opik.integrations import otel
from opik.integrations.otel import attributes as otel_attributes
TRACE_ID = "0193b3a5-1234-7abc-9def-0123456789ab"
PARENT_SPAN_ID = "0193b3a5-5678-7abc-9def-0123456789cd"
@pytest.fixture
def tracer():
provider = TracerProvider()
provider.add_span_processor(otel.OpikSpanProcessor())
yield provider.get_tracer(__name__)
provider.shutdown()
@pytest.fixture
def inmemory_provider():
"""Provider that captures finished spans in memory, with OpikSpanProcessor
registered. Teardown via yield so shutdown runs even if a test fails."""
exporter = InMemorySpanExporter()
provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(exporter))
provider.add_span_processor(otel.OpikSpanProcessor())
yield provider, exporter
provider.shutdown()
class TestOpikSpanProcessor:
def test_on_start__parent_has_no_opik_attrs__leaves_span_and_child_untouched(
self, tracer
):
with tracer.start_as_current_span("parent") as span:
parent_attrs = dict(span.attributes or {})
with tracer.start_as_current_span("child") as child:
child_attrs = dict(child.attributes or {})
assert otel_attributes.OPIK_TRACE_ID not in parent_attrs
assert otel_attributes.OPIK_SPAN_ID not in parent_attrs
assert otel_attributes.OPIK_PARENT_SPAN_ID not in parent_attrs
assert otel_attributes.OPIK_TRACE_ID not in child_attrs
assert otel_attributes.OPIK_SPAN_ID not in child_attrs
assert otel_attributes.OPIK_PARENT_SPAN_ID not in child_attrs
def test_on_start__parent_has_full_triple__child_inherits_and_chains(self, tracer):
with tracer.start_as_current_span("parent") as parent:
# Simulate: parent already has the triple (e.g. propagated via attach_to_parent or
# an earlier processor pass).
parent.set_attribute(otel_attributes.OPIK_TRACE_ID, TRACE_ID)
parent.set_attribute(
otel_attributes.OPIK_SPAN_ID, "0193b3a5-aaaa-7abc-9def-000000000001"
)
with tracer.start_as_current_span("child") as child:
child_attrs = dict(child.attributes)
assert child_attrs[otel_attributes.OPIK_TRACE_ID] == TRACE_ID
# child gets a freshly minted opik.span_id (UUIDv7, distinct from parent's)
assert id_helpers.is_valid_uuid_v7(child_attrs[otel_attributes.OPIK_SPAN_ID])
assert (
child_attrs[otel_attributes.OPIK_SPAN_ID]
!= "0193b3a5-aaaa-7abc-9def-000000000001"
)
# child's opik.parent_span_id == parent's opik.span_id
assert (
child_attrs[otel_attributes.OPIK_PARENT_SPAN_ID]
== "0193b3a5-aaaa-7abc-9def-000000000001"
)
def test_on_start__parent_has_trace_id_without_span_id__no_inheritance(
self, tracer
):
# If a parent carries opik.trace_id without opik.span_id, the processor cannot
# chain a stable parent_span_id and refuses to guess. attach_to_parent always
# sets both, so this state only arises from a misconfigured upstream.
with tracer.start_as_current_span("parent") as parent:
parent.set_attribute(otel_attributes.OPIK_TRACE_ID, TRACE_ID)
parent.set_attribute(otel_attributes.OPIK_PARENT_SPAN_ID, PARENT_SPAN_ID)
with tracer.start_as_current_span("child") as child:
child_attrs = dict(child.attributes or {})
assert otel_attributes.OPIK_TRACE_ID not in child_attrs
assert otel_attributes.OPIK_SPAN_ID not in child_attrs
assert otel_attributes.OPIK_PARENT_SPAN_ID not in child_attrs
def test_on_start__baggage_carries_opik_ids__span_inherits_from_baggage(
self, tracer
):
ctx = baggage.set_baggage(otel_attributes.OPIK_TRACE_ID, TRACE_ID)
ctx = baggage.set_baggage(
otel_attributes.OPIK_SPAN_ID,
"0193b3a5-bbbb-7abc-9def-000000000002",
context=ctx,
)
token = context_api.attach(ctx)
try:
with tracer.start_as_current_span("from_baggage") as span:
span_attrs = dict(span.attributes)
finally:
context_api.detach(token)
assert span_attrs[otel_attributes.OPIK_TRACE_ID] == TRACE_ID
assert id_helpers.is_valid_uuid_v7(span_attrs[otel_attributes.OPIK_SPAN_ID])
assert (
span_attrs[otel_attributes.OPIK_PARENT_SPAN_ID]
== "0193b3a5-bbbb-7abc-9def-000000000002"
)
def test_on_start__baggage_only_trace_id__span_gets_trace_and_no_parent(
self, tracer
):
ctx = baggage.set_baggage(otel_attributes.OPIK_TRACE_ID, TRACE_ID)
token = context_api.attach(ctx)
try:
with tracer.start_as_current_span("from_baggage") as span:
span_attrs = dict(span.attributes)
finally:
context_api.detach(token)
assert span_attrs[otel_attributes.OPIK_TRACE_ID] == TRACE_ID
assert id_helpers.is_valid_uuid_v7(span_attrs[otel_attributes.OPIK_SPAN_ID])
assert otel_attributes.OPIK_PARENT_SPAN_ID not in span_attrs
class TestInProcessOpikTrackContextFallback:
"""When no parent span / baggage carries Opik IDs, the processor attaches the
OTel subtree to the active ``@opik.track`` span. This is what links libraries
that emit their own OTel spans (logfire / PydanticAI) to a tracked function.
"""
def test_on_start__no_active_opik_context__span_untouched(self, tracer):
with tracer.start_as_current_span("standalone") as span:
attrs = dict(span.attributes or {})
assert otel_attributes.OPIK_TRACE_ID not in attrs
assert otel_attributes.OPIK_SPAN_ID not in attrs
assert otel_attributes.OPIK_PARENT_SPAN_ID not in attrs
def test_on_start__inside_opik_track_context__attaches_to_tracked_span(
self, tracer, fake_backend
):
captured = {}
@opik.track(name="tracked")
def handler():
headers = opik_context.get_distributed_trace_headers()
captured["trace_id"] = headers["opik_trace_id"]
captured["span_id"] = headers["opik_parent_span_id"]
with tracer.start_as_current_span("otel-root") as span:
captured["attrs"] = dict(span.attributes or {})
handler()
opik.flush_tracker()
assert captured["attrs"][otel_attributes.OPIK_TRACE_ID] == captured["trace_id"]
assert (
captured["attrs"][otel_attributes.OPIK_PARENT_SPAN_ID]
== captured["span_id"]
)
# a fresh UUIDv7 is minted for the OTel span itself
assert id_helpers.is_valid_uuid_v7(
captured["attrs"][otel_attributes.OPIK_SPAN_ID]
)
assert captured["attrs"][otel_attributes.OPIK_SPAN_ID] != captured["span_id"]
def test_on_start__inside_opik_track_context__descendants_chain_through_root(
self, tracer, fake_backend
):
captured = {}
@opik.track(name="tracked")
def handler():
captured["span_id"] = opik_context.get_current_span_data().id
with tracer.start_as_current_span("otel-root") as root:
captured["root"] = dict(root.attributes or {})
with tracer.start_as_current_span("otel-child") as child:
captured["child"] = dict(child.attributes or {})
handler()
opik.flush_tracker()
# root attaches directly to the tracked span
assert (
captured["root"][otel_attributes.OPIK_PARENT_SPAN_ID] == captured["span_id"]
)
# child chains through the root's opik.span_id, not the tracked span
assert (
captured["child"][otel_attributes.OPIK_PARENT_SPAN_ID]
== captured["root"][otel_attributes.OPIK_SPAN_ID]
)
# all share the tracked trace
assert (
captured["child"][otel_attributes.OPIK_TRACE_ID]
== captured["root"][otel_attributes.OPIK_TRACE_ID]
)
def test_on_start__inside_async_opik_track_context__attaches_to_tracked_span(
self, tracer, fake_backend
):
captured = {}
@opik.track(name="tracked-async")
async def handler():
headers = opik_context.get_distributed_trace_headers()
captured["trace_id"] = headers["opik_trace_id"]
captured["span_id"] = headers["opik_parent_span_id"]
with tracer.start_as_current_span("otel-root") as span:
captured["attrs"] = dict(span.attributes or {})
asyncio.run(handler())
opik.flush_tracker()
assert captured["attrs"][otel_attributes.OPIK_TRACE_ID] == captured["trace_id"]
assert (
captured["attrs"][otel_attributes.OPIK_PARENT_SPAN_ID]
== captured["span_id"]
)
def test_on_start__multiple_tracers_sibling_roots__all_link_to_tracked_span(
self, inmemory_provider, fake_backend
):
# Generic OTel: spans from independent tracers (i.e. any OTel-based
# instrumentation, not just logfire) all attach to the tracked span.
provider, exporter = inmemory_provider
lib_a = provider.get_tracer("vendor.a")
lib_b = provider.get_tracer("framework.b")
captured = {}
@opik.track(name="tracked")
def handler():
captured["trace_id"] = opik_context.get_current_trace_data().id
captured["span_id"] = opik_context.get_current_span_data().id
with lib_a.start_as_current_span("a-root"):
pass
with lib_b.start_as_current_span("b-root"):
pass
handler()
opik.flush_tracker()
spans = exporter.get_finished_spans()
assert len(spans) == 2
for span in spans:
attrs = dict(span.attributes or {})
assert attrs[otel_attributes.OPIK_TRACE_ID] == captured["trace_id"]
# both are top-level OTel roots -> siblings directly under the tracked span
assert attrs[otel_attributes.OPIK_PARENT_SPAN_ID] == captured["span_id"]
def test_on_start__nested_opik_track__otel_attaches_to_innermost_span(
self, tracer, fake_backend
):
captured = {}
@opik.track(name="inner")
def inner():
captured["inner_span_id"] = opik_context.get_current_span_data().id
with tracer.start_as_current_span("otel") as span:
captured["attrs"] = dict(span.attributes or {})
@opik.track(name="outer")
def outer():
captured["outer_span_id"] = opik_context.get_current_span_data().id
inner()
outer()
opik.flush_tracker()
# OTel span attaches to the innermost active tracked span, not the outer one
assert (
captured["attrs"][otel_attributes.OPIK_PARENT_SPAN_ID]
== captured["inner_span_id"]
)
assert (
captured["attrs"][otel_attributes.OPIK_PARENT_SPAN_ID]
!= captured["outer_span_id"]
)
def test_on_start__baggage_present_inside_track_context__baggage_takes_precedence(
self, tracer, fake_backend
):
# A cross-process distributed context (baggage) represents an explicit
# upstream parent and must win over the local @opik.track context, so the
# in-process fallback never regresses the distributed-tracing flow.
captured = {}
ctx = baggage.set_baggage(otel_attributes.OPIK_TRACE_ID, TRACE_ID)
ctx = baggage.set_baggage(
otel_attributes.OPIK_SPAN_ID,
"0193b3a5-cccc-7abc-9def-000000000003",
context=ctx,
)
@opik.track(name="tracked")
def handler():
captured["track_trace_id"] = opik_context.get_current_trace_data().id
token = context_api.attach(ctx)
try:
with tracer.start_as_current_span("span") as span:
captured["attrs"] = dict(span.attributes or {})
finally:
context_api.detach(token)
handler()
opik.flush_tracker()
assert captured["attrs"][otel_attributes.OPIK_TRACE_ID] == TRACE_ID
assert (
captured["attrs"][otel_attributes.OPIK_TRACE_ID]
!= captured["track_trace_id"]
)
def test_on_start__invalid_baggage_inside_track_context__not_linked(
self, tracer, fake_backend
):
# A broken upstream distributed context must not be silently absorbed into
# the local @opik.track trace — the span is left standalone, not linked.
captured = {}
ctx = baggage.set_baggage(otel_attributes.OPIK_TRACE_ID, "not-a-uuid")
@opik.track(name="tracked")
def handler():
captured["track_trace_id"] = opik_context.get_current_trace_data().id
token = context_api.attach(ctx)
try:
with tracer.start_as_current_span("span") as span:
captured["attrs"] = dict(span.attributes or {})
finally:
context_api.detach(token)
handler()
opik.flush_tracker()
assert otel_attributes.OPIK_TRACE_ID not in captured["attrs"]
assert otel_attributes.OPIK_PARENT_SPAN_ID not in captured["attrs"]
def test_on_start__partial_baggage_inside_track_context__not_linked(
self, tracer, fake_backend
):
# Partial distributed context — opik.span_id present in baggage without
# opik.trace_id — must NOT be absorbed into the local @opik.track trace.
captured = {}
ctx = baggage.set_baggage(
otel_attributes.OPIK_SPAN_ID, "0193b3a5-dddd-7abc-9def-000000000004"
)
@opik.track(name="tracked")
def handler():
token = context_api.attach(ctx)
try:
with tracer.start_as_current_span("span") as span:
captured["attrs"] = dict(span.attributes or {})
finally:
context_api.detach(token)
handler()
opik.flush_tracker()
assert otel_attributes.OPIK_TRACE_ID not in captured["attrs"]
assert otel_attributes.OPIK_PARENT_SPAN_ID not in captured["attrs"]
def test_on_start__otel_span_started_in_worker_thread__not_linked(
self, tracer, fake_backend
):
# The fallback reads Opik's contextvars; a bare worker thread does not
# inherit them. The span gracefully falls back to today's standalone
# behavior instead of linking. Documents the boundary of the fallback.
captured = {}
@opik.track(name="tracked")
def handler():
def worker():
with tracer.start_as_current_span("in-thread") as span:
captured["attrs"] = dict(span.attributes or {})
thread = threading.Thread(target=worker)
thread.start()
thread.join()
handler()
opik.flush_tracker()
assert otel_attributes.OPIK_TRACE_ID not in captured["attrs"]
assert otel_attributes.OPIK_PARENT_SPAN_ID not in captured["attrs"]
class TestAttachToParentWithProcessor:
def test_attach_to_parent__deep_chain_keeps_each_level_linked(self, tracer):
headers = {"opik_trace_id": TRACE_ID, "opik_parent_span_id": PARENT_SPAN_ID}
with tracer.start_as_current_span("boundary") as boundary:
ok = otel.attach_to_parent(boundary, headers)
assert ok is True
boundary_span_id = boundary.attributes[otel_attributes.OPIK_SPAN_ID]
with tracer.start_as_current_span("level1") as l1:
l1_attrs = dict(l1.attributes)
with tracer.start_as_current_span("level2") as l2:
l2_attrs = dict(l2.attributes)
# Each level should reference its immediate parent's opik.span_id.
assert l1_attrs[otel_attributes.OPIK_PARENT_SPAN_ID] == boundary_span_id
l1_span_id = l1_attrs[otel_attributes.OPIK_SPAN_ID]
assert l2_attrs[otel_attributes.OPIK_PARENT_SPAN_ID] == l1_span_id
# All share the same trace.
assert l1_attrs[otel_attributes.OPIK_TRACE_ID] == TRACE_ID
assert l2_attrs[otel_attributes.OPIK_TRACE_ID] == TRACE_ID
# All is valid UUIDv7.
assert id_helpers.is_valid_uuid_v7(boundary_span_id)
assert id_helpers.is_valid_uuid_v7(l1_span_id)
assert id_helpers.is_valid_uuid_v7(l2_attrs[otel_attributes.OPIK_SPAN_ID])