add service layer OTel Span (#28582)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
heyszt
2025-12-05 21:58:32 +08:00
committed by GitHub
parent 72f83c010f
commit 10b59cd6ba
24 changed files with 1226 additions and 151 deletions

View File

@@ -0,0 +1,96 @@
"""
Shared fixtures for OTel tests.
Provides:
- Mock TracerProvider with MemorySpanExporter
- Mock configurations
- Test data factories
"""
from unittest.mock import MagicMock, create_autospec
import pytest
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
from opentelemetry.trace import set_tracer_provider
@pytest.fixture
def memory_span_exporter():
"""Provide an in-memory span exporter for testing."""
return InMemorySpanExporter()
@pytest.fixture
def tracer_provider_with_memory_exporter(memory_span_exporter):
"""Provide a TracerProvider configured with memory exporter."""
import opentelemetry.trace as trace_api
trace_api._TRACER_PROVIDER = None
trace_api._TRACER_PROVIDER_SET_ONCE._done = False
provider = TracerProvider()
processor = SimpleSpanProcessor(memory_span_exporter)
provider.add_span_processor(processor)
set_tracer_provider(provider)
yield provider
provider.force_flush()
@pytest.fixture
def mock_app_model():
"""Create a mock App model."""
app = MagicMock()
app.id = "test-app-id"
app.tenant_id = "test-tenant-id"
return app
@pytest.fixture
def mock_account_user():
"""Create a mock Account user."""
from models.model import Account
user = create_autospec(Account, instance=True)
user.id = "test-user-id"
return user
@pytest.fixture
def mock_end_user():
"""Create a mock EndUser."""
from models.model import EndUser
user = create_autospec(EndUser, instance=True)
user.id = "test-end-user-id"
return user
@pytest.fixture
def mock_workflow_runner():
"""Create a mock WorkflowAppRunner."""
runner = MagicMock()
runner.application_generate_entity = MagicMock()
runner.application_generate_entity.user_id = "test-user-id"
runner.application_generate_entity.stream = True
runner.application_generate_entity.app_config = MagicMock()
runner.application_generate_entity.app_config.app_id = "test-app-id"
runner.application_generate_entity.app_config.tenant_id = "test-tenant-id"
runner.application_generate_entity.app_config.workflow_id = "test-workflow-id"
return runner
@pytest.fixture(autouse=True)
def reset_handler_instances():
"""Reset handler singleton instances before each test."""
from extensions.otel.decorators.base import _HANDLER_INSTANCES
_HANDLER_INSTANCES.clear()
from extensions.otel.decorators.handler import SpanHandler
_HANDLER_INSTANCES[SpanHandler] = SpanHandler()
yield
_HANDLER_INSTANCES.clear()

View File

@@ -0,0 +1,92 @@
"""
Tests for AppGenerateHandler.
Test objectives:
1. Verify handler compatibility with real function signature (fails when parameters change)
2. Verify span attribute mapping correctness
"""
from unittest.mock import patch
from core.app.entities.app_invoke_entities import InvokeFrom
from extensions.otel.decorators.handlers.generate_handler import AppGenerateHandler
from extensions.otel.semconv import DifySpanAttributes, GenAIAttributes
class TestAppGenerateHandler:
"""Core tests for AppGenerateHandler"""
@patch("extensions.otel.decorators.base.dify_config.ENABLE_OTEL", True)
def test_compatible_with_real_function_signature(
self, tracer_provider_with_memory_exporter, mock_app_model, mock_account_user
):
"""
Verify handler compatibility with real AppGenerateService.generate signature.
If AppGenerateService.generate parameters change, this test will fail,
prompting developers to update the handler's parameter extraction logic.
"""
from services.app_generate_service import AppGenerateService
handler = AppGenerateHandler()
kwargs = {
"app_model": mock_app_model,
"user": mock_account_user,
"args": {"workflow_id": "test-wf-123"},
"invoke_from": InvokeFrom.DEBUGGER,
"streaming": True,
"root_node_id": None,
}
arguments = handler._extract_arguments(AppGenerateService.generate, (), kwargs)
assert arguments is not None, "Failed to extract arguments from AppGenerateService.generate"
assert "app_model" in arguments, "Handler uses app_model but parameter is missing"
assert "user" in arguments, "Handler uses user but parameter is missing"
assert "args" in arguments, "Handler uses args but parameter is missing"
assert "streaming" in arguments, "Handler uses streaming but parameter is missing"
@patch("extensions.otel.decorators.base.dify_config.ENABLE_OTEL", True)
def test_all_span_attributes_set_correctly(
self, tracer_provider_with_memory_exporter, memory_span_exporter, mock_app_model, mock_account_user
):
"""Verify all span attributes are mapped correctly"""
handler = AppGenerateHandler()
tracer = tracer_provider_with_memory_exporter.get_tracer(__name__)
test_app_id = "app-456"
test_tenant_id = "tenant-789"
test_user_id = "user-111"
test_workflow_id = "wf-222"
mock_app_model.id = test_app_id
mock_app_model.tenant_id = test_tenant_id
mock_account_user.id = test_user_id
def dummy_func(app_model, user, args, invoke_from, streaming=True):
return "result"
handler.wrapper(
tracer,
dummy_func,
(),
{
"app_model": mock_app_model,
"user": mock_account_user,
"args": {"workflow_id": test_workflow_id},
"invoke_from": InvokeFrom.DEBUGGER,
"streaming": False,
},
)
spans = memory_span_exporter.get_finished_spans()
assert len(spans) == 1
attrs = spans[0].attributes
assert attrs[DifySpanAttributes.APP_ID] == test_app_id
assert attrs[DifySpanAttributes.TENANT_ID] == test_tenant_id
assert attrs[GenAIAttributes.USER_ID] == test_user_id
assert attrs[DifySpanAttributes.WORKFLOW_ID] == test_workflow_id
assert attrs[DifySpanAttributes.USER_TYPE] == "Account"
assert attrs[DifySpanAttributes.STREAMING] is False

View File

@@ -0,0 +1,76 @@
"""
Tests for WorkflowAppRunnerHandler.
Test objectives:
1. Verify handler compatibility with real WorkflowAppRunner structure (fails when structure changes)
2. Verify span attribute mapping correctness
"""
from unittest.mock import patch
from extensions.otel.decorators.handlers.workflow_app_runner_handler import WorkflowAppRunnerHandler
from extensions.otel.semconv import DifySpanAttributes, GenAIAttributes
class TestWorkflowAppRunnerHandler:
"""Core tests for WorkflowAppRunnerHandler"""
def test_handler_structure_dependencies(self):
"""
Verify handler dependencies on WorkflowAppRunner structure.
Handler depends on:
- runner.application_generate_entity (WorkflowAppGenerateEntity)
- entity.app_config (WorkflowAppConfig)
- entity.user_id, entity.stream
- app_config.app_id, app_config.tenant_id, app_config.workflow_id
If these attribute paths change in real types, this test will fail,
prompting developers to update the handler's attribute access logic.
"""
from core.app.app_config.entities import WorkflowUIBasedAppConfig
from core.app.entities.app_invoke_entities import WorkflowAppGenerateEntity
required_entity_fields = ["user_id", "stream", "app_config"]
entity_fields = WorkflowAppGenerateEntity.model_fields
for field in required_entity_fields:
assert field in entity_fields, f"Handler expects WorkflowAppGenerateEntity.{field} but field is missing"
required_config_fields = ["app_id", "tenant_id", "workflow_id"]
config_fields = WorkflowUIBasedAppConfig.model_fields
for field in required_config_fields:
assert field in config_fields, f"Handler expects app_config.{field} but field is missing"
@patch("extensions.otel.decorators.base.dify_config.ENABLE_OTEL", True)
def test_all_span_attributes_set_correctly(
self, tracer_provider_with_memory_exporter, memory_span_exporter, mock_workflow_runner
):
"""Verify all span attributes are mapped correctly"""
handler = WorkflowAppRunnerHandler()
tracer = tracer_provider_with_memory_exporter.get_tracer(__name__)
test_app_id = "app-999"
test_tenant_id = "tenant-888"
test_user_id = "user-777"
test_workflow_id = "wf-666"
mock_workflow_runner.application_generate_entity.user_id = test_user_id
mock_workflow_runner.application_generate_entity.stream = False
mock_workflow_runner.application_generate_entity.app_config.app_id = test_app_id
mock_workflow_runner.application_generate_entity.app_config.tenant_id = test_tenant_id
mock_workflow_runner.application_generate_entity.app_config.workflow_id = test_workflow_id
def runner_run(self):
return "result"
handler.wrapper(tracer, runner_run, (mock_workflow_runner,), {})
spans = memory_span_exporter.get_finished_spans()
assert len(spans) == 1
attrs = spans[0].attributes
assert attrs[DifySpanAttributes.APP_ID] == test_app_id
assert attrs[DifySpanAttributes.TENANT_ID] == test_tenant_id
assert attrs[GenAIAttributes.USER_ID] == test_user_id
assert attrs[DifySpanAttributes.WORKFLOW_ID] == test_workflow_id
assert attrs[DifySpanAttributes.STREAMING] is False

View File

@@ -0,0 +1,119 @@
"""
Tests for trace_span decorator.
Test coverage:
- Decorator basic functionality
- Enable/disable logic
- Handler singleton management
- Integration with OpenTelemetry SDK
"""
from unittest.mock import patch
import pytest
from opentelemetry.trace import StatusCode
from extensions.otel.decorators.base import trace_span
class TestTraceSpanDecorator:
"""Test trace_span decorator basic functionality."""
@patch("extensions.otel.decorators.base.dify_config.ENABLE_OTEL", True)
def test_decorated_function_executes_normally(self, tracer_provider_with_memory_exporter):
"""Test that decorated function executes and returns correct value."""
@trace_span()
def test_func(x, y):
return x + y
result = test_func(2, 3)
assert result == 5
@patch("extensions.otel.decorators.base.dify_config.ENABLE_OTEL", True)
def test_decorator_with_args_and_kwargs(self, tracer_provider_with_memory_exporter):
"""Test that decorator correctly handles args and kwargs."""
@trace_span()
def test_func(a, b, c=10):
return a + b + c
result = test_func(1, 2, c=3)
assert result == 6
class TestTraceSpanWithMemoryExporter:
"""Test trace_span with MemorySpanExporter to verify span creation."""
@patch("extensions.otel.decorators.base.dify_config.ENABLE_OTEL", True)
def test_span_is_created_and_exported(self, tracer_provider_with_memory_exporter, memory_span_exporter):
"""Test that span is created and exported to memory exporter."""
@trace_span()
def test_func():
return "result"
test_func()
spans = memory_span_exporter.get_finished_spans()
assert len(spans) == 1
@patch("extensions.otel.decorators.base.dify_config.ENABLE_OTEL", True)
def test_span_name_matches_function(self, tracer_provider_with_memory_exporter, memory_span_exporter):
"""Test that span name matches the decorated function."""
@trace_span()
def my_test_function():
return "result"
my_test_function()
spans = memory_span_exporter.get_finished_spans()
assert len(spans) == 1
assert "my_test_function" in spans[0].name
@patch("extensions.otel.decorators.base.dify_config.ENABLE_OTEL", True)
def test_span_status_is_ok_on_success(self, tracer_provider_with_memory_exporter, memory_span_exporter):
"""Test that span status is OK when function succeeds."""
@trace_span()
def test_func():
return "result"
test_func()
spans = memory_span_exporter.get_finished_spans()
assert len(spans) == 1
assert spans[0].status.status_code == StatusCode.OK
@patch("extensions.otel.decorators.base.dify_config.ENABLE_OTEL", True)
def test_span_status_is_error_on_exception(self, tracer_provider_with_memory_exporter, memory_span_exporter):
"""Test that span status is ERROR when function raises exception."""
@trace_span()
def test_func():
raise ValueError("test error")
with pytest.raises(ValueError, match="test error"):
test_func()
spans = memory_span_exporter.get_finished_spans()
assert len(spans) == 1
assert spans[0].status.status_code == StatusCode.ERROR
@patch("extensions.otel.decorators.base.dify_config.ENABLE_OTEL", True)
def test_exception_is_recorded_in_span(self, tracer_provider_with_memory_exporter, memory_span_exporter):
"""Test that exception details are recorded in span events."""
@trace_span()
def test_func():
raise ValueError("test error")
with pytest.raises(ValueError):
test_func()
spans = memory_span_exporter.get_finished_spans()
assert len(spans) == 1
events = spans[0].events
assert len(events) > 0
assert any("exception" in event.name.lower() for event in events)

View File

@@ -0,0 +1,258 @@
"""
Tests for SpanHandler base class.
Test coverage:
- _build_span_name method
- _extract_arguments method
- wrapper method default implementation
- Signature caching
"""
from unittest.mock import patch
import pytest
from opentelemetry.trace import StatusCode
from extensions.otel.decorators.handler import SpanHandler
class TestSpanHandlerExtractArguments:
"""Test SpanHandler._extract_arguments method."""
def test_extract_positional_arguments(self):
"""Test extracting positional arguments."""
handler = SpanHandler()
def func(a, b, c):
pass
args = (1, 2, 3)
kwargs = {}
result = handler._extract_arguments(func, args, kwargs)
assert result is not None
assert result["a"] == 1
assert result["b"] == 2
assert result["c"] == 3
def test_extract_keyword_arguments(self):
"""Test extracting keyword arguments."""
handler = SpanHandler()
def func(a, b, c):
pass
args = ()
kwargs = {"a": 1, "b": 2, "c": 3}
result = handler._extract_arguments(func, args, kwargs)
assert result is not None
assert result["a"] == 1
assert result["b"] == 2
assert result["c"] == 3
def test_extract_mixed_arguments(self):
"""Test extracting mixed positional and keyword arguments."""
handler = SpanHandler()
def func(a, b, c):
pass
args = (1,)
kwargs = {"b": 2, "c": 3}
result = handler._extract_arguments(func, args, kwargs)
assert result is not None
assert result["a"] == 1
assert result["b"] == 2
assert result["c"] == 3
def test_extract_arguments_with_defaults(self):
"""Test extracting arguments with default values."""
handler = SpanHandler()
def func(a, b=10, c=20):
pass
args = (1,)
kwargs = {}
result = handler._extract_arguments(func, args, kwargs)
assert result is not None
assert result["a"] == 1
assert result["b"] == 10
assert result["c"] == 20
def test_extract_arguments_handles_self(self):
"""Test extracting arguments from instance method (with self)."""
handler = SpanHandler()
class MyClass:
def method(self, a, b):
pass
instance = MyClass()
args = (1, 2)
kwargs = {}
result = handler._extract_arguments(instance.method, args, kwargs)
assert result is not None
assert result["a"] == 1
assert result["b"] == 2
def test_extract_arguments_returns_none_on_error(self):
"""Test that _extract_arguments returns None when extraction fails."""
handler = SpanHandler()
def func(a, b):
pass
args = (1,)
kwargs = {}
result = handler._extract_arguments(func, args, kwargs)
assert result is None
def test_signature_caching(self):
"""Test that function signatures are cached."""
handler = SpanHandler()
def func(a, b):
pass
assert func not in handler._signature_cache
handler._extract_arguments(func, (1, 2), {})
assert func in handler._signature_cache
cached_sig = handler._signature_cache[func]
handler._extract_arguments(func, (3, 4), {})
assert handler._signature_cache[func] is cached_sig
class TestSpanHandlerWrapper:
"""Test SpanHandler.wrapper default implementation."""
@patch("extensions.otel.decorators.base.dify_config.ENABLE_OTEL", True)
def test_wrapper_creates_span(self, tracer_provider_with_memory_exporter, memory_span_exporter):
"""Test that wrapper creates a span."""
handler = SpanHandler()
tracer = tracer_provider_with_memory_exporter.get_tracer(__name__)
def test_func():
return "result"
result = handler.wrapper(tracer, test_func, (), {})
assert result == "result"
spans = memory_span_exporter.get_finished_spans()
assert len(spans) == 1
@patch("extensions.otel.decorators.base.dify_config.ENABLE_OTEL", True)
def test_wrapper_sets_span_kind_internal(self, tracer_provider_with_memory_exporter, memory_span_exporter):
"""Test that wrapper sets SpanKind to INTERNAL."""
from opentelemetry.trace import SpanKind
handler = SpanHandler()
tracer = tracer_provider_with_memory_exporter.get_tracer(__name__)
def test_func():
return "result"
handler.wrapper(tracer, test_func, (), {})
spans = memory_span_exporter.get_finished_spans()
assert len(spans) == 1
assert spans[0].kind == SpanKind.INTERNAL
@patch("extensions.otel.decorators.base.dify_config.ENABLE_OTEL", True)
def test_wrapper_sets_status_ok_on_success(self, tracer_provider_with_memory_exporter, memory_span_exporter):
"""Test that wrapper sets status to OK when function succeeds."""
handler = SpanHandler()
tracer = tracer_provider_with_memory_exporter.get_tracer(__name__)
def test_func():
return "result"
handler.wrapper(tracer, test_func, (), {})
spans = memory_span_exporter.get_finished_spans()
assert len(spans) == 1
assert spans[0].status.status_code == StatusCode.OK
@patch("extensions.otel.decorators.base.dify_config.ENABLE_OTEL", True)
def test_wrapper_records_exception_on_error(self, tracer_provider_with_memory_exporter, memory_span_exporter):
"""Test that wrapper records exception when function raises."""
handler = SpanHandler()
tracer = tracer_provider_with_memory_exporter.get_tracer(__name__)
def test_func():
raise ValueError("test error")
with pytest.raises(ValueError, match="test error"):
handler.wrapper(tracer, test_func, (), {})
spans = memory_span_exporter.get_finished_spans()
assert len(spans) == 1
events = spans[0].events
assert len(events) > 0
assert any("exception" in event.name.lower() for event in events)
@patch("extensions.otel.decorators.base.dify_config.ENABLE_OTEL", True)
def test_wrapper_sets_status_error_on_exception(self, tracer_provider_with_memory_exporter, memory_span_exporter):
"""Test that wrapper sets status to ERROR when function raises exception."""
handler = SpanHandler()
tracer = tracer_provider_with_memory_exporter.get_tracer(__name__)
def test_func():
raise ValueError("test error")
with pytest.raises(ValueError):
handler.wrapper(tracer, test_func, (), {})
spans = memory_span_exporter.get_finished_spans()
assert len(spans) == 1
assert spans[0].status.status_code == StatusCode.ERROR
assert "test error" in spans[0].status.description
@patch("extensions.otel.decorators.base.dify_config.ENABLE_OTEL", True)
def test_wrapper_re_raises_exception(self, tracer_provider_with_memory_exporter):
"""Test that wrapper re-raises exception after recording it."""
handler = SpanHandler()
tracer = tracer_provider_with_memory_exporter.get_tracer(__name__)
def test_func():
raise ValueError("test error")
with pytest.raises(ValueError, match="test error"):
handler.wrapper(tracer, test_func, (), {})
@patch("extensions.otel.decorators.base.dify_config.ENABLE_OTEL", True)
def test_wrapper_passes_arguments_correctly(self, tracer_provider_with_memory_exporter, memory_span_exporter):
"""Test that wrapper correctly passes arguments to wrapped function."""
handler = SpanHandler()
tracer = tracer_provider_with_memory_exporter.get_tracer(__name__)
def test_func(a, b, c=10):
return a + b + c
result = handler.wrapper(tracer, test_func, (1, 2), {"c": 3})
assert result == 6
@patch("extensions.otel.decorators.base.dify_config.ENABLE_OTEL", True)
def test_wrapper_with_memory_exporter(self, tracer_provider_with_memory_exporter, memory_span_exporter):
"""Test wrapper end-to-end with memory exporter."""
handler = SpanHandler()
tracer = tracer_provider_with_memory_exporter.get_tracer(__name__)
def my_function(x):
return x * 2
result = handler.wrapper(tracer, my_function, (5,), {})
assert result == 10
spans = memory_span_exporter.get_finished_spans()
assert len(spans) == 1
assert "my_function" in spans[0].name
assert spans[0].status.status_code == StatusCode.OK