mirror of
https://github.com/langgenius/dify.git
synced 2026-10-16 15:35:32 +08:00
feat(agent): export agent trajectory with a separated tracer (#42651)
This commit is contained in:
@@ -107,3 +107,4 @@ DIFY_AGENT_OUTBOUND_HTTP_KEEPALIVE_EXPIRY=30
|
||||
DIFY_AGENT_LOCAL_SANDBOX_MATERIALIZED_HOME_ROOT=/home/dify
|
||||
DIFY_AGENT_LOCAL_SANDBOX_WORKSPACE_ROOT=/workspace
|
||||
DIFY_AGENT_LOCAL_SANDBOX_HOME_SNAPSHOT_ROOT=/home/dify/.snapshots
|
||||
LOGFIRE_CONSOLE=false
|
||||
|
||||
@@ -81,6 +81,43 @@ in [E2B runtime metering](runtime-metering.md).
|
||||
| `DIFY_AGENT_OUTBOUND_HTTP_MAX_CONNECTIONS` | `100` | Maximum total shared outbound HTTP connections. |
|
||||
| `DIFY_AGENT_OUTBOUND_HTTP_MAX_KEEPALIVE_CONNECTIONS` | `20` | Maximum idle shared outbound HTTP connections. |
|
||||
| `DIFY_AGENT_OUTBOUND_HTTP_KEEPALIVE_EXPIRY` | `30` | Idle keep-alive expiry in seconds. |
|
||||
| `DIFY_AGENT_TRAJECTORY_ENABLED` | `false` | Opt-in switch for the separate Agent trajectory export pipeline. |
|
||||
| `DIFY_AGENT_TRAJECTORY_OTLP_TRACES_ENDPOINT` | empty | Full HTTP(S) OTLP traces endpoint required when trajectory export is enabled. |
|
||||
| `DIFY_AGENT_TRAJECTORY_OTLP_HEADERS` | `{}` | JSON object of request headers sent with Agent OTLP trace exports. |
|
||||
| `DIFY_AGENT_TRAJECTORY_SERVICE_NAME` | `dify-agent-trajectory` | Resource service name attached to Agent trajectory spans. |
|
||||
| `DIFY_AGENT_TRAJECTORY_INCLUDE_CONTENT` | `false` | Include message and tool content in Agent trajectory spans. |
|
||||
| `DIFY_AGENT_TRAJECTORY_TRACE_CONTEXT_MODE` | `isolated` | `isolated` starts independent platform/Agent traces; `shared` preserves cross-instance parent context. Requires process restart to change. |
|
||||
| `DIFY_AGENT_TRAJECTORY_MAX_QUEUE_SIZE` | `2048` | Maximum number of spans queued for Agent trace export; must be positive. |
|
||||
| `DIFY_AGENT_TRAJECTORY_MAX_EXPORT_BATCH_SIZE` | `512` | Maximum spans per export batch; must be positive and no greater than the queue size. |
|
||||
| `DIFY_AGENT_TRAJECTORY_SCHEDULE_DELAY_MS` | `5000` | Delay between scheduled Agent span exports, in milliseconds; must be positive. |
|
||||
| `DIFY_AGENT_TRAJECTORY_EXPORT_TIMEOUT_MS` | `5000` | Batch processor export timeout setting, in milliseconds; must be positive. Does not change the OTLP HTTP exporter request timeout. |
|
||||
|
||||
Platform observability uses the process-level Logfire instance and standard
|
||||
`OTEL_*` / `LOGFIRE_*` configuration. Agent observability uses a separate local
|
||||
Logfire instance and is disabled by default. Set `DIFY_AGENT_TRAJECTORY_ENABLED=true`
|
||||
and a full `DIFY_AGENT_TRAJECTORY_OTLP_TRACES_ENDPOINT` to opt in;
|
||||
`DIFY_AGENT_TRAJECTORY_OTLP_HEADERS` is a JSON object of request headers. Agent
|
||||
traces do not inherit platform endpoints, credentials, sampling, or console
|
||||
export. Message/tool content requires the separate
|
||||
`DIFY_AGENT_TRAJECTORY_INCLUDE_CONTENT` opt-in. Platform FastAPI parameter/error
|
||||
payloads, HTTPX bodies/headers and Redis statements are not captured by these
|
||||
instrumentations. The Agent instance is created at service startup and shut down
|
||||
after active runs finish. These are deployment-level settings, not yet
|
||||
per-tenant authorization or routing; a shared backend does not provide storage
|
||||
isolation.
|
||||
|
||||
With `DIFY_AGENT_TRAJECTORY_TRACE_CONTEXT_MODE=isolated` (the default), Agent
|
||||
run spans start independent traces instead of inheriting platform request
|
||||
parents. Platform HTTPX/Redis instrumentation also detaches local Agent
|
||||
parents while preserving platform-only and incoming distributed trace
|
||||
relationships. In `shared` mode both pipelines retain cross-instance parent
|
||||
context and the Agent sampler respects the parent sampling decision. Use
|
||||
`shared` only when both pipelines export to the same queryable backend space;
|
||||
otherwise missing-root spans can recur. Service names do not select the mode.
|
||||
The two SDK instances, exporters, enable switch, and content opt-in remain
|
||||
separate. The mode is fixed at process initialization and requires a restart
|
||||
to change. No extra shared root span is added; incomplete traces may still
|
||||
appear temporarily while a run or export is in progress.
|
||||
|
||||
Example `.env`:
|
||||
|
||||
|
||||
@@ -18,6 +18,31 @@ on this layer to reach the plugin daemon.
|
||||
| `invoke_from` | `Literal[...]` | Dify caller category recorded for observability and correlation. |
|
||||
| `app_id` / `workflow_id` / `workflow_run_id` / `node_id` / `node_execution_id` / `conversation_id` / `agent_id` / `agent_config_version_id` / `trace_id` | `str \| None` | Optional Dify-owned execution identifiers forwarded with the run. |
|
||||
|
||||
## Trajectory span attributes
|
||||
|
||||
When Agent trajectory export is enabled, this layer's fields are attached to every
|
||||
trajectory span of the run. The names follow the Dify Enterprise Data Push
|
||||
dictionary so a trajectory span joins the platform's own workflow and node signals
|
||||
on the same keys. Fields you leave unset are omitted, so an Agent App turn carries
|
||||
`dify.conversation.id` while a workflow Agent node carries the workflow keys.
|
||||
|
||||
| Config field | Span attribute |
|
||||
| --- | --- |
|
||||
| `tenant_id` | `dify.tenant_id` |
|
||||
| `app_id` | `dify.app_id` |
|
||||
| `agent_id` | `dify.agent_id` |
|
||||
| `user_id` | `gen_ai.user.id` |
|
||||
| `invoke_from` | `dify.invoke_from` |
|
||||
| `conversation_id` | `dify.conversation.id` |
|
||||
| `workflow_id` | `dify.workflow.id` |
|
||||
| `workflow_run_id` | `dify.workflow.run_id` |
|
||||
| `node_id` | `dify.node.id` |
|
||||
| `node_execution_id` | `dify.node.execution_id` |
|
||||
| `trace_id` | `dify.trace_id` |
|
||||
|
||||
`app_id` is the app the run was invoked through, so a workflow Agent node reports
|
||||
the workflow's own app rather than the Agent's.
|
||||
|
||||
The execution-context layer type id is `dify.execution_context`.
|
||||
|
||||
## Basic usage
|
||||
|
||||
@@ -11,7 +11,7 @@ optional JSON Schema output layer share the same ``Agent`` construction path.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import Any, cast
|
||||
from typing import Any, Final, cast
|
||||
|
||||
from pydantic_ai import Agent
|
||||
from pydantic_ai.messages import UserContent
|
||||
@@ -21,6 +21,9 @@ from pydantic_ai.output import OutputSpec
|
||||
from agenton.layers.types import PydanticAITool
|
||||
|
||||
|
||||
DIFY_AGENT_RUN_NAME: Final[str] = "dify-agent"
|
||||
|
||||
|
||||
def create_agent(
|
||||
model: Model[Any],
|
||||
*,
|
||||
@@ -35,7 +38,13 @@ def create_agent(
|
||||
carries the Pydantic hooks needed for schema exposure and runtime validation,
|
||||
so agent construction does not need to register a separate validator.
|
||||
"""
|
||||
return cast(Agent[None, object], Agent(model, output_type=output_type, tools=tools))
|
||||
# TODO: propagate roster name, so gen_ai.agent.name can pick that up
|
||||
agent = cast(
|
||||
Agent[None, object],
|
||||
Agent(model, name=DIFY_AGENT_RUN_NAME, output_type=output_type, tools=tools),
|
||||
)
|
||||
agent.instrument = False
|
||||
return agent
|
||||
|
||||
|
||||
def normalize_user_input(user_prompts: Sequence[UserContent]) -> str | Sequence[UserContent]:
|
||||
@@ -45,4 +54,4 @@ def normalize_user_input(user_prompts: Sequence[UserContent]) -> str | Sequence[
|
||||
return list(user_prompts)
|
||||
|
||||
|
||||
__all__ = ["create_agent", "normalize_user_input"]
|
||||
__all__ = ["DIFY_AGENT_RUN_NAME", "create_agent", "normalize_user_input"]
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal
|
||||
|
||||
from opentelemetry.context import Context
|
||||
from opentelemetry.sdk.trace import ReadableSpan
|
||||
from opentelemetry.trace import (
|
||||
INVALID_SPAN,
|
||||
Link,
|
||||
Span,
|
||||
SpanKind,
|
||||
Tracer,
|
||||
TracerProvider,
|
||||
get_current_span,
|
||||
set_span_in_context,
|
||||
)
|
||||
from opentelemetry.util.types import Attributes
|
||||
|
||||
from dify_agent.layers.execution_context.configs import DifyExecutionContextLayerConfig
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from logfire import Logfire
|
||||
from pydantic_ai import Agent
|
||||
|
||||
DIFY_TRACE_ID_ATTRIBUTE: Final[str] = "dify.trace_id"
|
||||
DIFY_TENANT_ID_ATTRIBUTE: Final[str] = "dify.tenant_id"
|
||||
DIFY_APP_ID_ATTRIBUTE: Final[str] = "dify.app_id"
|
||||
DIFY_AGENT_ID_ATTRIBUTE: Final[str] = "dify.agent_id"
|
||||
DIFY_INVOKE_FROM_ATTRIBUTE: Final[str] = "dify.invoke_from"
|
||||
DIFY_CONVERSATION_ID_ATTRIBUTE: Final[str] = "dify.conversation.id"
|
||||
DIFY_WORKFLOW_ID_ATTRIBUTE: Final[str] = "dify.workflow.id"
|
||||
DIFY_WORKFLOW_RUN_ID_ATTRIBUTE: Final[str] = "dify.workflow.run_id"
|
||||
DIFY_NODE_ID_ATTRIBUTE: Final[str] = "dify.node.id"
|
||||
DIFY_NODE_EXECUTION_ID_ATTRIBUTE: Final[str] = "dify.node.execution_id"
|
||||
GEN_AI_USER_ID_ATTRIBUTE: Final[str] = "gen_ai.user.id"
|
||||
|
||||
|
||||
def dify_run_attributes(execution_context: DifyExecutionContextLayerConfig) -> tuple[tuple[str, str], ...]:
|
||||
return tuple(
|
||||
(key, value)
|
||||
for key, value in (
|
||||
(DIFY_TRACE_ID_ATTRIBUTE, execution_context.trace_id),
|
||||
(DIFY_TENANT_ID_ATTRIBUTE, execution_context.tenant_id),
|
||||
(DIFY_APP_ID_ATTRIBUTE, execution_context.app_id),
|
||||
(DIFY_AGENT_ID_ATTRIBUTE, execution_context.agent_id),
|
||||
(GEN_AI_USER_ID_ATTRIBUTE, execution_context.user_id),
|
||||
(DIFY_INVOKE_FROM_ATTRIBUTE, execution_context.invoke_from),
|
||||
(DIFY_CONVERSATION_ID_ATTRIBUTE, execution_context.conversation_id),
|
||||
(DIFY_WORKFLOW_ID_ATTRIBUTE, execution_context.workflow_id),
|
||||
(DIFY_WORKFLOW_RUN_ID_ATTRIBUTE, execution_context.workflow_run_id),
|
||||
(DIFY_NODE_ID_ATTRIBUTE, execution_context.node_id),
|
||||
(DIFY_NODE_EXECUTION_ID_ATTRIBUTE, execution_context.node_execution_id),
|
||||
)
|
||||
if value
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IsolatedTracerProvider(TracerProvider):
|
||||
client: Logfire
|
||||
preserve_external_parent: bool = False
|
||||
|
||||
def get_tracer(
|
||||
self,
|
||||
instrumenting_module_name: str,
|
||||
instrumenting_library_version: str | None = None,
|
||||
schema_url: str | None = None,
|
||||
attributes: Attributes = None,
|
||||
) -> Tracer:
|
||||
delegate = self.client.config.get_tracer_provider().get_tracer(
|
||||
instrumenting_module_name,
|
||||
instrumenting_library_version,
|
||||
schema_url=schema_url,
|
||||
attributes=attributes,
|
||||
)
|
||||
return _IsolatedTracer(delegate, self.client, self.preserve_external_parent)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _IsolatedTracer(Tracer):
|
||||
delegate: Tracer
|
||||
client: Logfire
|
||||
preserve_external_parent: bool
|
||||
|
||||
def _parent_context(self, context: Context | None) -> Context | None:
|
||||
parent = get_current_span(context)
|
||||
if not parent.get_span_context().is_valid:
|
||||
return context
|
||||
if isinstance(parent, ReadableSpan):
|
||||
instance_id = self.client.resource_attributes.get("service.instance.id")
|
||||
if instance_id is not None and parent.resource.attributes.get("service.instance.id") == instance_id:
|
||||
return context
|
||||
elif self.preserve_external_parent:
|
||||
return context
|
||||
return set_span_in_context(INVALID_SPAN, context)
|
||||
|
||||
def start_span(
|
||||
self,
|
||||
name: str,
|
||||
context: Context | None = None,
|
||||
kind: SpanKind = SpanKind.INTERNAL,
|
||||
attributes: Attributes = None,
|
||||
links: Sequence[Link] | None = None,
|
||||
start_time: int | None = None,
|
||||
record_exception: bool = True,
|
||||
set_status_on_exception: bool = True,
|
||||
) -> Span:
|
||||
return self.delegate.start_span(
|
||||
name,
|
||||
context=self._parent_context(context),
|
||||
kind=kind,
|
||||
attributes=attributes,
|
||||
links=links,
|
||||
start_time=start_time,
|
||||
record_exception=record_exception,
|
||||
set_status_on_exception=set_status_on_exception,
|
||||
)
|
||||
|
||||
def start_as_current_span(
|
||||
self,
|
||||
name: str,
|
||||
context: Context | None = None,
|
||||
kind: SpanKind = SpanKind.INTERNAL,
|
||||
attributes: Attributes = None,
|
||||
links: Sequence[Link] | None = None,
|
||||
start_time: int | None = None,
|
||||
record_exception: bool = True,
|
||||
set_status_on_exception: bool = True,
|
||||
end_on_exit: bool = True,
|
||||
):
|
||||
return self.delegate.start_as_current_span(
|
||||
name,
|
||||
context=self._parent_context(context),
|
||||
kind=kind,
|
||||
attributes=attributes,
|
||||
links=links,
|
||||
start_time=start_time,
|
||||
record_exception=record_exception,
|
||||
set_status_on_exception=set_status_on_exception,
|
||||
end_on_exit=end_on_exit,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RunScopedTracerProvider(TracerProvider):
|
||||
"""Stamp one run's fixed attributes on every span a delegate provider creates."""
|
||||
|
||||
delegate: TracerProvider
|
||||
attributes: tuple[tuple[str, str], ...]
|
||||
|
||||
def get_tracer(
|
||||
self,
|
||||
instrumenting_module_name: str,
|
||||
instrumenting_library_version: str | None = None,
|
||||
schema_url: str | None = None,
|
||||
attributes: Attributes = None,
|
||||
) -> Tracer:
|
||||
delegate = self.delegate.get_tracer(
|
||||
instrumenting_module_name,
|
||||
instrumenting_library_version,
|
||||
schema_url=schema_url,
|
||||
attributes=attributes,
|
||||
)
|
||||
return _RunScopedTracer(delegate, self.attributes)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _RunScopedTracer(Tracer):
|
||||
delegate: Tracer
|
||||
attributes: tuple[tuple[str, str], ...]
|
||||
|
||||
def _run_attributes(self, attributes: Attributes) -> Attributes:
|
||||
return {**(attributes or {}), **dict(self.attributes)}
|
||||
|
||||
def start_span(
|
||||
self,
|
||||
name: str,
|
||||
context: Context | None = None,
|
||||
kind: SpanKind = SpanKind.INTERNAL,
|
||||
attributes: Attributes = None,
|
||||
links: Sequence[Link] | None = None,
|
||||
start_time: int | None = None,
|
||||
record_exception: bool = True,
|
||||
set_status_on_exception: bool = True,
|
||||
) -> Span:
|
||||
return self.delegate.start_span(
|
||||
name,
|
||||
context=context,
|
||||
kind=kind,
|
||||
attributes=self._run_attributes(attributes),
|
||||
links=links,
|
||||
start_time=start_time,
|
||||
record_exception=record_exception,
|
||||
set_status_on_exception=set_status_on_exception,
|
||||
)
|
||||
|
||||
def start_as_current_span(
|
||||
self,
|
||||
name: str,
|
||||
context: Context | None = None,
|
||||
kind: SpanKind = SpanKind.INTERNAL,
|
||||
attributes: Attributes = None,
|
||||
links: Sequence[Link] | None = None,
|
||||
start_time: int | None = None,
|
||||
record_exception: bool = True,
|
||||
set_status_on_exception: bool = True,
|
||||
end_on_exit: bool = True,
|
||||
):
|
||||
return self.delegate.start_as_current_span(
|
||||
name,
|
||||
context=context,
|
||||
kind=kind,
|
||||
attributes=self._run_attributes(attributes),
|
||||
links=links,
|
||||
start_time=start_time,
|
||||
record_exception=record_exception,
|
||||
set_status_on_exception=set_status_on_exception,
|
||||
end_on_exit=end_on_exit,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AgentObservability:
|
||||
client: Logfire
|
||||
include_content: bool = False
|
||||
trace_context_mode: Literal["isolated", "shared"] = "isolated"
|
||||
|
||||
def instrument(
|
||||
self,
|
||||
agent: Agent[Any, Any],
|
||||
*,
|
||||
execution_context: DifyExecutionContextLayerConfig | None = None,
|
||||
) -> None:
|
||||
"""Instrument one run's agent and attribute its spans to its Dify context.
|
||||
|
||||
``execution_context`` is the run's Dify identity and correlation context.
|
||||
Its Data Push-aligned attributes are attached to every span the
|
||||
instrumented agent produces; see ``dify_run_attributes``. Runs without an
|
||||
execution context are still instrumented, just without Dify attribution.
|
||||
"""
|
||||
self.client.instrument_pydantic_ai(
|
||||
agent,
|
||||
include_content=self.include_content,
|
||||
include_binary_content=False,
|
||||
tracer_provider=self._tracer_provider(execution_context),
|
||||
)
|
||||
|
||||
def _tracer_provider(self, execution_context: DifyExecutionContextLayerConfig | None) -> TracerProvider:
|
||||
provider: TracerProvider = (
|
||||
IsolatedTracerProvider(self.client)
|
||||
if self.trace_context_mode == "isolated"
|
||||
else self.client.config.get_tracer_provider()
|
||||
)
|
||||
if execution_context is None:
|
||||
return provider
|
||||
run_attributes = dify_run_attributes(execution_context)
|
||||
if not run_attributes:
|
||||
return provider
|
||||
return RunScopedTracerProvider(provider, run_attributes)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""Flush and tear down this instance's own export pipeline.
|
||||
|
||||
The owning scope closes the instance; shutdown is blocking, so it runs off
|
||||
the event loop. This only affects the Agent pipeline and leaves the
|
||||
platform Logfire instance running.
|
||||
"""
|
||||
await asyncio.to_thread(self.client.shutdown, timeout_millis=5000)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DIFY_AGENT_ID_ATTRIBUTE",
|
||||
"DIFY_APP_ID_ATTRIBUTE",
|
||||
"DIFY_CONVERSATION_ID_ATTRIBUTE",
|
||||
"DIFY_INVOKE_FROM_ATTRIBUTE",
|
||||
"DIFY_NODE_EXECUTION_ID_ATTRIBUTE",
|
||||
"DIFY_NODE_ID_ATTRIBUTE",
|
||||
"DIFY_TENANT_ID_ATTRIBUTE",
|
||||
"DIFY_TRACE_ID_ATTRIBUTE",
|
||||
"DIFY_WORKFLOW_ID_ATTRIBUTE",
|
||||
"DIFY_WORKFLOW_RUN_ID_ATTRIBUTE",
|
||||
"GEN_AI_USER_ID_ATTRIBUTE",
|
||||
"AgentObservability",
|
||||
"IsolatedTracerProvider",
|
||||
"RunScopedTracerProvider",
|
||||
"dify_run_attributes",
|
||||
]
|
||||
@@ -29,6 +29,7 @@ from dify_agent.runtime.event_coalescer import (
|
||||
DEFAULT_TEXT_DELTA_MAX_CHARS,
|
||||
)
|
||||
from dify_agent.runtime.event_sink import RunEventSink, RunFinalizationResult, emit_run_failed
|
||||
from dify_agent.runtime.observability import AgentObservability
|
||||
from dify_agent.runtime.runner import DEFAULT_AGENT_RUN_TIMEOUT_SECONDS, AgentRunRunner
|
||||
from dify_agent.server.schemas import RunRecord
|
||||
|
||||
@@ -119,6 +120,7 @@ class RunScheduler:
|
||||
layer_providers: tuple[LayerProviderInput, ...]
|
||||
plugin_daemon_http_client: httpx.AsyncClient
|
||||
dify_api_http_client: httpx.AsyncClient
|
||||
agent_observability: AgentObservability | None
|
||||
_lifecycle_lock: asyncio.Lock
|
||||
|
||||
def __init__(
|
||||
@@ -134,6 +136,7 @@ class RunScheduler:
|
||||
stream_text_delta_max_chars: int = DEFAULT_TEXT_DELTA_MAX_CHARS,
|
||||
layer_providers: tuple[LayerProviderInput, ...] | None = None,
|
||||
runner_factory: RunRunnerFactory | None = None,
|
||||
agent_observability: AgentObservability | None = None,
|
||||
) -> None:
|
||||
self.store = store
|
||||
self.shutdown_grace_seconds = shutdown_grace_seconds
|
||||
@@ -147,6 +150,7 @@ class RunScheduler:
|
||||
self.dify_api_http_client = dify_api_http_client
|
||||
self.layer_providers = layer_providers if layer_providers is not None else create_default_layer_providers()
|
||||
self.runner_factory = runner_factory
|
||||
self.agent_observability = agent_observability
|
||||
self._lifecycle_lock = asyncio.Lock()
|
||||
|
||||
async def create_run(self, request: CreateRunRequest) -> RunRecord:
|
||||
@@ -320,6 +324,7 @@ class RunScheduler:
|
||||
stream_text_delta_coalescing_enabled=self.stream_text_delta_coalescing_enabled,
|
||||
stream_text_delta_flush_interval_seconds=self.stream_text_delta_flush_interval_seconds,
|
||||
stream_text_delta_max_chars=self.stream_text_delta_max_chars,
|
||||
agent_observability=self.agent_observability,
|
||||
)
|
||||
|
||||
def _discard_active_run(self, run_id: str) -> None:
|
||||
|
||||
@@ -82,6 +82,7 @@ from dify_agent.runtime.event_sink import (
|
||||
emit_run_started,
|
||||
emit_run_succeeded,
|
||||
)
|
||||
from dify_agent.runtime.observability import AgentObservability
|
||||
from dify_agent.runtime.history import (
|
||||
get_history_layer,
|
||||
replace_run_history,
|
||||
@@ -198,6 +199,7 @@ class AgentRunRunner:
|
||||
stream_text_delta_coalescing_enabled: bool
|
||||
stream_text_delta_flush_interval_seconds: float
|
||||
stream_text_delta_max_chars: int
|
||||
agent_observability: AgentObservability | None
|
||||
_terminal_session_snapshot: CompositorSessionSnapshot | None
|
||||
_terminal_usage: AgentRunUsage | None
|
||||
|
||||
@@ -215,6 +217,7 @@ class AgentRunRunner:
|
||||
stream_text_delta_coalescing_enabled: bool = True,
|
||||
stream_text_delta_flush_interval_seconds: float = DEFAULT_TEXT_DELTA_FLUSH_INTERVAL_SECONDS,
|
||||
stream_text_delta_max_chars: int = DEFAULT_TEXT_DELTA_MAX_CHARS,
|
||||
agent_observability: AgentObservability | None = None,
|
||||
) -> None:
|
||||
if stream_text_delta_flush_interval_seconds <= 0:
|
||||
raise ValueError("stream_text_delta_flush_interval_seconds must be positive")
|
||||
@@ -231,6 +234,7 @@ class AgentRunRunner:
|
||||
self.stream_text_delta_coalescing_enabled = stream_text_delta_coalescing_enabled
|
||||
self.stream_text_delta_flush_interval_seconds = stream_text_delta_flush_interval_seconds
|
||||
self.stream_text_delta_max_chars = stream_text_delta_max_chars
|
||||
self.agent_observability = agent_observability
|
||||
self._terminal_session_snapshot = None
|
||||
self._terminal_usage = None
|
||||
|
||||
@@ -390,6 +394,14 @@ class AgentRunRunner:
|
||||
tools=tools,
|
||||
output_type=_resolve_agent_output_type(output_contract.output_type, ask_human_layer is not None),
|
||||
)
|
||||
if self.agent_observability is not None:
|
||||
# The model layer's required execution-context dependency is the
|
||||
# run's only carrier of Dify identity, and its node name is
|
||||
# caller-chosen, so read it through the typed dependency.
|
||||
self.agent_observability.instrument(
|
||||
agent,
|
||||
execution_context=llm_layer.deps.execution_context.config,
|
||||
)
|
||||
run_timeout = asyncio.timeout(self.run_timeout_seconds)
|
||||
try:
|
||||
with capture_run_messages() as captured_messages:
|
||||
|
||||
@@ -11,9 +11,11 @@ adds no reporting to business operations. Redis is not used as a run job queue.
|
||||
stay state-only: they borrow the lifespan-owned clients through the runner and
|
||||
receive runtime-backend and Shell settings through provider construction rather
|
||||
than reading environment variables themselves. The standard server mounts the
|
||||
HTTP Agent Stub router. Process-level Logfire instrumentation is configured at
|
||||
app construction time and only exports remotely when Logfire's default
|
||||
environment configuration provides a token.
|
||||
HTTP Agent Stub router. Process-level platform Logfire instrumentation is
|
||||
configured at app construction time. Logfire-platform export is token-gated;
|
||||
standard OTLP export follows the SDK environment configuration, including
|
||||
server dotenv files. Agent trajectory export is a separate opt-in Logfire
|
||||
instance owned by the app lifespan and injected into each run's runner.
|
||||
"""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
@@ -30,7 +32,7 @@ from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
|
||||
from dify_agent.runtime.compositor_factory import create_default_layer_providers
|
||||
from dify_agent.runtime.run_scheduler import RunScheduler
|
||||
from dify_agent.server.auth import create_bearer_token_dependency
|
||||
from dify_agent.server.observability import configure_server_observability
|
||||
from dify_agent.server.observability import configure_agent_observability, configure_server_observability
|
||||
from dify_agent.server.routes.e2b_usage import create_e2b_usage_router
|
||||
from dify_agent.server.routes.runs import create_runs_router
|
||||
from dify_agent.server.routes.execution_bindings import create_execution_bindings_router
|
||||
@@ -102,6 +104,8 @@ def create_app(settings: ServerSettings | None = None) -> FastAPI:
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
agent_observability = configure_agent_observability(resolved_settings)
|
||||
_app.state.agent_observability = agent_observability
|
||||
redis = Redis.from_url(resolved_settings.redis_url)
|
||||
plugin_daemon_http_client = create_plugin_daemon_http_client(resolved_settings)
|
||||
dify_api_inner_http_client = create_dify_api_inner_http_client(resolved_settings)
|
||||
@@ -121,6 +125,7 @@ def create_app(settings: ServerSettings | None = None) -> FastAPI:
|
||||
stream_text_delta_flush_interval_seconds=(resolved_settings.stream_text_delta_flush_interval_ms / 1000),
|
||||
stream_text_delta_max_chars=resolved_settings.stream_text_delta_max_chars,
|
||||
layer_providers=layer_providers,
|
||||
agent_observability=agent_observability,
|
||||
)
|
||||
state["store"] = store
|
||||
state["scheduler"] = scheduler
|
||||
@@ -131,9 +136,12 @@ def create_app(settings: ServerSettings | None = None) -> FastAPI:
|
||||
await dify_api_inner_http_client.aclose()
|
||||
await plugin_daemon_http_client.aclose()
|
||||
await redis.aclose()
|
||||
# Closed last so runs draining above still reach the exporter.
|
||||
if agent_observability is not None:
|
||||
await agent_observability.aclose()
|
||||
|
||||
app = FastAPI(title="Dify Agent Run Server", version="0.1.0", lifespan=lifespan)
|
||||
configure_server_observability(app)
|
||||
app.state.platform_observability = configure_server_observability(app, settings=resolved_settings)
|
||||
|
||||
def get_store() -> RedisRunStore:
|
||||
return cast(RedisRunStore, state["store"])
|
||||
|
||||
@@ -1,47 +1,181 @@
|
||||
"""Process-level Logfire setup for the Dify Agent run server.
|
||||
|
||||
The run server performs observability setup at the FastAPI app boundary rather
|
||||
than inside agent runtime code. Global instrumentations cover shared HTTPX,
|
||||
Redis, and Pydantic AI clients once per process; the FastAPI instrumentation is
|
||||
applied per app instance because tests and embedded callers can build multiple
|
||||
apps in one Python process. Remote export remains token-gated through Logfire's
|
||||
``if-token-present`` mode and Logfire's default environment-variable handling,
|
||||
so development without a token only writes Logfire's console output locally.
|
||||
than inside agent runtime code. Global instrumentations cover shared HTTPX and
|
||||
Redis clients once per process; the FastAPI instrumentation is applied per app
|
||||
instance because tests and embedded callers can build multiple apps in one
|
||||
Python process. ``OTEL_*`` and ``LOGFIRE_*`` values captured in the
|
||||
``ServerSettings`` dotenv snapshot are forwarded before SDK setup without
|
||||
overriding the process environment. Logfire-platform export remains token-gated through
|
||||
``if-token-present``; standard OTLP export is configured independently.
|
||||
|
||||
The optional Agent trajectory pipeline is a separate, deployment-opt-in Logfire
|
||||
instance configured only from ``DIFY_AGENT_TRAJECTORY_*`` settings. It is
|
||||
created while the inherited ``OTEL_*``/``LOGFIRE_*`` environment is temporarily
|
||||
stripped so platform endpoints, credentials, and sampling never leak into the
|
||||
Agent pipeline; the isolation is startup-only and does not coordinate external
|
||||
code that mutates ``os.environ`` concurrently.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from threading import RLock
|
||||
from typing import Any, Literal
|
||||
|
||||
import logfire
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Request, WebSocket
|
||||
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
|
||||
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
||||
from opentelemetry.sdk.trace.sampling import ALWAYS_ON, ParentBased
|
||||
|
||||
from dify_agent.runtime.observability import AgentObservability, IsolatedTracerProvider
|
||||
from dify_agent.server.settings import ServerSettings
|
||||
|
||||
_global_instrumentation_ready = False
|
||||
_global_trace_context_mode: Literal["isolated", "shared"] | None = None
|
||||
_observability_configuration_lock = RLock()
|
||||
|
||||
|
||||
def configure_server_observability(app: FastAPI) -> None:
|
||||
@contextmanager
|
||||
def _isolated_agent_environment() -> Iterator[None]:
|
||||
"""Serialize SDK configuration and strip inherited SDK env for Agent setup."""
|
||||
with _observability_configuration_lock:
|
||||
inherited = {key: value for key, value in os.environ.items() if key.startswith(("OTEL_", "LOGFIRE_"))}
|
||||
for key in inherited:
|
||||
del os.environ[key]
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
for key in tuple(os.environ):
|
||||
if key.startswith(("OTEL_", "LOGFIRE_")):
|
||||
del os.environ[key]
|
||||
os.environ.update(inherited)
|
||||
|
||||
|
||||
def _platform_request_attributes(_request: Request | WebSocket, _attributes: dict[str, Any]) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def configure_server_observability(app: FastAPI, *, settings: ServerSettings | None = None) -> logfire.Logfire:
|
||||
"""Configure Logfire and instrument the server's framework/client boundaries.
|
||||
|
||||
Instrumentation calls intentionally use Logfire's defaults instead of
|
||||
re-exposing capture options through Dify settings. The only Dify-owned
|
||||
policy here is that remote export stays token-gated while local console
|
||||
output still works without a token.
|
||||
Platform instrumentation captures infrastructure metadata without parsed
|
||||
request payloads or HTTP bodies. SDK environment variables
|
||||
are forwarded from the settings dotenv snapshot with process-environment
|
||||
priority.
|
||||
Logfire-platform export stays token-gated; OTLP endpoints do not require a
|
||||
Logfire token. FastAPI parsed argument/error payloads, HTTPX bodies and
|
||||
headers, and Redis statements are not captured. The trace context mode is a
|
||||
startup-only choice and cannot change after instrumentation. Returns the
|
||||
platform Logfire instance.
|
||||
"""
|
||||
global _global_instrumentation_ready
|
||||
global _global_trace_context_mode
|
||||
|
||||
logfire.configure(
|
||||
send_to_logfire="if-token-present",
|
||||
inspect_arguments=False,
|
||||
resolved_settings = settings or ServerSettings()
|
||||
mode = resolved_settings.trajectory_trace_context_mode
|
||||
|
||||
with _observability_configuration_lock:
|
||||
if _global_instrumentation_ready and _global_trace_context_mode != mode:
|
||||
raise ValueError("Trace context mode cannot change after platform instrumentation; restart the process")
|
||||
for key, value in resolved_settings.observability_dotenv.items():
|
||||
if key.startswith(("OTEL_", "LOGFIRE_")):
|
||||
os.environ.setdefault(key, value.get_secret_value())
|
||||
|
||||
platform = logfire.configure(
|
||||
send_to_logfire="if-token-present",
|
||||
inspect_arguments=False,
|
||||
)
|
||||
|
||||
tracer_provider: IsolatedTracerProvider | Any = (
|
||||
IsolatedTracerProvider(platform, preserve_external_parent=True)
|
||||
if mode == "isolated"
|
||||
else platform.config.get_tracer_provider()
|
||||
)
|
||||
if not _global_instrumentation_ready:
|
||||
platform.instrument_httpx(
|
||||
capture_all=False,
|
||||
capture_headers=False,
|
||||
capture_request_body=False,
|
||||
capture_response_body=False,
|
||||
tracer_provider=tracer_provider,
|
||||
)
|
||||
platform.instrument_redis(capture_statement=False, tracer_provider=tracer_provider)
|
||||
_global_instrumentation_ready = True
|
||||
_global_trace_context_mode = mode
|
||||
|
||||
if getattr(app.state, "dify_agent_logfire_instrumented", False):
|
||||
return platform
|
||||
platform.instrument_fastapi(
|
||||
app,
|
||||
request_attributes_mapper=_platform_request_attributes,
|
||||
capture_headers=False,
|
||||
tracer_provider=tracer_provider,
|
||||
)
|
||||
app.state.dify_agent_logfire_instrumented = True
|
||||
return platform
|
||||
|
||||
|
||||
def configure_agent_observability(settings: ServerSettings) -> AgentObservability | None:
|
||||
"""Build the opt-in Agent trajectory pipeline from deployment settings only.
|
||||
|
||||
This is one process-wide pipeline and is meant to stay that way. Per-tenant
|
||||
destinations belong in a routing ``SpanExporter`` that reads a span's
|
||||
``dify.tenant_id`` and fans out to a cached per-tenant exporter, not in a
|
||||
tracer instance per tenant: a second instance costs another
|
||||
``logfire.configure()``, which means another
|
||||
``_isolated_agent_environment()`` window on a live process and another batch
|
||||
processor thread. Routing keeps setup static and makes a tenant destination
|
||||
plain data. Such a router must fail closed on an unknown tenant rather than
|
||||
fall back to this deployment-wide endpoint.
|
||||
"""
|
||||
if not settings.trajectory_enabled:
|
||||
return None
|
||||
endpoint = settings.trajectory_otlp_traces_endpoint
|
||||
if endpoint is None:
|
||||
raise ValueError("trajectory_otlp_traces_endpoint is required when trajectory_enabled is true")
|
||||
with _isolated_agent_environment():
|
||||
exporter = OTLPSpanExporter(
|
||||
endpoint=str(endpoint),
|
||||
headers={key: value.get_secret_value() for key, value in settings.trajectory_otlp_headers.items()},
|
||||
timeout=5,
|
||||
)
|
||||
processor = BatchSpanProcessor(
|
||||
exporter,
|
||||
max_queue_size=settings.trajectory_max_queue_size,
|
||||
max_export_batch_size=settings.trajectory_max_export_batch_size,
|
||||
schedule_delay_millis=settings.trajectory_schedule_delay_ms,
|
||||
export_timeout_millis=settings.trajectory_export_timeout_ms,
|
||||
)
|
||||
try:
|
||||
client = logfire.configure(
|
||||
local=True,
|
||||
send_to_logfire=False,
|
||||
service_name=settings.trajectory_service_name,
|
||||
console=False,
|
||||
metrics=False,
|
||||
inspect_arguments=False,
|
||||
add_baggage_to_attributes=False,
|
||||
sampling=logfire.SamplingOptions(
|
||||
head=ALWAYS_ON if settings.trajectory_trace_context_mode == "isolated" else ParentBased(ALWAYS_ON)
|
||||
),
|
||||
additional_span_processors=[processor],
|
||||
)
|
||||
except BaseException:
|
||||
processor.shutdown()
|
||||
raise
|
||||
return AgentObservability(
|
||||
client=client,
|
||||
include_content=settings.trajectory_include_content,
|
||||
trace_context_mode=settings.trajectory_trace_context_mode,
|
||||
)
|
||||
|
||||
if not _global_instrumentation_ready:
|
||||
logfire.instrument_httpx()
|
||||
logfire.instrument_redis()
|
||||
logfire.instrument_pydantic_ai()
|
||||
_global_instrumentation_ready = True
|
||||
|
||||
if getattr(app.state, "dify_agent_logfire_instrumented", False):
|
||||
return
|
||||
logfire.instrument_fastapi(app)
|
||||
app.state.dify_agent_logfire_instrumented = True
|
||||
|
||||
|
||||
__all__ = ["configure_server_observability"]
|
||||
__all__ = [
|
||||
"AgentObservability",
|
||||
"configure_agent_observability",
|
||||
"configure_server_observability",
|
||||
]
|
||||
|
||||
@@ -14,8 +14,9 @@ import httpx
|
||||
|
||||
from typing import ClassVar, Literal, cast
|
||||
|
||||
from pydantic import AliasChoices, AnyHttpUrl, Field, TypeAdapter, field_validator, model_validator
|
||||
from pydantic import AliasChoices, AnyHttpUrl, Field, SecretStr, TypeAdapter, field_validator, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from pydantic_settings.sources import DotEnvSettingsSource, InitSettingsSource, PydanticBaseSettingsSource
|
||||
|
||||
from dify_agent.agent_stub.protocol.agent_stub import normalize_agent_stub_api_base_url
|
||||
from dify_agent.agent_stub.server.agent_stub_config import DifyApiAgentStubConfigRequestHandler
|
||||
@@ -45,7 +46,12 @@ DEFAULT_RUN_EVENT_STREAM_MAX_LENGTH = 5000
|
||||
|
||||
|
||||
class ServerSettings(BaseSettings):
|
||||
"""Environment settings for scheduling, outbound HTTP, and runtime resources."""
|
||||
"""Environment settings for scheduling, outbound HTTP, and runtime resources.
|
||||
|
||||
``observability_dotenv`` is a construction-time snapshot of the effective
|
||||
dotenv source restricted to ``OTEL_*``/``LOGFIRE_*`` keys; it is excluded
|
||||
from serialization and repr, and is consumed only by observability setup.
|
||||
"""
|
||||
|
||||
redis_url: str = "redis://localhost:6379/0"
|
||||
redis_prefix: str = "dify-agent"
|
||||
@@ -124,6 +130,16 @@ class ServerSettings(BaseSettings):
|
||||
binding_file_download_command_timeout_seconds: float = Field(default=210.0, gt=0)
|
||||
server_secret_key: str | None = None
|
||||
api_token: str | None = None
|
||||
trajectory_enabled: bool = False
|
||||
trajectory_otlp_traces_endpoint: AnyHttpUrl | None = None
|
||||
trajectory_otlp_headers: dict[str, SecretStr] = Field(default_factory=dict, repr=False)
|
||||
trajectory_service_name: str = Field(default="dify-agent-trajectory", min_length=1)
|
||||
trajectory_include_content: bool = False
|
||||
trajectory_trace_context_mode: Literal["isolated", "shared"] = "isolated"
|
||||
trajectory_max_queue_size: int = Field(default=2048, gt=0)
|
||||
trajectory_max_export_batch_size: int = Field(default=512, gt=0)
|
||||
trajectory_schedule_delay_ms: int = Field(default=5000, gt=0)
|
||||
trajectory_export_timeout_ms: int = Field(default=5000, gt=0)
|
||||
shell_redact_patterns: str = ""
|
||||
outbound_http_connect_timeout: float = Field(default=10.0, ge=0)
|
||||
outbound_http_read_timeout: float = Field(default=600.0, ge=0)
|
||||
@@ -132,6 +148,7 @@ class ServerSettings(BaseSettings):
|
||||
outbound_http_max_connections: int = Field(default=100, ge=1)
|
||||
outbound_http_max_keepalive_connections: int = Field(default=20, ge=0)
|
||||
outbound_http_keepalive_expiry: float = Field(default=30.0, ge=0)
|
||||
observability_dotenv: dict[str, SecretStr] = Field(default_factory=dict, exclude=True, repr=False)
|
||||
|
||||
model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict(
|
||||
env_prefix="DIFY_AGENT_",
|
||||
@@ -140,6 +157,29 @@ class ServerSettings(BaseSettings):
|
||||
populate_by_name=True,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def settings_customise_sources(
|
||||
cls,
|
||||
settings_cls: type[BaseSettings],
|
||||
init_settings: PydanticBaseSettingsSource,
|
||||
env_settings: PydanticBaseSettingsSource,
|
||||
dotenv_settings: PydanticBaseSettingsSource,
|
||||
file_secret_settings: PydanticBaseSettingsSource,
|
||||
) -> tuple[PydanticBaseSettingsSource, ...]:
|
||||
variables: dict[str, str] = {}
|
||||
if isinstance(dotenv_settings, DotEnvSettingsSource):
|
||||
for key, value in dotenv_settings.env_vars.items():
|
||||
name = key if dotenv_settings.case_sensitive else key.upper()
|
||||
if value is not None and name.startswith(("OTEL_", "LOGFIRE_")):
|
||||
variables[name] = value
|
||||
return (
|
||||
InitSettingsSource(settings_cls, {"observability_dotenv": variables}),
|
||||
init_settings,
|
||||
env_settings,
|
||||
dotenv_settings,
|
||||
file_secret_settings,
|
||||
)
|
||||
|
||||
@field_validator("agent_stub_api_base_url")
|
||||
@classmethod
|
||||
def normalize_agent_stub_api_base_url_value(cls, value: str | None) -> str | None:
|
||||
@@ -195,6 +235,16 @@ class ServerSettings(BaseSettings):
|
||||
raise ValueError("DIFY_AGENT_INNER_API_URL must not include a query string or fragment")
|
||||
return parsed
|
||||
|
||||
@field_validator("trajectory_otlp_traces_endpoint")
|
||||
@classmethod
|
||||
def validate_trajectory_otlp_traces_endpoint(cls, value: AnyHttpUrl | None) -> AnyHttpUrl | None:
|
||||
"""Reject embedded credentials or fragments in the Agent OTLP endpoint."""
|
||||
if value is None:
|
||||
return None
|
||||
if value.username is not None or value.password is not None or value.fragment is not None:
|
||||
raise ValueError("DIFY_AGENT_TRAJECTORY_OTLP_TRACES_ENDPOINT must not include credentials or a fragment")
|
||||
return value
|
||||
|
||||
@field_validator("inner_api_key", "api_token")
|
||||
@classmethod
|
||||
def normalize_optional_api_token(cls, value: str | None) -> str | None:
|
||||
@@ -230,6 +280,15 @@ class ServerSettings(BaseSettings):
|
||||
)
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_trajectory_requirements(self) -> "ServerSettings":
|
||||
"""Validate the Agent endpoint and batch processor limits."""
|
||||
if self.trajectory_enabled and self.trajectory_otlp_traces_endpoint is None:
|
||||
raise ValueError("trajectory_otlp_traces_endpoint is required when trajectory_enabled is true")
|
||||
if self.trajectory_max_export_batch_size > self.trajectory_max_queue_size:
|
||||
raise ValueError("trajectory_max_export_batch_size must not exceed trajectory_max_queue_size")
|
||||
return self
|
||||
|
||||
def build_runtime_backend_profile(self) -> RuntimeBackendProfile | None:
|
||||
"""Build the deployment-selected resource backend without adding service state."""
|
||||
if self.runtime_backend == "local" and not self.local_sandbox_endpoint:
|
||||
|
||||
@@ -2,9 +2,11 @@ from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import importlib
|
||||
import os
|
||||
import time
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from dify_agent.agent_stub.server.app import create_agent_stub_app
|
||||
@@ -12,6 +14,18 @@ from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
|
||||
from dify_agent.server.settings import ServerSettings
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolated_agent_stub_settings(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# ServerSettings reads the process environment and a dotenv file resolved
|
||||
# against the current directory. A developer's local dify-agent/.env can
|
||||
# configure the Agent Stub, which would silently invalidate the unconfigured
|
||||
# cases here, so every case states the settings it exercises.
|
||||
for name in tuple(os.environ):
|
||||
if name.startswith("DIFY_AGENT_"):
|
||||
monkeypatch.delenv(name)
|
||||
monkeypatch.setitem(ServerSettings.model_config, "env_file", None)
|
||||
|
||||
|
||||
def _base64url_secret(value: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
@@ -0,0 +1,723 @@
|
||||
"""Real Logfire/OTel SDK tests for the opt-in Agent observability instance."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from collections.abc import Iterator
|
||||
from typing import Literal, cast
|
||||
|
||||
import httpx
|
||||
import logfire
|
||||
import pytest
|
||||
from opentelemetry import context as otel_context
|
||||
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 NonRecordingSpan, SpanContext, TraceFlags, get_current_span, set_span_in_context
|
||||
from pydantic_ai import Agent, Tool
|
||||
from pydantic_ai.models.instrumented import InstrumentationSettings
|
||||
from pydantic_ai.models.test import TestModel
|
||||
|
||||
import dify_agent.server.observability as server_observability
|
||||
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
|
||||
from dify_agent.runtime.agent_factory import DIFY_AGENT_RUN_NAME, create_agent
|
||||
from dify_agent.runtime.observability import (
|
||||
DIFY_TENANT_ID_ATTRIBUTE,
|
||||
GEN_AI_USER_ID_ATTRIBUTE,
|
||||
AgentObservability,
|
||||
IsolatedTracerProvider,
|
||||
dify_run_attributes,
|
||||
)
|
||||
from dify_agent.server.settings import ServerSettings
|
||||
|
||||
_AGENT_RUN_SPAN_NAME = f"invoke_agent {DIFY_AGENT_RUN_NAME}"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolate_environment(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setattr(os, "environ", dict(os.environ))
|
||||
for key in tuple(os.environ):
|
||||
if key.startswith(("OTEL_", "LOGFIRE_")):
|
||||
monkeypatch.delenv(key)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def restore_agent_instrumentation(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
|
||||
monkeypatch.setattr(Agent, "_instrument_default", False)
|
||||
yield
|
||||
|
||||
|
||||
def _local_client(exporter: InMemorySpanExporter) -> logfire.Logfire:
|
||||
return logfire.configure(
|
||||
local=True,
|
||||
send_to_logfire=False,
|
||||
console=False,
|
||||
metrics=False,
|
||||
inspect_arguments=False,
|
||||
additional_span_processors=[SimpleSpanProcessor(exporter)],
|
||||
)
|
||||
|
||||
|
||||
def _platform_provider(exporter: InMemorySpanExporter) -> TracerProvider:
|
||||
provider = TracerProvider()
|
||||
provider.add_span_processor(SimpleSpanProcessor(exporter))
|
||||
return provider
|
||||
|
||||
|
||||
def _static_tool() -> str:
|
||||
return "tool-sentinel-output"
|
||||
|
||||
|
||||
def _serialized_attributes(exporter: InMemorySpanExporter) -> str:
|
||||
return repr([dict(span.attributes or {}) for span in exporter.get_finished_spans()])
|
||||
|
||||
|
||||
def test_create_agent_instrument_disabled_survives_global_instrument_all() -> None:
|
||||
platform_exporter = InMemorySpanExporter()
|
||||
platform_provider = _platform_provider(platform_exporter)
|
||||
try:
|
||||
Agent.instrument_all(InstrumentationSettings(tracer_provider=platform_provider))
|
||||
|
||||
agent = create_agent(TestModel(custom_output_text="done"), tools=[])
|
||||
assert agent.instrument is False
|
||||
|
||||
_ = agent.run_sync("test-only input")
|
||||
platform_provider.force_flush()
|
||||
|
||||
assert not platform_exporter.get_finished_spans()
|
||||
finally:
|
||||
platform_provider.shutdown()
|
||||
|
||||
|
||||
def test_agent_observability_runs_agent_spans_to_business_exporter_only() -> None:
|
||||
platform_exporter = InMemorySpanExporter()
|
||||
platform_provider = _platform_provider(platform_exporter)
|
||||
business_exporter = InMemorySpanExporter()
|
||||
client = _local_client(business_exporter)
|
||||
try:
|
||||
Agent.instrument_all(InstrumentationSettings(tracer_provider=platform_provider))
|
||||
observability = AgentObservability(client=client)
|
||||
agent = create_agent(TestModel(custom_output_text="done"), tools=[Tool(_static_tool)])
|
||||
observability.instrument(agent)
|
||||
|
||||
_ = agent.run_sync("test-only input")
|
||||
assert client.force_flush(timeout_millis=10000)
|
||||
platform_provider.force_flush()
|
||||
|
||||
business_spans = business_exporter.get_finished_spans()
|
||||
assert business_spans
|
||||
assert any(
|
||||
span.instrumentation_scope is not None and "pydantic" in span.instrumentation_scope.name
|
||||
for span in business_spans
|
||||
)
|
||||
span_names = [span.name for span in business_spans]
|
||||
assert any("_static_tool" in name or "execute_tool" in name for name in span_names), span_names
|
||||
assert not platform_exporter.get_finished_spans()
|
||||
finally:
|
||||
client.shutdown(timeout_millis=5000)
|
||||
platform_provider.shutdown()
|
||||
|
||||
|
||||
def test_agent_observability_excludes_content_by_default() -> None:
|
||||
exporter = InMemorySpanExporter()
|
||||
client = _local_client(exporter)
|
||||
try:
|
||||
observability = AgentObservability(client=client, include_content=False)
|
||||
agent = create_agent(TestModel(custom_output_text="done"), tools=[Tool(_static_tool)])
|
||||
observability.instrument(agent)
|
||||
|
||||
_ = agent.run_sync("content-sentinel-input-1")
|
||||
assert client.force_flush(timeout_millis=10000)
|
||||
|
||||
spans = exporter.get_finished_spans()
|
||||
assert spans
|
||||
serialized = _serialized_attributes(exporter)
|
||||
assert "content-sentinel-input-1" not in serialized
|
||||
assert "tool-sentinel-output" not in serialized
|
||||
finally:
|
||||
client.shutdown(timeout_millis=5000)
|
||||
|
||||
|
||||
def test_agent_observability_includes_content_when_opted_in() -> None:
|
||||
exporter = InMemorySpanExporter()
|
||||
client = _local_client(exporter)
|
||||
try:
|
||||
observability = AgentObservability(client=client, include_content=True)
|
||||
agent = create_agent(TestModel(custom_output_text="done"), tools=[Tool(_static_tool)])
|
||||
observability.instrument(agent)
|
||||
|
||||
_ = agent.run_sync("content-sentinel-input-2")
|
||||
assert client.force_flush(timeout_millis=10000)
|
||||
|
||||
spans = exporter.get_finished_spans()
|
||||
assert spans
|
||||
serialized = _serialized_attributes(exporter)
|
||||
assert "content-sentinel-input-2" in serialized
|
||||
finally:
|
||||
client.shutdown(timeout_millis=5000)
|
||||
|
||||
|
||||
def test_business_agent_root_detaches_from_platform_parent_span() -> None:
|
||||
platform_exporter = InMemorySpanExporter()
|
||||
platform_client = _local_client(platform_exporter)
|
||||
business_exporter = InMemorySpanExporter()
|
||||
business_client = _local_client(business_exporter)
|
||||
try:
|
||||
observability = AgentObservability(client=business_client)
|
||||
agent = create_agent(TestModel(custom_output_text="done"), tools=[Tool(_static_tool)])
|
||||
observability.instrument(agent)
|
||||
|
||||
with platform_client.span("platform-incoming-request"):
|
||||
_ = agent.run_sync("test-only input")
|
||||
assert platform_client.force_flush(timeout_millis=10000)
|
||||
assert business_client.force_flush(timeout_millis=10000)
|
||||
|
||||
platform_root = next(
|
||||
span for span in platform_exporter.get_finished_spans() if span.name == "platform-incoming-request"
|
||||
)
|
||||
agent_root = next(span for span in business_exporter.get_finished_spans() if span.name == _AGENT_RUN_SPAN_NAME)
|
||||
assert agent_root.parent is None
|
||||
assert agent_root.context is not None
|
||||
assert agent_root.context.trace_id != platform_root.context.trace_id
|
||||
finally:
|
||||
business_client.shutdown(timeout_millis=5000)
|
||||
platform_client.shutdown(timeout_millis=5000)
|
||||
|
||||
|
||||
def test_agent_shutdown_does_not_stop_platform_pipeline() -> None:
|
||||
platform_exporter = InMemorySpanExporter()
|
||||
platform_client = _local_client(platform_exporter)
|
||||
business_exporter = InMemorySpanExporter()
|
||||
business_client = _local_client(business_exporter)
|
||||
try:
|
||||
Agent.instrument_all(InstrumentationSettings(tracer_provider=platform_client.config.get_tracer_provider()))
|
||||
observability = AgentObservability(client=business_client)
|
||||
agent = create_agent(TestModel(custom_output_text="done"), tools=[])
|
||||
observability.instrument(agent)
|
||||
_ = agent.run_sync("test-only input")
|
||||
assert business_client.force_flush(timeout_millis=10000)
|
||||
assert business_exporter.get_finished_spans()
|
||||
|
||||
business_client.shutdown(timeout_millis=5000)
|
||||
business_client = None
|
||||
|
||||
with platform_client.span("platform-marker-after-agent-shutdown"):
|
||||
pass
|
||||
assert platform_client.force_flush(timeout_millis=10000)
|
||||
assert any(
|
||||
span.name == "platform-marker-after-agent-shutdown" for span in platform_exporter.get_finished_spans()
|
||||
)
|
||||
finally:
|
||||
if business_client is not None:
|
||||
business_client.shutdown(timeout_millis=5000)
|
||||
platform_client.shutdown(timeout_millis=5000)
|
||||
|
||||
|
||||
def _spans_by_name(exporter: InMemorySpanExporter, name: str) -> list:
|
||||
return [span for span in exporter.get_finished_spans() if span.name == name]
|
||||
|
||||
|
||||
def _assert_parents_resolve_within(exporter: InMemorySpanExporter) -> None:
|
||||
spans = exporter.get_finished_spans()
|
||||
local_ids = {span.context.span_id for span in spans}
|
||||
for span in spans:
|
||||
if span.parent is not None:
|
||||
assert span.parent.span_id in local_ids, span.name
|
||||
|
||||
|
||||
def test_business_descendants_stay_in_business_trace_and_caller_context_restored() -> None:
|
||||
platform_exporter = InMemorySpanExporter()
|
||||
platform_client = _local_client(platform_exporter)
|
||||
business_exporter = InMemorySpanExporter()
|
||||
business_client = _local_client(business_exporter)
|
||||
try:
|
||||
observability = AgentObservability(client=business_client)
|
||||
agent = create_agent(TestModel(custom_output_text="done"), tools=[Tool(_static_tool)])
|
||||
observability.instrument(agent)
|
||||
|
||||
with platform_client.span("platform-incoming-request") as platform_span:
|
||||
_ = agent.run_sync("test-only input")
|
||||
assert get_current_span().get_span_context() == platform_span.get_span_context()
|
||||
assert platform_client.force_flush(timeout_millis=10000)
|
||||
assert business_client.force_flush(timeout_millis=10000)
|
||||
|
||||
platform_trace_ids = {span.context.trace_id for span in platform_exporter.get_finished_spans()}
|
||||
business_spans = business_exporter.get_finished_spans()
|
||||
roots = _spans_by_name(business_exporter, _AGENT_RUN_SPAN_NAME)
|
||||
assert len(roots) == 1
|
||||
root = roots[0]
|
||||
assert root.parent is None
|
||||
descendants = [span for span in business_spans if span is not root]
|
||||
assert descendants
|
||||
for span in descendants:
|
||||
assert span.parent is not None
|
||||
assert span.context.trace_id == root.context.trace_id
|
||||
_assert_parents_resolve_within(business_exporter)
|
||||
assert root.context.trace_id not in platform_trace_ids
|
||||
finally:
|
||||
business_client.shutdown(timeout_millis=5000)
|
||||
platform_client.shutdown(timeout_millis=5000)
|
||||
|
||||
|
||||
def test_platform_httpx_client_span_detaches_from_business_parent() -> None:
|
||||
platform_exporter = InMemorySpanExporter()
|
||||
platform_client = _local_client(platform_exporter)
|
||||
business_exporter = InMemorySpanExporter()
|
||||
business_client = _local_client(business_exporter)
|
||||
http = httpx.Client(transport=httpx.MockTransport(lambda _request: httpx.Response(200, json={"ok": True})))
|
||||
try:
|
||||
platform_client.instrument_httpx(
|
||||
http,
|
||||
capture_all=False,
|
||||
tracer_provider=IsolatedTracerProvider(platform_client, preserve_external_parent=True),
|
||||
)
|
||||
|
||||
def net_tool() -> str:
|
||||
return http.get("http://test-only.local/ping").text
|
||||
|
||||
observability = AgentObservability(client=business_client)
|
||||
agent = create_agent(TestModel(custom_output_text="done"), tools=[Tool(net_tool)])
|
||||
observability.instrument(agent)
|
||||
|
||||
with platform_client.span("platform-incoming-request"):
|
||||
_ = agent.run_sync("test-only input")
|
||||
http.close()
|
||||
assert platform_client.force_flush(timeout_millis=10000)
|
||||
assert business_client.force_flush(timeout_millis=10000)
|
||||
|
||||
platform_spans = platform_exporter.get_finished_spans()
|
||||
http_spans = [span for span in platform_spans if span.name.startswith("GET")]
|
||||
assert http_spans
|
||||
business_spans = business_exporter.get_finished_spans()
|
||||
business_trace_ids = {span.context.trace_id for span in business_spans}
|
||||
business_span_ids = {span.context.span_id for span in business_spans}
|
||||
for span in http_spans:
|
||||
assert span.parent is None or span.parent.span_id not in business_span_ids
|
||||
assert span.context.trace_id not in business_trace_ids
|
||||
_assert_parents_resolve_within(platform_exporter)
|
||||
_assert_parents_resolve_within(business_exporter)
|
||||
assert _spans_by_name(business_exporter, _AGENT_RUN_SPAN_NAME)
|
||||
finally:
|
||||
http.close()
|
||||
business_client.shutdown(timeout_millis=5000)
|
||||
platform_client.shutdown(timeout_millis=5000)
|
||||
|
||||
|
||||
def test_isolated_tracer_preserves_same_instance_and_external_parents() -> None:
|
||||
exporter = InMemorySpanExporter()
|
||||
client = _local_client(exporter)
|
||||
try:
|
||||
tracer = IsolatedTracerProvider(client, preserve_external_parent=True).get_tracer("test-scope")
|
||||
with client.span("platform-outer"):
|
||||
with tracer.start_as_current_span("platform-inner"):
|
||||
pass
|
||||
remote_ctx = set_span_in_context(
|
||||
NonRecordingSpan(
|
||||
SpanContext(
|
||||
trace_id=0x1111,
|
||||
span_id=0x2222,
|
||||
is_remote=True,
|
||||
trace_flags=TraceFlags(TraceFlags.SAMPLED),
|
||||
)
|
||||
)
|
||||
)
|
||||
with tracer.start_as_current_span("from-remote", context=remote_ctx):
|
||||
pass
|
||||
assert client.force_flush(timeout_millis=10000)
|
||||
|
||||
spans = {span.name: span for span in exporter.get_finished_spans()}
|
||||
inner = spans["platform-inner"]
|
||||
outer = spans["platform-outer"]
|
||||
remote = spans["from-remote"]
|
||||
assert inner.parent is not None
|
||||
assert inner.parent.span_id == outer.context.span_id
|
||||
assert inner.context.trace_id == outer.context.trace_id
|
||||
assert remote.parent is not None
|
||||
assert remote.parent.span_id == 0x2222
|
||||
assert remote.context.trace_id == 0x1111
|
||||
finally:
|
||||
client.shutdown(timeout_millis=5000)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ["start_span", "start_as_current_span"])
|
||||
@pytest.mark.parametrize("sampled", [True, False])
|
||||
def test_isolated_tracer_remote_and_same_instance_parent_policy(method: str, sampled: bool) -> None:
|
||||
platform_exporter = InMemorySpanExporter()
|
||||
platform_client = _local_client(platform_exporter)
|
||||
business_exporter = InMemorySpanExporter()
|
||||
business_client = _local_client(business_exporter)
|
||||
try:
|
||||
platform_tracer = IsolatedTracerProvider(platform_client, preserve_external_parent=True).get_tracer("platform")
|
||||
business_tracer = IsolatedTracerProvider(business_client).get_tracer("business")
|
||||
remote = NonRecordingSpan(
|
||||
SpanContext(
|
||||
trace_id=0xABCD,
|
||||
span_id=0x1234,
|
||||
is_remote=True,
|
||||
trace_flags=TraceFlags(TraceFlags.SAMPLED if sampled else 0),
|
||||
)
|
||||
)
|
||||
remote_ctx = otel_context.set_value("preserved-key", "preserved-value", set_span_in_context(remote))
|
||||
|
||||
def invoke(tracer, name, ctx) -> None:
|
||||
if method == "start_span":
|
||||
span = tracer.start_span(name, context=ctx)
|
||||
span.end()
|
||||
else:
|
||||
with tracer.start_as_current_span(name, context=ctx):
|
||||
pass
|
||||
|
||||
invoke(platform_tracer, "platform-remote-child", remote_ctx)
|
||||
invoke(business_tracer, "business-remote-child", remote_ctx)
|
||||
with business_client.span("business-outer"):
|
||||
invoke(business_tracer, "business-same-child", set_span_in_context(get_current_span()))
|
||||
assert platform_client.force_flush(timeout_millis=10000)
|
||||
assert business_client.force_flush(timeout_millis=10000)
|
||||
|
||||
platform_remotes = _spans_by_name(platform_exporter, "platform-remote-child")
|
||||
if sampled:
|
||||
platform_remote = platform_remotes[0]
|
||||
assert platform_remote.parent is not None
|
||||
assert platform_remote.parent.span_id == 0x1234
|
||||
assert platform_remote.context.trace_id == 0xABCD
|
||||
else:
|
||||
assert platform_remotes == []
|
||||
business_remote = _spans_by_name(business_exporter, "business-remote-child")[0]
|
||||
assert business_remote.parent is None
|
||||
assert business_remote.context.trace_id != 0xABCD
|
||||
business_same = _spans_by_name(business_exporter, "business-same-child")[0]
|
||||
business_outer_span = _spans_by_name(business_exporter, "business-outer")[0]
|
||||
assert business_same.parent is not None
|
||||
assert business_same.parent.span_id == business_outer_span.context.span_id
|
||||
assert business_same.context.trace_id == business_outer_span.context.trace_id
|
||||
finally:
|
||||
business_client.shutdown(timeout_millis=5000)
|
||||
platform_client.shutdown(timeout_millis=5000)
|
||||
|
||||
|
||||
def test_concurrent_agent_runs_have_independent_business_roots() -> None:
|
||||
platform_exporter = InMemorySpanExporter()
|
||||
platform_client = _local_client(platform_exporter)
|
||||
business_exporter = InMemorySpanExporter()
|
||||
business_client = _local_client(business_exporter)
|
||||
try:
|
||||
observability = AgentObservability(client=business_client)
|
||||
agents = []
|
||||
for index in range(2):
|
||||
agent = create_agent(TestModel(custom_output_text=f"done-{index}"), tools=[Tool(_static_tool)])
|
||||
observability.instrument(agent)
|
||||
agents.append(agent)
|
||||
|
||||
async def scenario() -> None:
|
||||
with platform_client.span("platform-shared-parent") as platform_span:
|
||||
await asyncio.gather(*(agent.run("test-only input") for agent in agents))
|
||||
assert get_current_span().get_span_context() == platform_span.get_span_context()
|
||||
|
||||
asyncio.run(scenario())
|
||||
assert platform_client.force_flush(timeout_millis=10000)
|
||||
assert business_client.force_flush(timeout_millis=10000)
|
||||
|
||||
roots = _spans_by_name(business_exporter, _AGENT_RUN_SPAN_NAME)
|
||||
assert len(roots) == 2
|
||||
assert all(root.parent is None for root in roots)
|
||||
assert roots[0].context.trace_id != roots[1].context.trace_id
|
||||
for span in business_exporter.get_finished_spans():
|
||||
if span in roots:
|
||||
continue
|
||||
assert span.parent is not None
|
||||
assert span.context.trace_id in {root.context.trace_id for root in roots}
|
||||
_assert_parents_resolve_within(business_exporter)
|
||||
finally:
|
||||
business_client.shutdown(timeout_millis=5000)
|
||||
platform_client.shutdown(timeout_millis=5000)
|
||||
|
||||
|
||||
def test_isolated_tracer_restores_context_on_exception() -> None:
|
||||
exporter = InMemorySpanExporter()
|
||||
client = _local_client(exporter)
|
||||
try:
|
||||
tracer = IsolatedTracerProvider(client).get_tracer("test-scope")
|
||||
with client.span("outer") as outer:
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
with tracer.start_as_current_span("failing-span"):
|
||||
raise RuntimeError("boom")
|
||||
assert get_current_span().get_span_context() == outer.get_span_context()
|
||||
assert client.force_flush(timeout_millis=10000)
|
||||
assert _spans_by_name(exporter, "failing-span")
|
||||
finally:
|
||||
client.shutdown(timeout_millis=5000)
|
||||
|
||||
|
||||
def test_isolated_tracer_restores_context_on_task_cancellation() -> None:
|
||||
exporter = InMemorySpanExporter()
|
||||
client = _local_client(exporter)
|
||||
try:
|
||||
tracer = IsolatedTracerProvider(client).get_tracer("test-scope")
|
||||
|
||||
async def scenario() -> None:
|
||||
with client.span("outer") as outer:
|
||||
started = asyncio.Event()
|
||||
|
||||
async def body() -> None:
|
||||
with tracer.start_as_current_span("cancelled-span"):
|
||||
started.set()
|
||||
await asyncio.Event().wait()
|
||||
|
||||
task = asyncio.create_task(body())
|
||||
await asyncio.wait_for(started.wait(), timeout=1)
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
assert get_current_span().get_span_context() == outer.get_span_context()
|
||||
|
||||
asyncio.run(scenario())
|
||||
finally:
|
||||
client.shutdown(timeout_millis=5000)
|
||||
|
||||
|
||||
def test_shared_trace_context_mode_links_business_run_to_platform_parent() -> None:
|
||||
platform_exporter = InMemorySpanExporter()
|
||||
platform_client = _local_client(platform_exporter)
|
||||
business_exporter = InMemorySpanExporter()
|
||||
business_client = _local_client(business_exporter)
|
||||
http = httpx.Client(transport=httpx.MockTransport(lambda _request: httpx.Response(200, json={"ok": True})))
|
||||
try:
|
||||
platform_client.instrument_httpx(
|
||||
http,
|
||||
capture_all=False,
|
||||
tracer_provider=platform_client.config.get_tracer_provider(),
|
||||
)
|
||||
|
||||
def net_tool() -> str:
|
||||
return http.get("http://test-only.local/ping").text
|
||||
|
||||
observability = AgentObservability(client=business_client, trace_context_mode="shared")
|
||||
agent = create_agent(TestModel(custom_output_text="done"), tools=[Tool(net_tool)])
|
||||
observability.instrument(agent)
|
||||
|
||||
with platform_client.span("shared-platform-root"):
|
||||
platform_context = get_current_span().get_span_context()
|
||||
_ = agent.run_sync("test-only input")
|
||||
http.close()
|
||||
assert platform_client.force_flush(timeout_millis=10000)
|
||||
assert business_client.force_flush(timeout_millis=10000)
|
||||
|
||||
platform_spans = platform_exporter.get_finished_spans()
|
||||
business_spans = business_exporter.get_finished_spans()
|
||||
all_spans = [*platform_spans, *business_spans]
|
||||
assert len({span.context.trace_id for span in all_spans}) == 1
|
||||
assert all(span.context.trace_id == platform_context.trace_id for span in all_spans)
|
||||
root = _spans_by_name(business_exporter, _AGENT_RUN_SPAN_NAME)[0]
|
||||
assert root.parent is not None
|
||||
assert root.parent.span_id == platform_context.span_id
|
||||
http_spans = [span for span in platform_spans if span.name.startswith("GET")]
|
||||
assert http_spans
|
||||
business_ids = {span.context.span_id for span in business_spans}
|
||||
for span in http_spans:
|
||||
assert span.parent is not None
|
||||
assert span.parent.span_id in business_ids
|
||||
all_ids = {(span.context.trace_id, span.context.span_id) for span in all_spans}
|
||||
for span in all_spans:
|
||||
if span.parent is not None:
|
||||
assert (span.context.trace_id, span.parent.span_id) in all_ids
|
||||
assert not any(
|
||||
span.instrumentation_scope is not None and "pydantic" in span.instrumentation_scope.name
|
||||
for span in platform_spans
|
||||
)
|
||||
assert not _spans_by_name(business_exporter, "shared-platform-root")
|
||||
finally:
|
||||
http.close()
|
||||
business_client.shutdown(timeout_millis=5000)
|
||||
platform_client.shutdown(timeout_millis=5000)
|
||||
|
||||
|
||||
def test_shared_mode_agent_pipeline_respects_remote_parent_sampling(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
business_exporter = InMemorySpanExporter()
|
||||
monkeypatch.setattr(server_observability, "OTLPSpanExporter", lambda **_kwargs: business_exporter)
|
||||
settings = ServerSettings(
|
||||
_env_file=None,
|
||||
trajectory_enabled=True,
|
||||
trajectory_otlp_traces_endpoint="http://127.0.0.1:1/v1/traces",
|
||||
trajectory_service_name="dify-agent-trajectory",
|
||||
trajectory_trace_context_mode="shared",
|
||||
)
|
||||
observability_instance = server_observability.configure_agent_observability(settings)
|
||||
assert observability_instance is not None
|
||||
try:
|
||||
agent = create_agent(TestModel(custom_output_text="done"), tools=[Tool(_static_tool)])
|
||||
observability_instance.instrument(agent)
|
||||
|
||||
unsampled = set_span_in_context(
|
||||
NonRecordingSpan(SpanContext(trace_id=0x9999, span_id=0x1111, is_remote=True, trace_flags=TraceFlags(0)))
|
||||
)
|
||||
token = otel_context.attach(unsampled)
|
||||
try:
|
||||
_ = agent.run_sync("unsampled-parent input")
|
||||
finally:
|
||||
otel_context.detach(token)
|
||||
assert observability_instance.client.force_flush(timeout_millis=10000)
|
||||
assert not business_exporter.get_finished_spans()
|
||||
|
||||
sampled = set_span_in_context(
|
||||
NonRecordingSpan(
|
||||
SpanContext(
|
||||
trace_id=0x8888,
|
||||
span_id=0x2222,
|
||||
is_remote=True,
|
||||
trace_flags=TraceFlags(TraceFlags.SAMPLED),
|
||||
)
|
||||
)
|
||||
)
|
||||
token = otel_context.attach(sampled)
|
||||
try:
|
||||
_ = agent.run_sync("sampled-parent input")
|
||||
finally:
|
||||
otel_context.detach(token)
|
||||
assert observability_instance.client.force_flush(timeout_millis=10000)
|
||||
spans = business_exporter.get_finished_spans()
|
||||
assert spans
|
||||
assert all(span.context.trace_id == 0x8888 for span in spans)
|
||||
root = _spans_by_name(business_exporter, _AGENT_RUN_SPAN_NAME)[0]
|
||||
assert root.parent is not None
|
||||
assert root.parent.span_id == 0x2222
|
||||
finally:
|
||||
observability_instance.client.shutdown(timeout_millis=5000)
|
||||
|
||||
|
||||
def _workflow_execution_context() -> DifyExecutionContextLayerConfig:
|
||||
return DifyExecutionContextLayerConfig(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
agent_id="agent-1",
|
||||
user_id="user-1",
|
||||
user_from="account",
|
||||
workflow_id="workflow-1",
|
||||
workflow_run_id="workflow-run-1",
|
||||
node_id="node-1",
|
||||
node_execution_id="node-execution-1",
|
||||
agent_mode="workflow_run",
|
||||
invoke_from="service-api",
|
||||
)
|
||||
|
||||
|
||||
def test_dify_run_attributes_use_data_push_names_and_drop_absent_fields() -> None:
|
||||
workflow_attributes = dict(dify_run_attributes(_workflow_execution_context()))
|
||||
|
||||
assert workflow_attributes == {
|
||||
"dify.tenant_id": "tenant-1",
|
||||
"dify.app_id": "app-1",
|
||||
"dify.agent_id": "agent-1",
|
||||
"gen_ai.user.id": "user-1",
|
||||
"dify.invoke_from": "service-api",
|
||||
"dify.workflow.id": "workflow-1",
|
||||
"dify.workflow.run_id": "workflow-run-1",
|
||||
"dify.node.id": "node-1",
|
||||
"dify.node.execution_id": "node-execution-1",
|
||||
}
|
||||
|
||||
agent_app_attributes = dict(
|
||||
dify_run_attributes(
|
||||
DifyExecutionContextLayerConfig(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
agent_id="agent-1",
|
||||
conversation_id="conversation-1",
|
||||
trace_id="trace-1",
|
||||
user_from="end-user",
|
||||
agent_mode="agent_app",
|
||||
invoke_from="web-app",
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# An Agent App turn has no workflow graph, so those keys stay absent instead of
|
||||
# being exported empty; the conversation and business trace ids take their place.
|
||||
assert agent_app_attributes == {
|
||||
"dify.trace_id": "trace-1",
|
||||
"dify.tenant_id": "tenant-1",
|
||||
"dify.app_id": "app-1",
|
||||
"dify.agent_id": "agent-1",
|
||||
"dify.invoke_from": "web-app",
|
||||
"dify.conversation.id": "conversation-1",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("trace_context_mode", ["isolated", "shared"])
|
||||
def test_agent_observability_stamps_dify_context_on_every_run_span(trace_context_mode: str) -> None:
|
||||
exporter = InMemorySpanExporter()
|
||||
client = _local_client(exporter)
|
||||
expected = dict(dify_run_attributes(_workflow_execution_context()))
|
||||
try:
|
||||
observability = AgentObservability(
|
||||
client=client,
|
||||
trace_context_mode=cast(Literal["isolated", "shared"], trace_context_mode),
|
||||
)
|
||||
agent = create_agent(TestModel(custom_output_text="done"), tools=[Tool(_static_tool)])
|
||||
observability.instrument(agent, execution_context=_workflow_execution_context())
|
||||
|
||||
_ = agent.run_sync("test-only input")
|
||||
assert client.force_flush(timeout_millis=10000)
|
||||
|
||||
spans = exporter.get_finished_spans()
|
||||
assert spans
|
||||
for span in spans:
|
||||
attributes = dict(span.attributes or {})
|
||||
assert {key: attributes.get(key) for key in expected} == expected, span.name
|
||||
run_span = _spans_by_name(exporter, _AGENT_RUN_SPAN_NAME)[0]
|
||||
assert run_span.attributes is not None
|
||||
assert run_span.attributes["gen_ai.operation.name"] == "invoke_agent"
|
||||
finally:
|
||||
client.shutdown(timeout_millis=5000)
|
||||
|
||||
|
||||
def test_agent_observability_omits_dify_attributes_without_an_execution_context() -> None:
|
||||
exporter = InMemorySpanExporter()
|
||||
client = _local_client(exporter)
|
||||
try:
|
||||
observability = AgentObservability(client=client)
|
||||
agent = create_agent(TestModel(custom_output_text="done"), tools=[Tool(_static_tool)])
|
||||
observability.instrument(agent)
|
||||
|
||||
_ = agent.run_sync("test-only input")
|
||||
assert client.force_flush(timeout_millis=10000)
|
||||
|
||||
spans = exporter.get_finished_spans()
|
||||
assert spans
|
||||
for span in spans:
|
||||
attributes = dict(span.attributes or {})
|
||||
assert not [key for key in attributes if key.startswith("dify.")], span.name
|
||||
assert GEN_AI_USER_ID_ATTRIBUTE not in attributes
|
||||
finally:
|
||||
client.shutdown(timeout_millis=5000)
|
||||
|
||||
|
||||
def test_dify_attributes_do_not_break_isolated_parent_policy() -> None:
|
||||
platform_exporter = InMemorySpanExporter()
|
||||
platform_client = _local_client(platform_exporter)
|
||||
business_exporter = InMemorySpanExporter()
|
||||
business_client = _local_client(business_exporter)
|
||||
try:
|
||||
observability = AgentObservability(client=business_client)
|
||||
agent = create_agent(TestModel(custom_output_text="done"), tools=[Tool(_static_tool)])
|
||||
observability.instrument(agent, execution_context=_workflow_execution_context())
|
||||
|
||||
with platform_client.span("platform-incoming-request"):
|
||||
_ = agent.run_sync("test-only input")
|
||||
assert platform_client.force_flush(timeout_millis=10000)
|
||||
assert business_client.force_flush(timeout_millis=10000)
|
||||
|
||||
platform_root = next(
|
||||
span for span in platform_exporter.get_finished_spans() if span.name == "platform-incoming-request"
|
||||
)
|
||||
root = _spans_by_name(business_exporter, _AGENT_RUN_SPAN_NAME)[0]
|
||||
assert root.parent is None
|
||||
assert root.context is not None
|
||||
assert root.context.trace_id != platform_root.context.trace_id
|
||||
_assert_parents_resolve_within(business_exporter)
|
||||
for span in platform_exporter.get_finished_spans():
|
||||
assert DIFY_TENANT_ID_ATTRIBUTE not in dict(span.attributes or {})
|
||||
finally:
|
||||
business_client.shutdown(timeout_millis=5000)
|
||||
platform_client.shutdown(timeout_millis=5000)
|
||||
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
from collections.abc import Mapping
|
||||
from typing import cast
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
@@ -26,6 +27,7 @@ from dify_agent.protocol.schemas import (
|
||||
utc_now,
|
||||
)
|
||||
from dify_agent.runtime.cancellation import RunCancellationIntent
|
||||
from dify_agent.runtime.observability import AgentObservability
|
||||
from dify_agent.runtime.event_sink import (
|
||||
NonTerminalRunEvent,
|
||||
RunFinalizationResult,
|
||||
@@ -487,6 +489,35 @@ def test_default_runner_factory_passes_runtime_limits_to_runner() -> None:
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_default_runner_factory_passes_agent_observability_instance() -> None:
|
||||
async def scenario() -> None:
|
||||
store = FakeStore()
|
||||
record = await store.create_run()
|
||||
sentinel = cast(AgentObservability, object())
|
||||
async with httpx.AsyncClient() as client:
|
||||
scheduler = RunScheduler(
|
||||
store=store,
|
||||
plugin_daemon_http_client=client,
|
||||
dify_api_http_client=client,
|
||||
agent_observability=sentinel,
|
||||
)
|
||||
|
||||
runner = scheduler._default_runner_factory(record, _request(), is_cancelled=lambda: False)
|
||||
default_scheduler = RunScheduler(
|
||||
store=store,
|
||||
plugin_daemon_http_client=client,
|
||||
dify_api_http_client=client,
|
||||
)
|
||||
default_runner = default_scheduler._default_runner_factory(record, _request(), is_cancelled=lambda: False)
|
||||
|
||||
assert isinstance(runner, AgentRunRunner)
|
||||
assert runner.agent_observability is sentinel
|
||||
assert isinstance(default_runner, AgentRunRunner)
|
||||
assert default_runner.agent_observability is None
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_create_run_starts_background_task_and_returns_running() -> None:
|
||||
async def scenario() -> None:
|
||||
store = FakeStore()
|
||||
|
||||
@@ -69,6 +69,8 @@ from dify_agent.protocol.schemas import (
|
||||
RunLayerSpec,
|
||||
RunSucceededEvent,
|
||||
)
|
||||
|
||||
from dify_agent.runtime.observability import AgentObservability
|
||||
from dify_agent.runtime.event_sink import InMemoryRunEventSink
|
||||
from dify_agent.runtime.compositor_factory import create_default_layer_providers
|
||||
from dify_agent.runtime.runner import (
|
||||
@@ -264,6 +266,8 @@ def _request(
|
||||
ask_human_config: DifyAskHumanLayerConfig | None = None,
|
||||
llm_layer_name: str = DIFY_AGENT_MODEL_LAYER_ID,
|
||||
execution_context_layer_name: str = "execution_context",
|
||||
app_id: str | None = None,
|
||||
agent_id: str | None = None,
|
||||
on_exit: LayerExitSignals | None = None,
|
||||
output_config: Mapping[str, object] | DifyOutputLayerConfig | None = None,
|
||||
model_settings: ModelSettings | None = None,
|
||||
@@ -296,6 +300,8 @@ def _request(
|
||||
type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
|
||||
config=DifyExecutionContextLayerConfig(
|
||||
tenant_id="tenant-1",
|
||||
app_id=app_id,
|
||||
agent_id=agent_id,
|
||||
user_from="account",
|
||||
agent_mode="workflow_run",
|
||||
invoke_from="service-api",
|
||||
@@ -739,6 +745,61 @@ def test_runner_passes_context_compaction(monkeypatch: pytest.MonkeyPatch) -> No
|
||||
assert sink.statuses["run-compaction"] == "succeeded"
|
||||
|
||||
|
||||
def test_runner_instruments_agent_with_injected_observability(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
class FakeAgentObservability:
|
||||
def __init__(self) -> None:
|
||||
self.instrumented: list[tuple[object, DifyExecutionContextLayerConfig | None]] = []
|
||||
|
||||
def instrument(
|
||||
self,
|
||||
agent: object,
|
||||
*,
|
||||
execution_context: DifyExecutionContextLayerConfig | None = None,
|
||||
) -> None:
|
||||
self.instrumented.append((agent, execution_context))
|
||||
|
||||
class FakeAgent:
|
||||
async def run(self, *_args: object, **_kwargs: object) -> FakeAgentRunResult:
|
||||
return FakeAgentRunResult("done")
|
||||
|
||||
observability = FakeAgentObservability()
|
||||
created_agent = FakeAgent()
|
||||
|
||||
def fake_get_model(_self: DifyPluginLLMLayer, *, http_client: httpx.AsyncClient, agent_run_id: str):
|
||||
assert http_client.is_closed is False
|
||||
return TestModel(custom_output_text="unused") # pyright: ignore[reportReturnType]
|
||||
|
||||
monkeypatch.setattr(DifyPluginLLMLayer, "get_model", fake_get_model)
|
||||
monkeypatch.setattr("dify_agent.runtime.runner.create_agent", lambda *_args, **_kwargs: created_agent)
|
||||
sink = InMemoryRunEventSink()
|
||||
|
||||
async def scenario() -> None:
|
||||
async with httpx.AsyncClient() as client:
|
||||
await AgentRunRunner(
|
||||
sink=sink,
|
||||
request=_request(app_id="app-1", agent_id="agent-1"),
|
||||
run_id="run-observed",
|
||||
plugin_daemon_http_client=client,
|
||||
dify_api_http_client=client,
|
||||
agent_observability=cast(AgentObservability, observability),
|
||||
).run()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
assert sink.statuses["run-observed"] == "succeeded"
|
||||
# The run's Dify context reaches observability through the model layer's
|
||||
# execution-context dependency, whose node name the caller chooses.
|
||||
assert len(observability.instrumented) == 1
|
||||
instrumented_agent, execution_context = observability.instrumented[0]
|
||||
assert instrumented_agent is created_agent
|
||||
assert execution_context is not None
|
||||
assert (execution_context.tenant_id, execution_context.app_id, execution_context.agent_id) == (
|
||||
"tenant-1",
|
||||
"app-1",
|
||||
"agent-1",
|
||||
)
|
||||
|
||||
|
||||
def test_runner_rejects_compaction_budget_before_model_resolution_or_invocation(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
|
||||
@@ -21,11 +21,14 @@ from dify_agent.runtime_backend.profile import (
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolated_backend_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# RuntimeBackendSettings reads the process environment, so a developer's
|
||||
# exported DIFY_AGENT_* / E2B_* values must not leak into these tests.
|
||||
# RuntimeBackendSettings reads both the process environment and a dotenv file
|
||||
# resolved against the current directory, so neither a developer's exported
|
||||
# DIFY_AGENT_* / E2B_* values nor their local dify-agent/.env may leak in.
|
||||
# Every case here states the settings it exercises.
|
||||
for name in list(os.environ):
|
||||
if name.startswith("DIFY_AGENT_") or name in ("E2B_API_KEY", "E2B_API_TOKEN"):
|
||||
monkeypatch.delenv(name)
|
||||
monkeypatch.setitem(RuntimeBackendSettings.model_config, "env_file", None)
|
||||
|
||||
|
||||
def test_e2b_backend_uses_prepared_dify_template_and_one_hour_lease_by_default() -> None:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
import time
|
||||
from typing import ClassVar
|
||||
|
||||
@@ -9,6 +10,7 @@ import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import dify_agent.server.app as app_module
|
||||
import dify_agent.server.observability as server_observability
|
||||
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
|
||||
from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer
|
||||
from dify_agent.layers.knowledge.configs import DifyKnowledgeBaseLayerConfig
|
||||
@@ -24,6 +26,24 @@ from dify_agent.server.settings import ServerSettings
|
||||
from dify_agent.storage.redis_run_store import RedisRunStore
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolated_app_settings(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Keep app construction independent of the developer's dotenv and SDK env.
|
||||
|
||||
``ServerSettings`` resolves ``.env``/``dify-agent/.env`` against the current
|
||||
directory, and importing this module builds ``app_module.app``, which already
|
||||
latched the process-global trace context mode from whatever it found there.
|
||||
Pinning the settings source and resetting that latch lets each case build an
|
||||
app from the settings it states, in any order and on any machine.
|
||||
"""
|
||||
for name in tuple(os.environ):
|
||||
if name.startswith(("DIFY_AGENT_", "OTEL_", "LOGFIRE_")):
|
||||
monkeypatch.delenv(name)
|
||||
monkeypatch.setitem(ServerSettings.model_config, "env_file", None)
|
||||
monkeypatch.setattr(server_observability, "_global_instrumentation_ready", False)
|
||||
monkeypatch.setattr(server_observability, "_global_trace_context_mode", None)
|
||||
|
||||
|
||||
def _base64url_secret(value: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii")
|
||||
|
||||
@@ -89,6 +109,7 @@ class FakeRunScheduler:
|
||||
stream_text_delta_flush_interval_seconds: float,
|
||||
stream_text_delta_max_chars: int,
|
||||
layer_providers: tuple[DifyAgentLayerProvider, ...],
|
||||
agent_observability: object | None = None,
|
||||
) -> None:
|
||||
self.store = store
|
||||
self.shutdown_grace_seconds = shutdown_grace_seconds
|
||||
@@ -99,6 +120,7 @@ class FakeRunScheduler:
|
||||
self.layer_providers = layer_providers
|
||||
self.plugin_daemon_http_client = plugin_daemon_http_client
|
||||
self.dify_api_http_client = dify_api_http_client
|
||||
self.agent_observability = agent_observability
|
||||
self.shutdown_called = False
|
||||
self.created.append(self)
|
||||
|
||||
@@ -449,6 +471,84 @@ def test_create_dify_api_inner_http_client_uses_generic_outbound_httpx_construct
|
||||
assert client.trust_env is False
|
||||
|
||||
|
||||
def test_create_app_lifecycle_owns_agent_observability_instance(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_patch_app_lifecycle(monkeypatch)
|
||||
events: list[str] = []
|
||||
|
||||
class FakeAgentObservability:
|
||||
async def aclose(self) -> None:
|
||||
events.append("agent-observability-close")
|
||||
|
||||
sentinel = FakeAgentObservability()
|
||||
|
||||
def fake_configure_agent_observability(_settings: ServerSettings) -> object:
|
||||
events.append("agent-observability-configure")
|
||||
return sentinel
|
||||
|
||||
async def recording_shutdown(self: FakeRunScheduler) -> None:
|
||||
self.shutdown_called = True
|
||||
events.append("scheduler-shutdown")
|
||||
|
||||
monkeypatch.setattr(app_module, "configure_agent_observability", fake_configure_agent_observability)
|
||||
monkeypatch.setattr(FakeRunScheduler, "shutdown", recording_shutdown)
|
||||
FakeRunScheduler.created.clear()
|
||||
|
||||
app = create_app(ServerSettings(redis_url="redis://example.invalid/0"))
|
||||
with TestClient(app):
|
||||
assert app.state.agent_observability is sentinel
|
||||
assert FakeRunScheduler.created[0].agent_observability is sentinel
|
||||
assert events == ["agent-observability-configure"]
|
||||
|
||||
# The instance is closed after the scheduler drains, so spans from runs
|
||||
# finishing during shutdown still reach the exporter.
|
||||
assert events == ["agent-observability-configure", "scheduler-shutdown", "agent-observability-close"]
|
||||
|
||||
|
||||
def test_create_app_defaults_agent_observability_to_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_patch_app_lifecycle(monkeypatch)
|
||||
FakeRunScheduler.created.clear()
|
||||
settings = ServerSettings(redis_url="redis://example.invalid/0", trajectory_enabled=False)
|
||||
created: list[ServerSettings] = []
|
||||
real_configure = app_module.configure_agent_observability
|
||||
|
||||
def recording_configure(settings: ServerSettings):
|
||||
created.append(settings)
|
||||
return real_configure(settings)
|
||||
|
||||
monkeypatch.setattr(app_module, "configure_agent_observability", recording_configure)
|
||||
app = create_app(settings)
|
||||
with TestClient(app):
|
||||
assert app.state.agent_observability is None
|
||||
assert FakeRunScheduler.created[0].agent_observability is None
|
||||
|
||||
assert created == [settings]
|
||||
assert app.state.platform_observability is not None
|
||||
|
||||
|
||||
def test_create_app_passes_settings_to_server_observability(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_patch_app_lifecycle(monkeypatch)
|
||||
captured: dict[str, object] = {}
|
||||
sentinel = object()
|
||||
|
||||
def fake_configure(app, *, settings=None):
|
||||
captured["app"] = app
|
||||
captured["settings"] = settings
|
||||
return sentinel
|
||||
|
||||
monkeypatch.setattr(app_module, "configure_server_observability", fake_configure)
|
||||
settings = ServerSettings(
|
||||
_env_file=None,
|
||||
redis_url="redis://example.invalid/0",
|
||||
trajectory_trace_context_mode="shared",
|
||||
)
|
||||
|
||||
app = create_app(settings)
|
||||
|
||||
assert captured["app"] is app
|
||||
assert captured["settings"] is settings
|
||||
assert app.state.platform_observability is sentinel
|
||||
|
||||
|
||||
def test_server_settings_use_generic_outbound_http_args_for_shared_clients() -> None:
|
||||
model_fields = ServerSettings.model_fields
|
||||
|
||||
|
||||
@@ -1,15 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import ClassVar
|
||||
import asyncio
|
||||
import os
|
||||
from typing import ClassVar, cast
|
||||
|
||||
import logfire
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from opentelemetry.sdk.trace.sampling import ALWAYS_ON, ParentBased
|
||||
from opentelemetry.trace import NoOpTracerProvider
|
||||
|
||||
import dify_agent.server.observability as observability
|
||||
from dify_agent.runtime.observability import AgentObservability
|
||||
from dify_agent.server.observability import configure_server_observability
|
||||
from dify_agent.server.settings import ServerSettings
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolate_observability_environment(monkeypatch, tmp_path):
|
||||
# The temporary working directory hides a developer's dotenv, while stripping
|
||||
# the SDK and DIFY_AGENT_* variables hides exported ones. Cases that exercise
|
||||
# a dotenv or a specific variable write it themselves after this runs.
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setattr(os, "environ", dict(os.environ))
|
||||
for key in tuple(os.environ):
|
||||
if key.startswith(("OTEL_", "LOGFIRE_", "DIFY_AGENT_")):
|
||||
monkeypatch.delenv(key)
|
||||
|
||||
|
||||
_FAKE_NATIVE_PROVIDER = NoOpTracerProvider()
|
||||
|
||||
|
||||
class _FakeLogfireConfig:
|
||||
@staticmethod
|
||||
def get_tracer_provider() -> NoOpTracerProvider:
|
||||
return _FAKE_NATIVE_PROVIDER
|
||||
|
||||
|
||||
class FakeLogfireModule:
|
||||
SamplingOptions: ClassVar[type] = logfire.SamplingOptions
|
||||
config: ClassVar[_FakeLogfireConfig] = _FakeLogfireConfig()
|
||||
configure_calls: ClassVar[list[dict[str, object]]] = []
|
||||
configure_envs: ClassVar[list[dict[str, str]]] = []
|
||||
fastapi_calls: ClassVar[list[dict[str, object]]] = []
|
||||
httpx_calls: ClassVar[list[dict[str, object]]] = []
|
||||
redis_calls: ClassVar[list[dict[str, object]]] = []
|
||||
@@ -18,14 +50,19 @@ class FakeLogfireModule:
|
||||
@classmethod
|
||||
def reset(cls) -> None:
|
||||
cls.configure_calls.clear()
|
||||
cls.configure_envs.clear()
|
||||
cls.fastapi_calls.clear()
|
||||
cls.httpx_calls.clear()
|
||||
cls.redis_calls.clear()
|
||||
cls.pydantic_ai_calls.clear()
|
||||
|
||||
@classmethod
|
||||
def configure(cls, **kwargs: object) -> None:
|
||||
def configure(cls, **kwargs: object) -> "type[FakeLogfireModule]":
|
||||
cls.configure_calls.append(kwargs)
|
||||
cls.configure_envs.append(
|
||||
{key: value for key, value in os.environ.items() if key.startswith(("OTEL_", "LOGFIRE_"))}
|
||||
)
|
||||
return cls
|
||||
|
||||
@classmethod
|
||||
def instrument_fastapi(cls, app: FastAPI, **kwargs: object) -> None:
|
||||
@@ -50,14 +87,16 @@ def test_configure_server_observability_keeps_remote_export_token_gated_by_logfi
|
||||
monkeypatch.setattr(observability, "_global_instrumentation_ready", False)
|
||||
app = FastAPI()
|
||||
|
||||
configure_server_observability(app)
|
||||
platform = configure_server_observability(app)
|
||||
|
||||
assert platform is FakeLogfireModule
|
||||
assert FakeLogfireModule.configure_calls == [
|
||||
{
|
||||
"send_to_logfire": "if-token-present",
|
||||
"inspect_arguments": False,
|
||||
}
|
||||
]
|
||||
assert FakeLogfireModule.configure_envs == [{}]
|
||||
|
||||
|
||||
def test_configure_server_observability_instruments_server_boundaries_once(monkeypatch) -> None:
|
||||
@@ -70,12 +109,418 @@ def test_configure_server_observability_instruments_server_boundaries_once(monke
|
||||
configure_server_observability(first_app)
|
||||
configure_server_observability(second_app)
|
||||
|
||||
assert FakeLogfireModule.httpx_calls == [{}]
|
||||
assert FakeLogfireModule.redis_calls == [{}]
|
||||
assert FakeLogfireModule.pydantic_ai_calls == [{}]
|
||||
expected_provider = observability.IsolatedTracerProvider(FakeLogfireModule, preserve_external_parent=True)
|
||||
assert FakeLogfireModule.httpx_calls == [
|
||||
{
|
||||
"capture_all": False,
|
||||
"capture_headers": False,
|
||||
"capture_request_body": False,
|
||||
"capture_response_body": False,
|
||||
"tracer_provider": expected_provider,
|
||||
}
|
||||
]
|
||||
assert FakeLogfireModule.redis_calls == [{"capture_statement": False, "tracer_provider": expected_provider}]
|
||||
assert FakeLogfireModule.pydantic_ai_calls == []
|
||||
assert FakeLogfireModule.fastapi_calls == [
|
||||
{"app": first_app},
|
||||
{"app": second_app},
|
||||
{
|
||||
"app": first_app,
|
||||
"request_attributes_mapper": observability._platform_request_attributes,
|
||||
"capture_headers": False,
|
||||
"tracer_provider": expected_provider,
|
||||
},
|
||||
{
|
||||
"app": second_app,
|
||||
"request_attributes_mapper": observability._platform_request_attributes,
|
||||
"capture_headers": False,
|
||||
"tracer_provider": expected_provider,
|
||||
},
|
||||
]
|
||||
assert first_app.state.dify_agent_logfire_instrumented is True
|
||||
assert second_app.state.dify_agent_logfire_instrumented is True
|
||||
|
||||
|
||||
def test_configure_server_observability_shared_mode_uses_native_tracer_provider(monkeypatch) -> None:
|
||||
FakeLogfireModule.reset()
|
||||
monkeypatch.setattr(observability, "logfire", FakeLogfireModule)
|
||||
monkeypatch.setattr(observability, "_global_instrumentation_ready", False)
|
||||
monkeypatch.setattr(observability, "_global_trace_context_mode", None)
|
||||
app = FastAPI()
|
||||
|
||||
configure_server_observability(app, settings=ServerSettings(trajectory_trace_context_mode="shared"))
|
||||
|
||||
assert FakeLogfireModule.httpx_calls == [
|
||||
{
|
||||
"capture_all": False,
|
||||
"capture_headers": False,
|
||||
"capture_request_body": False,
|
||||
"capture_response_body": False,
|
||||
"tracer_provider": _FAKE_NATIVE_PROVIDER,
|
||||
}
|
||||
]
|
||||
assert FakeLogfireModule.redis_calls == [{"capture_statement": False, "tracer_provider": _FAKE_NATIVE_PROVIDER}]
|
||||
assert FakeLogfireModule.fastapi_calls == [
|
||||
{
|
||||
"app": app,
|
||||
"request_attributes_mapper": observability._platform_request_attributes,
|
||||
"capture_headers": False,
|
||||
"tracer_provider": _FAKE_NATIVE_PROVIDER,
|
||||
}
|
||||
]
|
||||
assert FakeLogfireModule.pydantic_ai_calls == []
|
||||
|
||||
|
||||
def test_configure_server_observability_reads_trace_context_mode_from_environment(monkeypatch) -> None:
|
||||
FakeLogfireModule.reset()
|
||||
monkeypatch.setattr(observability, "logfire", FakeLogfireModule)
|
||||
monkeypatch.setattr(observability, "_global_instrumentation_ready", False)
|
||||
monkeypatch.setattr(observability, "_global_trace_context_mode", None)
|
||||
monkeypatch.setenv("DIFY_AGENT_TRAJECTORY_TRACE_CONTEXT_MODE", "shared")
|
||||
|
||||
configure_server_observability(FastAPI())
|
||||
|
||||
assert FakeLogfireModule.httpx_calls[0]["tracer_provider"] is _FAKE_NATIVE_PROVIDER
|
||||
|
||||
|
||||
def test_configure_server_observability_rejects_trace_context_mode_change(monkeypatch) -> None:
|
||||
FakeLogfireModule.reset()
|
||||
monkeypatch.setattr(observability, "logfire", FakeLogfireModule)
|
||||
monkeypatch.setattr(observability, "_global_instrumentation_ready", False)
|
||||
monkeypatch.setattr(observability, "_global_trace_context_mode", None)
|
||||
|
||||
configure_server_observability(FastAPI(), settings=ServerSettings(trajectory_trace_context_mode="isolated"))
|
||||
|
||||
with pytest.raises(ValueError, match="Trace context mode cannot change"):
|
||||
configure_server_observability(FastAPI(), settings=ServerSettings(trajectory_trace_context_mode="shared"))
|
||||
assert len(FakeLogfireModule.configure_calls) == 1
|
||||
|
||||
configure_server_observability(FastAPI(), settings=ServerSettings(trajectory_trace_context_mode="isolated"))
|
||||
assert len(FakeLogfireModule.configure_calls) == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dotenv_path", [".env", "dify-agent/.env"])
|
||||
def test_configure_server_observability_forwards_otel_logfire_from_dotenv(
|
||||
monkeypatch, tmp_path, dotenv_path: str
|
||||
) -> None:
|
||||
FakeLogfireModule.reset()
|
||||
monkeypatch.setattr(observability, "logfire", FakeLogfireModule)
|
||||
monkeypatch.setattr(observability, "_global_instrumentation_ready", False)
|
||||
dotenv_file = tmp_path / dotenv_path
|
||||
dotenv_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
dotenv_file.write_text(
|
||||
'OTEL_EXPORTER_OTLP_ENDPOINT="http://127.0.0.1:4318"\n'
|
||||
'OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer test-only"\n'
|
||||
"LOGFIRE_SERVICE_NAME=dify-agent-test\n"
|
||||
'OTEL_RESOURCE_ATTRIBUTES="service.namespace=dify,deployment.environment.name=local"\n'
|
||||
"UNRELATED_SECRET=not-forwarded\n"
|
||||
"DIFY_AGENT_API_TOKEN=not-forwarded\n"
|
||||
"OTLP_API_KEY=not-forwarded\n"
|
||||
"NOT_OTEL_VALUE=not-forwarded\n"
|
||||
"OTELISH=not-forwarded\n"
|
||||
"OTEL_UNSET\n"
|
||||
"LOGFIRE_EMPTY=\n"
|
||||
)
|
||||
|
||||
configure_server_observability(FastAPI())
|
||||
|
||||
assert FakeLogfireModule.configure_envs == [
|
||||
{
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT": "http://127.0.0.1:4318",
|
||||
"OTEL_EXPORTER_OTLP_HEADERS": "Authorization=Bearer test-only",
|
||||
"LOGFIRE_SERVICE_NAME": "dify-agent-test",
|
||||
"OTEL_RESOURCE_ATTRIBUTES": "service.namespace=dify,deployment.environment.name=local",
|
||||
"LOGFIRE_EMPTY": "",
|
||||
}
|
||||
]
|
||||
for key in (
|
||||
"UNRELATED_SECRET",
|
||||
"DIFY_AGENT_API_TOKEN",
|
||||
"OTLP_API_KEY",
|
||||
"NOT_OTEL_VALUE",
|
||||
"OTELISH",
|
||||
"OTEL_UNSET",
|
||||
):
|
||||
assert key not in os.environ
|
||||
|
||||
|
||||
def test_configure_server_observability_later_dotenv_file_wins(monkeypatch, tmp_path) -> None:
|
||||
FakeLogfireModule.reset()
|
||||
monkeypatch.setattr(observability, "logfire", FakeLogfireModule)
|
||||
monkeypatch.setattr(observability, "_global_instrumentation_ready", False)
|
||||
(tmp_path / ".env").write_text("OTEL_EXPORTER_OTLP_ENDPOINT=http://root:4318\nLOGFIRE_SERVICE_NAME=root\n")
|
||||
nested_dir = tmp_path / "dify-agent"
|
||||
nested_dir.mkdir()
|
||||
(nested_dir / ".env").write_text("OTEL_EXPORTER_OTLP_ENDPOINT=http://nested:4318\n")
|
||||
|
||||
configure_server_observability(FastAPI())
|
||||
|
||||
assert FakeLogfireModule.configure_envs == [
|
||||
{
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT": "http://nested:4318",
|
||||
"LOGFIRE_SERVICE_NAME": "root",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("existing_endpoint", ["http://existing:4318", ""])
|
||||
def test_configure_server_observability_process_env_wins_and_is_idempotent(
|
||||
monkeypatch, tmp_path, existing_endpoint: str
|
||||
) -> None:
|
||||
FakeLogfireModule.reset()
|
||||
monkeypatch.setattr(observability, "logfire", FakeLogfireModule)
|
||||
monkeypatch.setattr(observability, "_global_instrumentation_ready", False)
|
||||
monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", existing_endpoint)
|
||||
monkeypatch.setenv("LOGFIRE_SERVICE_NAME", "existing-service")
|
||||
(tmp_path / ".env").write_text(
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT=http://dotenv:4318\n"
|
||||
"LOGFIRE_SERVICE_NAME=dotenv-service\n"
|
||||
"OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf\n"
|
||||
)
|
||||
|
||||
configure_server_observability(FastAPI())
|
||||
configure_server_observability(FastAPI())
|
||||
|
||||
expected = {
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT": existing_endpoint,
|
||||
"LOGFIRE_SERVICE_NAME": "existing-service",
|
||||
"OTEL_EXPORTER_OTLP_PROTOCOL": "http/protobuf",
|
||||
}
|
||||
assert FakeLogfireModule.configure_envs == [expected, expected]
|
||||
assert os.environ["OTEL_EXPORTER_OTLP_ENDPOINT"] == existing_endpoint
|
||||
|
||||
|
||||
@pytest.mark.parametrize("use_custom_file", [True, False])
|
||||
def test_configure_server_observability_reads_configured_env_file_paths(
|
||||
monkeypatch, tmp_path, use_custom_file: bool
|
||||
) -> None:
|
||||
FakeLogfireModule.reset()
|
||||
monkeypatch.setattr(observability, "logfire", FakeLogfireModule)
|
||||
monkeypatch.setattr(observability, "_global_instrumentation_ready", False)
|
||||
(tmp_path / ".env").write_text("LOGFIRE_SERVICE_NAME=default-dotenv\n")
|
||||
if use_custom_file:
|
||||
custom_file = tmp_path / "custom.env"
|
||||
custom_file.write_text("LOGFIRE_SERVICE_NAME=custom-dotenv\n")
|
||||
monkeypatch.setitem(ServerSettings.model_config, "env_file", custom_file)
|
||||
else:
|
||||
monkeypatch.setitem(ServerSettings.model_config, "env_file", None)
|
||||
|
||||
configure_server_observability(FastAPI())
|
||||
|
||||
if use_custom_file:
|
||||
assert FakeLogfireModule.configure_envs == [{"LOGFIRE_SERVICE_NAME": "custom-dotenv"}]
|
||||
else:
|
||||
assert FakeLogfireModule.configure_envs == [{}]
|
||||
|
||||
|
||||
class FakeOTLPSpanExporter:
|
||||
calls: ClassVar[list[dict[str, object]]] = []
|
||||
shutdown_called: bool
|
||||
|
||||
def __init__(self, **kwargs: object) -> None:
|
||||
self.kwargs = kwargs
|
||||
self.shutdown_called = False
|
||||
type(self).calls.append(kwargs)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
self.shutdown_called = True
|
||||
|
||||
|
||||
class FakeBatchSpanProcessor:
|
||||
calls: ClassVar[list[dict[str, object]]] = []
|
||||
instances: ClassVar[list["FakeBatchSpanProcessor"]] = []
|
||||
exporter: FakeOTLPSpanExporter
|
||||
shutdown_called: bool
|
||||
|
||||
def __init__(self, exporter: FakeOTLPSpanExporter, **kwargs: object) -> None:
|
||||
self.exporter = exporter
|
||||
self.kwargs = kwargs
|
||||
self.shutdown_called = False
|
||||
type(self).calls.append(kwargs)
|
||||
type(self).instances.append(self)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
self.shutdown_called = True
|
||||
|
||||
|
||||
class RaisingLogfireModule(FakeLogfireModule):
|
||||
@classmethod
|
||||
def configure(cls, **_kwargs: object) -> "type[RaisingLogfireModule]":
|
||||
cls.configure_calls.append(_kwargs)
|
||||
raise RuntimeError("configure failed")
|
||||
|
||||
|
||||
def _patch_agent_observability_dependencies(monkeypatch, logfire_module=FakeLogfireModule) -> None:
|
||||
logfire_module.reset()
|
||||
FakeOTLPSpanExporter.calls.clear()
|
||||
FakeBatchSpanProcessor.calls.clear()
|
||||
monkeypatch.setattr(observability, "logfire", logfire_module)
|
||||
monkeypatch.setattr(observability, "OTLPSpanExporter", FakeOTLPSpanExporter)
|
||||
monkeypatch.setattr(observability, "BatchSpanProcessor", FakeBatchSpanProcessor)
|
||||
FakeBatchSpanProcessor.instances.clear()
|
||||
|
||||
|
||||
def _enabled_trajectory_settings(**overrides: object) -> ServerSettings:
|
||||
values: dict[str, object] = {
|
||||
"trajectory_enabled": True,
|
||||
"trajectory_otlp_traces_endpoint": "http://127.0.0.1:4318/v1/traces",
|
||||
}
|
||||
values.update(overrides)
|
||||
return ServerSettings.model_validate(values)
|
||||
|
||||
|
||||
def test_configure_agent_observability_disabled_creates_no_sdk_or_exporter(monkeypatch) -> None:
|
||||
_patch_agent_observability_dependencies(monkeypatch)
|
||||
settings = _enabled_trajectory_settings(trajectory_enabled=False)
|
||||
|
||||
assert observability.configure_agent_observability(settings) is None
|
||||
assert FakeLogfireModule.configure_calls == []
|
||||
assert FakeOTLPSpanExporter.calls == []
|
||||
assert FakeBatchSpanProcessor.calls == []
|
||||
|
||||
|
||||
def test_configure_agent_observability_missing_endpoint_fails_before_sdk_setup(monkeypatch) -> None:
|
||||
_patch_agent_observability_dependencies(monkeypatch)
|
||||
settings = _enabled_trajectory_settings()
|
||||
settings.trajectory_otlp_traces_endpoint = None
|
||||
|
||||
with pytest.raises(ValueError, match="trajectory_otlp_traces_endpoint"):
|
||||
observability.configure_agent_observability(settings)
|
||||
assert FakeLogfireModule.configure_calls == []
|
||||
assert FakeOTLPSpanExporter.calls == []
|
||||
|
||||
|
||||
def test_configure_agent_observability_builds_independent_pipeline_without_inherited_env(monkeypatch) -> None:
|
||||
_patch_agent_observability_dependencies(monkeypatch)
|
||||
monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://platform:4318")
|
||||
monkeypatch.setenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", "http://platform:4318/v1/traces")
|
||||
monkeypatch.setenv("OTEL_EXPORTER_OTLP_HEADERS", "Authorization=platform-test-only")
|
||||
monkeypatch.setenv("LOGFIRE_TOKEN", "platform-test-only")
|
||||
settings = _enabled_trajectory_settings(
|
||||
trajectory_otlp_headers={"Authorization": "agent-test-only"},
|
||||
trajectory_service_name="dify-agent-trajectory",
|
||||
trajectory_include_content=True,
|
||||
)
|
||||
|
||||
result = observability.configure_agent_observability(settings)
|
||||
|
||||
assert isinstance(result, AgentObservability)
|
||||
assert result.client is FakeLogfireModule
|
||||
assert result.include_content is True
|
||||
assert FakeOTLPSpanExporter.calls == [
|
||||
{
|
||||
"endpoint": "http://127.0.0.1:4318/v1/traces",
|
||||
"headers": {"Authorization": "agent-test-only"},
|
||||
"timeout": 5,
|
||||
}
|
||||
]
|
||||
assert FakeBatchSpanProcessor.calls == [
|
||||
{
|
||||
"max_queue_size": 2048,
|
||||
"max_export_batch_size": 512,
|
||||
"schedule_delay_millis": 5000,
|
||||
"export_timeout_millis": 5000,
|
||||
}
|
||||
]
|
||||
configure_kwargs = FakeLogfireModule.configure_calls[0]
|
||||
assert configure_kwargs["local"] is True
|
||||
assert configure_kwargs["send_to_logfire"] is False
|
||||
assert configure_kwargs["service_name"] == "dify-agent-trajectory"
|
||||
assert configure_kwargs["console"] is False
|
||||
assert configure_kwargs["metrics"] is False
|
||||
assert configure_kwargs["inspect_arguments"] is False
|
||||
assert configure_kwargs["add_baggage_to_attributes"] is False
|
||||
assert configure_kwargs["sampling"] is not None
|
||||
assert configure_kwargs["sampling"].head is ALWAYS_ON
|
||||
assert result.trace_context_mode == "isolated"
|
||||
assert FakeLogfireModule.configure_envs == [{}]
|
||||
assert os.environ["OTEL_EXPORTER_OTLP_ENDPOINT"] == "http://platform:4318"
|
||||
assert os.environ["OTEL_EXPORTER_OTLP_HEADERS"] == "Authorization=platform-test-only"
|
||||
assert os.environ["LOGFIRE_TOKEN"] == "platform-test-only"
|
||||
|
||||
|
||||
def test_configure_agent_observability_shared_mode_uses_parent_based_sampling(monkeypatch) -> None:
|
||||
_patch_agent_observability_dependencies(monkeypatch)
|
||||
settings = _enabled_trajectory_settings(
|
||||
trajectory_trace_context_mode="shared",
|
||||
trajectory_include_content=True,
|
||||
)
|
||||
|
||||
result = observability.configure_agent_observability(settings)
|
||||
|
||||
assert isinstance(result, AgentObservability)
|
||||
assert result.trace_context_mode == "shared"
|
||||
assert result.include_content is True
|
||||
configure_kwargs = FakeLogfireModule.configure_calls[0]
|
||||
assert isinstance(configure_kwargs["sampling"].head, ParentBased)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["isolated", "shared"])
|
||||
def test_configure_agent_observability_disabled_ignores_trace_context_mode(monkeypatch, mode: str) -> None:
|
||||
_patch_agent_observability_dependencies(monkeypatch)
|
||||
settings = _enabled_trajectory_settings(trajectory_enabled=False, trajectory_trace_context_mode=mode)
|
||||
|
||||
assert observability.configure_agent_observability(settings) is None
|
||||
assert FakeLogfireModule.configure_calls == []
|
||||
assert FakeOTLPSpanExporter.calls == []
|
||||
|
||||
|
||||
def test_configure_agent_observability_passes_batch_processor_limits_to_processor(monkeypatch) -> None:
|
||||
_patch_agent_observability_dependencies(monkeypatch)
|
||||
settings = _enabled_trajectory_settings(
|
||||
trajectory_max_queue_size=1024,
|
||||
trajectory_max_export_batch_size=128,
|
||||
trajectory_schedule_delay_ms=250,
|
||||
trajectory_export_timeout_ms=1500,
|
||||
)
|
||||
|
||||
observability.configure_agent_observability(settings)
|
||||
|
||||
assert FakeBatchSpanProcessor.calls == [
|
||||
{
|
||||
"max_queue_size": 1024,
|
||||
"max_export_batch_size": 128,
|
||||
"schedule_delay_millis": 250,
|
||||
"export_timeout_millis": 1500,
|
||||
}
|
||||
]
|
||||
assert FakeOTLPSpanExporter.calls[0]["timeout"] == 5
|
||||
|
||||
|
||||
def test_configure_agent_observability_empty_headers_do_not_inherit_platform_auth(monkeypatch) -> None:
|
||||
_patch_agent_observability_dependencies(monkeypatch)
|
||||
monkeypatch.setenv("OTEL_EXPORTER_OTLP_HEADERS", "Authorization=platform-test-only")
|
||||
|
||||
observability.configure_agent_observability(_enabled_trajectory_settings())
|
||||
|
||||
assert FakeOTLPSpanExporter.calls[0]["headers"] == {}
|
||||
assert os.environ["OTEL_EXPORTER_OTLP_HEADERS"] == "Authorization=platform-test-only"
|
||||
|
||||
|
||||
def test_configure_agent_observability_restores_env_and_shuts_processor_on_configure_failure(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
_patch_agent_observability_dependencies(monkeypatch, logfire_module=RaisingLogfireModule)
|
||||
monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://platform:4318")
|
||||
|
||||
with pytest.raises(RuntimeError, match="configure failed"):
|
||||
observability.configure_agent_observability(_enabled_trajectory_settings())
|
||||
|
||||
assert len(FakeBatchSpanProcessor.instances) == 1
|
||||
assert FakeBatchSpanProcessor.instances[0].shutdown_called is True
|
||||
assert os.environ["OTEL_EXPORTER_OTLP_ENDPOINT"] == "http://platform:4318"
|
||||
|
||||
|
||||
class FakeAgentLogfireClient:
|
||||
def __init__(self) -> None:
|
||||
self.shutdown_calls: list[dict[str, object]] = []
|
||||
|
||||
def shutdown(self, **kwargs: object) -> None:
|
||||
self.shutdown_calls.append(kwargs)
|
||||
|
||||
|
||||
def test_agent_observability_aclose_shuts_down_only_the_agent_client() -> None:
|
||||
client = FakeAgentLogfireClient()
|
||||
instance = AgentObservability(client=cast(logfire.Logfire, client))
|
||||
|
||||
asyncio.run(instance.aclose())
|
||||
|
||||
assert client.shutdown_calls == [{"timeout_millis": 5000}]
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
"""Real two-endpoint OTLP isolation between platform and Agent observability."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
import logfire
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import (
|
||||
ExportTraceServiceRequest,
|
||||
ExportTraceServiceResponse,
|
||||
)
|
||||
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
||||
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
||||
from pydantic import BaseModel
|
||||
from pydantic_ai import Agent, Tool
|
||||
from pydantic_ai.models.test import TestModel
|
||||
|
||||
import dify_agent.server.observability as observability
|
||||
from dify_agent.runtime.agent_factory import create_agent
|
||||
from dify_agent.runtime.observability import IsolatedTracerProvider
|
||||
from dify_agent.server.observability import configure_agent_observability
|
||||
from dify_agent.server.settings import ServerSettings
|
||||
|
||||
|
||||
class _OTLPReceiver:
|
||||
"""Collect decoded OTLP trace exports and request headers on a local port."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
received: list[tuple[dict[str, str], ExportTraceServiceRequest]] = []
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_POST(self) -> None:
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
request = ExportTraceServiceRequest()
|
||||
request.ParseFromString(self.rfile.read(length))
|
||||
received.append(({key.casefold(): value for key, value in self.headers.items()}, request))
|
||||
body = ExportTraceServiceResponse().SerializeToString()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/x-protobuf")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, *_args: object) -> None:
|
||||
return None
|
||||
|
||||
self.received = received
|
||||
self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
|
||||
@property
|
||||
def endpoint(self) -> str:
|
||||
host, port = self.server.server_address[:2]
|
||||
return f"http://{host}:{port}/v1/traces"
|
||||
|
||||
def service_names(self) -> list[str]:
|
||||
names: list[str] = []
|
||||
for _headers, request in self.received:
|
||||
for resource_spans in request.resource_spans:
|
||||
for attribute in resource_spans.resource.attributes:
|
||||
if attribute.key == "service.name":
|
||||
names.append(attribute.value.string_value)
|
||||
return names
|
||||
|
||||
def span_names(self) -> list[str]:
|
||||
names: list[str] = []
|
||||
for _headers, request in self.received:
|
||||
for resource_spans in request.resource_spans:
|
||||
for scope_spans in resource_spans.scope_spans:
|
||||
names.extend(span.name for span in scope_spans.spans)
|
||||
return names
|
||||
|
||||
def scope_names(self) -> list[str]:
|
||||
names: list[str] = []
|
||||
for _headers, request in self.received:
|
||||
for resource_spans in request.resource_spans:
|
||||
for scope_spans in resource_spans.scope_spans:
|
||||
names.append(scope_spans.scope.name)
|
||||
return names
|
||||
|
||||
def authorizations(self) -> list[str | None]:
|
||||
return [headers.get("authorization") for headers, _request in self.received]
|
||||
|
||||
def close(self) -> None:
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
self.thread.join(timeout=5)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolate_environment(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setattr(os, "environ", dict(os.environ))
|
||||
for key in tuple(os.environ):
|
||||
if key.startswith(("OTEL_", "LOGFIRE_")):
|
||||
monkeypatch.delenv(key)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def restore_agent_instrumentation(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(Agent, "_instrument_default", False)
|
||||
|
||||
|
||||
def _static_tool() -> str:
|
||||
return "tool-sentinel-output"
|
||||
|
||||
|
||||
def _platform_client(endpoint: str, headers: dict[str, str]) -> logfire.Logfire:
|
||||
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
|
||||
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
||||
|
||||
return logfire.configure(
|
||||
local=True,
|
||||
send_to_logfire=False,
|
||||
console=False,
|
||||
metrics=False,
|
||||
inspect_arguments=False,
|
||||
additional_span_processors=[
|
||||
BatchSpanProcessor(OTLPSpanExporter(endpoint=endpoint, headers=headers, timeout=5))
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_agent_pipeline_is_isolated_from_platform_endpoint_and_credentials(
|
||||
restore_agent_instrumentation,
|
||||
) -> None:
|
||||
platform_receiver = _OTLPReceiver()
|
||||
agent_receiver = _OTLPReceiver()
|
||||
platform: logfire.Logfire | None = None
|
||||
agent_observability = None
|
||||
try:
|
||||
platform_port = platform_receiver.server.server_address[1]
|
||||
os.environ["OTEL_EXPORTER_OTLP_ENDPOINT"] = f"http://127.0.0.1:{platform_port}"
|
||||
os.environ["OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"] = platform_receiver.endpoint
|
||||
os.environ["OTEL_EXPORTER_OTLP_METRICS_ENDPOINT"] = f"http://127.0.0.1:{platform_port}/v1/metrics"
|
||||
os.environ["OTEL_EXPORTER_OTLP_LOGS_ENDPOINT"] = f"http://127.0.0.1:{platform_port}/v1/logs"
|
||||
os.environ["OTEL_EXPORTER_OTLP_HEADERS"] = "Authorization=platform-test-only"
|
||||
os.environ["OTEL_RESOURCE_ATTRIBUTES"] = "service.name=platform-test"
|
||||
os.environ["LOGFIRE_TOKEN"] = "platform-test-only"
|
||||
os.environ["LOGFIRE_HTTPX_CAPTURE_ALL"] = "true"
|
||||
environment_snapshot = dict(os.environ)
|
||||
|
||||
platform = _platform_client(platform_receiver.endpoint, {"Authorization": "platform-test-only"})
|
||||
settings = ServerSettings(
|
||||
trajectory_enabled=True,
|
||||
trajectory_otlp_traces_endpoint=agent_receiver.endpoint,
|
||||
trajectory_otlp_headers={"Authorization": "agent-test-only"},
|
||||
trajectory_service_name="dify-agent-trajectory",
|
||||
)
|
||||
agent_observability = configure_agent_observability(settings)
|
||||
assert agent_observability is not None
|
||||
|
||||
agent = create_agent(TestModel(custom_output_text="done"), tools=[Tool(_static_tool)])
|
||||
assert agent.instrument is False
|
||||
agent_observability.instrument(agent)
|
||||
|
||||
with platform.span("platform-marker"):
|
||||
pass
|
||||
result = agent.run_sync("business-marker-input")
|
||||
assert result.output == "done"
|
||||
|
||||
assert platform.force_flush(timeout_millis=10000)
|
||||
assert agent_observability.client.force_flush(timeout_millis=10000)
|
||||
|
||||
platform_names = platform_receiver.span_names()
|
||||
agent_names = agent_receiver.span_names()
|
||||
assert "platform-marker" in platform_names
|
||||
assert "platform-marker" not in agent_names
|
||||
assert agent_names, "expected agent run spans on the business receiver"
|
||||
assert not any("pydantic" in scope for scope in platform_receiver.scope_names())
|
||||
assert set(platform_receiver.service_names()) == {"platform-test"}
|
||||
assert set(agent_receiver.service_names()) == {"dify-agent-trajectory"}
|
||||
assert platform_receiver.authorizations() == ["platform-test-only"] * len(platform_receiver.authorizations())
|
||||
assert agent_receiver.authorizations() == ["agent-test-only"] * len(agent_receiver.authorizations())
|
||||
assert dict(os.environ) == environment_snapshot
|
||||
finally:
|
||||
if agent_observability is not None:
|
||||
agent_observability.client.shutdown(timeout_millis=5000)
|
||||
if platform is not None:
|
||||
platform.shutdown(timeout_millis=5000)
|
||||
platform_receiver.close()
|
||||
agent_receiver.close()
|
||||
|
||||
|
||||
def test_agent_pipeline_empty_headers_send_no_platform_credentials(
|
||||
restore_agent_instrumentation,
|
||||
) -> None:
|
||||
agent_receiver = _OTLPReceiver()
|
||||
agent_observability = None
|
||||
try:
|
||||
os.environ["OTEL_EXPORTER_OTLP_HEADERS"] = "Authorization=platform-test-only"
|
||||
settings = ServerSettings(
|
||||
trajectory_enabled=True,
|
||||
trajectory_otlp_traces_endpoint=agent_receiver.endpoint,
|
||||
trajectory_service_name="dify-agent-trajectory",
|
||||
)
|
||||
agent_observability = configure_agent_observability(settings)
|
||||
assert agent_observability is not None
|
||||
|
||||
agent = create_agent(TestModel(custom_output_text="done"), tools=[])
|
||||
agent_observability.instrument(agent)
|
||||
_ = agent.run_sync("business-marker-input")
|
||||
assert agent_observability.client.force_flush(timeout_millis=10000)
|
||||
|
||||
assert agent_receiver.received, "expected spans on the business receiver"
|
||||
assert all(authorization is None for authorization in agent_receiver.authorizations())
|
||||
assert os.environ["OTEL_EXPORTER_OTLP_HEADERS"] == "Authorization=platform-test-only"
|
||||
finally:
|
||||
if agent_observability is not None:
|
||||
agent_observability.client.shutdown(timeout_millis=5000)
|
||||
agent_receiver.close()
|
||||
|
||||
|
||||
def test_agent_observability_disabled_creates_no_instance_or_export(
|
||||
restore_agent_instrumentation,
|
||||
) -> None:
|
||||
agent_receiver = _OTLPReceiver()
|
||||
try:
|
||||
settings = ServerSettings(
|
||||
trajectory_enabled=False,
|
||||
trajectory_otlp_traces_endpoint=agent_receiver.endpoint,
|
||||
)
|
||||
|
||||
assert configure_agent_observability(settings) is None
|
||||
agent = create_agent(TestModel(custom_output_text="done"), tools=[])
|
||||
assert agent.instrument is False
|
||||
_ = agent.run_sync("business-marker-input")
|
||||
|
||||
assert agent_receiver.received == []
|
||||
finally:
|
||||
agent_receiver.close()
|
||||
|
||||
|
||||
class _SmokePayload(BaseModel):
|
||||
text: str
|
||||
count: int
|
||||
|
||||
|
||||
def test_platform_fastapi_instrumentation_omits_parsed_argument_payloads() -> None:
|
||||
exporter = InMemorySpanExporter()
|
||||
client = logfire.configure(
|
||||
local=True,
|
||||
send_to_logfire=False,
|
||||
console=False,
|
||||
metrics=False,
|
||||
inspect_arguments=False,
|
||||
additional_span_processors=[SimpleSpanProcessor(exporter)],
|
||||
)
|
||||
app = FastAPI()
|
||||
|
||||
@app.post("/echo")
|
||||
def echo(payload: _SmokePayload) -> dict[str, str]:
|
||||
return {"text": payload.text}
|
||||
|
||||
try:
|
||||
with client.instrument_fastapi(
|
||||
app,
|
||||
request_attributes_mapper=observability._platform_request_attributes,
|
||||
capture_headers=False,
|
||||
tracer_provider=IsolatedTracerProvider(client, preserve_external_parent=True),
|
||||
):
|
||||
with TestClient(app) as http:
|
||||
assert http.post("/echo", json={"text": "valid-sentinel-payload", "count": 1}).status_code == 200
|
||||
assert http.post("/echo", json={"text": "invalid-sentinel-payload", "count": "x"}).status_code == 422
|
||||
assert client.force_flush(timeout_millis=10000)
|
||||
|
||||
spans = exporter.get_finished_spans()
|
||||
assert spans
|
||||
serialized = repr([dict(span.attributes or {}) for span in spans])
|
||||
serialized_events = repr([dict(event.attributes or {}) for span in spans for event in span.events])
|
||||
assert any("/echo" in name for name in [span.name for span in spans])
|
||||
assert "http.status_code" in serialized
|
||||
for sentinel in ("valid-sentinel-payload", "invalid-sentinel-payload"):
|
||||
assert sentinel not in serialized
|
||||
assert sentinel not in serialized_events
|
||||
assert "fastapi.arguments.values" not in serialized
|
||||
assert "fastapi.arguments.errors" not in serialized
|
||||
finally:
|
||||
client.shutdown(timeout_millis=5000)
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import os
|
||||
import secrets
|
||||
|
||||
import pytest
|
||||
@@ -24,6 +25,11 @@ from dify_agent.runtime_backend.openshell import (
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolate_settings_dotenv(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
|
||||
def _base64url_secret(value: bytes) -> str:
|
||||
import base64
|
||||
|
||||
@@ -526,6 +532,268 @@ def test_server_settings_rejects_non_array_shell_redact_patterns(monkeypatch: py
|
||||
_ = settings.get_shell_redact_patterns()
|
||||
|
||||
|
||||
def _isolate_trajectory_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
for key in (
|
||||
"DIFY_AGENT_TRAJECTORY_ENABLED",
|
||||
"DIFY_AGENT_TRAJECTORY_OTLP_TRACES_ENDPOINT",
|
||||
"DIFY_AGENT_TRAJECTORY_OTLP_HEADERS",
|
||||
"DIFY_AGENT_TRAJECTORY_SERVICE_NAME",
|
||||
"DIFY_AGENT_TRAJECTORY_INCLUDE_CONTENT",
|
||||
"DIFY_AGENT_TRAJECTORY_MAX_QUEUE_SIZE",
|
||||
"DIFY_AGENT_TRAJECTORY_MAX_EXPORT_BATCH_SIZE",
|
||||
"DIFY_AGENT_TRAJECTORY_SCHEDULE_DELAY_MS",
|
||||
"DIFY_AGENT_TRAJECTORY_EXPORT_TIMEOUT_MS",
|
||||
"DIFY_AGENT_TRAJECTORY_TRACE_CONTEXT_MODE",
|
||||
):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
|
||||
def test_server_settings_trajectory_defaults_disabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_isolate_trajectory_env(monkeypatch, tmp_path)
|
||||
|
||||
settings = ServerSettings()
|
||||
|
||||
assert settings.trajectory_enabled is False
|
||||
assert settings.trajectory_otlp_traces_endpoint is None
|
||||
assert settings.trajectory_otlp_headers == {}
|
||||
assert settings.trajectory_service_name == "dify-agent-trajectory"
|
||||
assert settings.trajectory_include_content is False
|
||||
assert settings.trajectory_max_queue_size == 2048
|
||||
assert settings.trajectory_max_export_batch_size == 512
|
||||
assert settings.trajectory_schedule_delay_ms == 5000
|
||||
assert settings.trajectory_export_timeout_ms == 5000
|
||||
assert settings.trajectory_trace_context_mode == "isolated"
|
||||
|
||||
|
||||
def test_server_settings_reads_trajectory_opt_in_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("DIFY_AGENT_TRAJECTORY_ENABLED", "true")
|
||||
monkeypatch.setenv("DIFY_AGENT_TRAJECTORY_OTLP_TRACES_ENDPOINT", "http://127.0.0.1:4318/v1/traces")
|
||||
monkeypatch.setenv("DIFY_AGENT_TRAJECTORY_OTLP_HEADERS", '{"Authorization": "Bearer test-only"}')
|
||||
monkeypatch.setenv("DIFY_AGENT_TRAJECTORY_SERVICE_NAME", "custom-trajectory")
|
||||
monkeypatch.setenv("DIFY_AGENT_TRAJECTORY_INCLUDE_CONTENT", "true")
|
||||
|
||||
settings = ServerSettings()
|
||||
|
||||
assert settings.trajectory_enabled is True
|
||||
assert str(settings.trajectory_otlp_traces_endpoint) == "http://127.0.0.1:4318/v1/traces"
|
||||
assert settings.trajectory_otlp_headers["Authorization"].get_secret_value() == "Bearer test-only"
|
||||
assert "test-only" not in repr(settings.trajectory_otlp_headers)
|
||||
assert settings.trajectory_service_name == "custom-trajectory"
|
||||
assert settings.trajectory_include_content is True
|
||||
|
||||
|
||||
def test_server_settings_trajectory_enabled_requires_endpoint(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_isolate_trajectory_env(monkeypatch, tmp_path)
|
||||
|
||||
with pytest.raises(ValidationError, match="trajectory_otlp_traces_endpoint"):
|
||||
_ = ServerSettings(trajectory_enabled=True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"endpoint",
|
||||
[
|
||||
"http://user:pass@collector.example/v1/traces",
|
||||
"http://collector.example/v1/traces#frag",
|
||||
],
|
||||
)
|
||||
def test_server_settings_rejects_trajectory_endpoint_with_credentials_or_fragment(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
endpoint: str,
|
||||
) -> None:
|
||||
_isolate_trajectory_env(monkeypatch, tmp_path)
|
||||
|
||||
with pytest.raises(ValidationError, match="credentials or a fragment"):
|
||||
_ = ServerSettings(trajectory_enabled=True, trajectory_otlp_traces_endpoint=endpoint)
|
||||
|
||||
|
||||
def test_server_settings_reads_trajectory_batch_processor_limits_from_env(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_isolate_trajectory_env(monkeypatch, tmp_path)
|
||||
monkeypatch.setenv("DIFY_AGENT_TRAJECTORY_MAX_QUEUE_SIZE", "1024")
|
||||
monkeypatch.setenv("DIFY_AGENT_TRAJECTORY_MAX_EXPORT_BATCH_SIZE", "128")
|
||||
monkeypatch.setenv("DIFY_AGENT_TRAJECTORY_SCHEDULE_DELAY_MS", "250")
|
||||
monkeypatch.setenv("DIFY_AGENT_TRAJECTORY_EXPORT_TIMEOUT_MS", "1500")
|
||||
|
||||
settings = ServerSettings()
|
||||
|
||||
assert settings.trajectory_max_queue_size == 1024
|
||||
assert settings.trajectory_max_export_batch_size == 128
|
||||
assert settings.trajectory_schedule_delay_ms == 250
|
||||
assert settings.trajectory_export_timeout_ms == 1500
|
||||
|
||||
|
||||
def test_server_settings_reads_trajectory_batch_processor_limits_from_dotenv(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_isolate_trajectory_env(monkeypatch, tmp_path)
|
||||
(tmp_path / ".env").write_text(
|
||||
"DIFY_AGENT_TRAJECTORY_MAX_QUEUE_SIZE=1024\n"
|
||||
"DIFY_AGENT_TRAJECTORY_MAX_EXPORT_BATCH_SIZE=128\n"
|
||||
"DIFY_AGENT_TRAJECTORY_SCHEDULE_DELAY_MS=250\n"
|
||||
"DIFY_AGENT_TRAJECTORY_EXPORT_TIMEOUT_MS=1500\n"
|
||||
)
|
||||
|
||||
settings = ServerSettings()
|
||||
|
||||
assert settings.trajectory_max_queue_size == 1024
|
||||
assert settings.trajectory_max_export_batch_size == 128
|
||||
assert settings.trajectory_schedule_delay_ms == 250
|
||||
assert settings.trajectory_export_timeout_ms == 1500
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field",
|
||||
[
|
||||
"trajectory_max_queue_size",
|
||||
"trajectory_max_export_batch_size",
|
||||
"trajectory_schedule_delay_ms",
|
||||
"trajectory_export_timeout_ms",
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("bad_value", [0, -1])
|
||||
def test_server_settings_rejects_non_positive_trajectory_batch_processor_limits(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
field: str,
|
||||
bad_value: int,
|
||||
) -> None:
|
||||
_isolate_trajectory_env(monkeypatch, tmp_path)
|
||||
|
||||
with pytest.raises(ValidationError, match=field):
|
||||
_ = ServerSettings.model_validate({field: bad_value})
|
||||
|
||||
|
||||
def test_server_settings_rejects_trajectory_batch_size_above_queue_size(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_isolate_trajectory_env(monkeypatch, tmp_path)
|
||||
|
||||
with pytest.raises(ValidationError, match="must not exceed"):
|
||||
_ = ServerSettings(trajectory_max_queue_size=1024, trajectory_max_export_batch_size=1025)
|
||||
|
||||
|
||||
def test_server_settings_accepts_equal_trajectory_batch_and_queue_size(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_isolate_trajectory_env(monkeypatch, tmp_path)
|
||||
|
||||
settings = ServerSettings(trajectory_max_queue_size=64, trajectory_max_export_batch_size=64)
|
||||
|
||||
assert settings.trajectory_max_queue_size == 64
|
||||
assert settings.trajectory_max_export_batch_size == 64
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["isolated", "shared"])
|
||||
def test_server_settings_reads_trajectory_trace_context_mode_from_env(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
mode: str,
|
||||
) -> None:
|
||||
_isolate_trajectory_env(monkeypatch, tmp_path)
|
||||
monkeypatch.setenv("DIFY_AGENT_TRAJECTORY_TRACE_CONTEXT_MODE", mode)
|
||||
|
||||
assert ServerSettings().trajectory_trace_context_mode == mode
|
||||
|
||||
|
||||
def test_server_settings_reads_trajectory_trace_context_mode_from_dotenv(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_isolate_trajectory_env(monkeypatch, tmp_path)
|
||||
(tmp_path / ".env").write_text("DIFY_AGENT_TRAJECTORY_TRACE_CONTEXT_MODE=shared\n")
|
||||
|
||||
assert ServerSettings().trajectory_trace_context_mode == "shared"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_mode", ["auto", "SHARED", ""])
|
||||
def test_server_settings_rejects_unknown_trajectory_trace_context_mode(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
bad_mode: str,
|
||||
) -> None:
|
||||
_isolate_trajectory_env(monkeypatch, tmp_path)
|
||||
|
||||
with pytest.raises(ValidationError, match="trajectory_trace_context_mode"):
|
||||
_ = ServerSettings.model_validate({"trajectory_trace_context_mode": bad_mode})
|
||||
|
||||
|
||||
def test_server_settings_observability_dotenv_snapshot_only_captures_sdk_keys(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
monkeypatch.delenv("OTEL_EXPORTER_OTLP_ENDPOINT", raising=False)
|
||||
monkeypatch.delenv("LOGFIRE_EMPTY", raising=False)
|
||||
monkeypatch.delenv("LOGFIRE_TOKEN", raising=False)
|
||||
dotenv = tmp_path / "otel.env"
|
||||
dotenv.write_text(
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT=http://snap:4318\n"
|
||||
"LOGFIRE_EMPTY=\n"
|
||||
"LOGFIRE_TOKEN=dotenv-test-only-secret\n"
|
||||
"OTEL_UNSET\n"
|
||||
"DIFY_AGENT_API_TOKEN=not-captured\n"
|
||||
"UNRELATED=not-captured\n"
|
||||
)
|
||||
|
||||
settings = ServerSettings(_env_file=dotenv)
|
||||
|
||||
snapshot = settings.observability_dotenv
|
||||
assert set(snapshot) == {"OTEL_EXPORTER_OTLP_ENDPOINT", "LOGFIRE_EMPTY", "LOGFIRE_TOKEN"}
|
||||
assert snapshot["OTEL_EXPORTER_OTLP_ENDPOINT"].get_secret_value() == "http://snap:4318"
|
||||
assert snapshot["LOGFIRE_EMPTY"].get_secret_value() == ""
|
||||
assert snapshot["LOGFIRE_TOKEN"].get_secret_value() == "dotenv-test-only-secret"
|
||||
assert "OTEL_EXPORTER_OTLP_ENDPOINT" not in os.environ
|
||||
assert "LOGFIRE_TOKEN" not in os.environ
|
||||
assert "observability_dotenv" not in settings.model_dump()
|
||||
assert "observability_dotenv" not in settings.model_dump_json()
|
||||
assert "http://snap:4318" not in repr(settings)
|
||||
assert "http://snap:4318" not in repr(snapshot)
|
||||
assert "dotenv-test-only-secret" not in repr(settings)
|
||||
assert "dotenv-test-only-secret" not in repr(snapshot)
|
||||
assert "dotenv-test-only-secret" not in settings.model_dump_json()
|
||||
assert "dotenv-test-only-secret" not in repr(settings.model_dump())
|
||||
|
||||
|
||||
def test_server_settings_observability_dotenv_snapshots_are_independent(tmp_path: Path) -> None:
|
||||
first = tmp_path / "first.env"
|
||||
first.write_text("OTEL_EXPORTER_OTLP_ENDPOINT=http://first:4318\n")
|
||||
second = tmp_path / "second.env"
|
||||
second.write_text("OTEL_EXPORTER_OTLP_ENDPOINT=http://second:4318\n")
|
||||
|
||||
settings_first = ServerSettings(_env_file=first)
|
||||
settings_second = ServerSettings(_env_file=second)
|
||||
settings_none = ServerSettings(_env_file=None)
|
||||
|
||||
assert settings_first.observability_dotenv["OTEL_EXPORTER_OTLP_ENDPOINT"].get_secret_value() == "http://first:4318"
|
||||
assert (
|
||||
settings_second.observability_dotenv["OTEL_EXPORTER_OTLP_ENDPOINT"].get_secret_value() == "http://second:4318"
|
||||
)
|
||||
assert settings_none.observability_dotenv == {}
|
||||
|
||||
|
||||
def test_server_settings_observability_dotenv_canonicalizes_names_by_case_sensitivity(tmp_path: Path) -> None:
|
||||
dotenv = tmp_path / "otel.env"
|
||||
dotenv.write_text("otel_exporter_otlp_endpoint=http://lower:4318\nLOGFIRE_SERVICE_NAME=upper\n")
|
||||
|
||||
insensitive = ServerSettings(_env_file=dotenv)
|
||||
assert set(insensitive.observability_dotenv) == {"OTEL_EXPORTER_OTLP_ENDPOINT", "LOGFIRE_SERVICE_NAME"}
|
||||
|
||||
sensitive = ServerSettings(_env_file=dotenv, _case_sensitive=True)
|
||||
assert set(sensitive.observability_dotenv) == {"LOGFIRE_SERVICE_NAME"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"overrides",
|
||||
[{"runtime_backend": "local"}, {"e2b_api_key": None}, {"e2b_project_id": " "}, {"inner_api_key": None}],
|
||||
|
||||
@@ -29,6 +29,19 @@ DIFY_AGENT_OUTBOUND_HTTP_MAX_CONNECTIONS=100
|
||||
DIFY_AGENT_OUTBOUND_HTTP_MAX_KEEPALIVE_CONNECTIONS=20
|
||||
DIFY_AGENT_OUTBOUND_HTTP_KEEPALIVE_EXPIRY=30
|
||||
|
||||
DIFY_AGENT_TRAJECTORY_ENABLED=false
|
||||
DIFY_AGENT_TRAJECTORY_OTLP_TRACES_ENDPOINT=
|
||||
DIFY_AGENT_TRAJECTORY_OTLP_HEADERS={}
|
||||
DIFY_AGENT_TRAJECTORY_SERVICE_NAME=dify-agent-trajectory
|
||||
DIFY_AGENT_TRAJECTORY_INCLUDE_CONTENT=false
|
||||
DIFY_AGENT_TRAJECTORY_TRACE_CONTEXT_MODE=isolated
|
||||
DIFY_AGENT_TRAJECTORY_MAX_QUEUE_SIZE=2048
|
||||
DIFY_AGENT_TRAJECTORY_MAX_EXPORT_BATCH_SIZE=512
|
||||
DIFY_AGENT_TRAJECTORY_SCHEDULE_DELAY_MS=5000
|
||||
DIFY_AGENT_TRAJECTORY_EXPORT_TIMEOUT_MS=5000
|
||||
|
||||
LOGFIRE_CONSOLE=false
|
||||
|
||||
# Leave empty to derive from PLUGIN_DAEMON_URL and PLUGIN_DAEMON_KEY in Docker Compose.
|
||||
DIFY_AGENT_PLUGIN_DAEMON_URL=
|
||||
DIFY_AGENT_PLUGIN_DAEMON_API_KEY=
|
||||
|
||||
Reference in New Issue
Block a user