feat(trace): add provider-neutral unified tracing (#39451)

Co-authored-by: EvoX <evox@evomap.ai>
This commit is contained in:
BlackouttaandEvoX authored and GitHub committed 2026-08-21 11:28:18 +00:00
1 parent 23150c2319
commit 3a8fbcbd02
37 files changed
+3496 -58

No files matched your search

@@ -229,7 +229,9 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
:return:
"""
self._conversation_name_generate_thread = self._message_cycle_manager.generate_conversation_name(
conversation_id=self._conversation_id, query=self._application_generate_entity.query
conversation_id=self._conversation_id,
query=self._application_generate_entity.query,
message_id=self._message_id,
)
generator = self._wrapper_process_stream_response(trace_manager=self._application_generate_entity.trace_manager)
@@ -125,7 +125,9 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline[EasyUIAppGenerat
if self._application_generate_entity.app_config.app_mode != AppMode.COMPLETION:
# start generate conversation name thread
self._conversation_name_generate_thread = self._message_cycle_manager.generate_conversation_name(
conversation_id=self._conversation_id, query=self._application_generate_entity.query
conversation_id=self._conversation_id,
query=self._application_generate_entity.query,
message_id=self._message_id,
)
generator = self._wrapper_process_stream_response(trace_manager=self._application_generate_entity.trace_manager)
@@ -75,7 +75,9 @@ class MessageCycleManager:
return StreamEvent.MESSAGE
def generate_conversation_name(self, *, conversation_id: str, query: str) -> Thread | None:
def generate_conversation_name(
self, *, conversation_id: str, query: str, message_id: str | None = None
) -> Thread | None:
"""
Generate conversation name.
:param conversation_id: conversation id
@@ -100,6 +102,7 @@ class MessageCycleManager:
"flask_app": current_app._get_current_object(), # type: ignore
"conversation_id": conversation_id,
"query": query,
"message_id": message_id,
},
)
thread.daemon = True
@@ -110,7 +113,13 @@ class MessageCycleManager:
return thread
def _generate_conversation_name_worker(self, flask_app: Flask, conversation_id: str, query: str):
def _generate_conversation_name_worker(
self,
flask_app: Flask,
conversation_id: str,
query: str,
message_id: str | None = None,
):
with flask_app.app_context():
with session_factory.create_session() as session:
# get conversation and message
@@ -135,7 +144,11 @@ class MessageCycleManager:
else:
try:
name = LLMGenerator.generate_conversation_name(
app_model.tenant_id, query, conversation_id, conversation.app_id
app_model.tenant_id,
query,
conversation_id,
conversation.app_id,
message_id=message_id,
)
redis_client.setex(cache_key, 3600, name)
except Exception:
+7 -1
View File
@@ -188,7 +188,12 @@ class LLMGenerator:
@classmethod
def generate_conversation_name(
cls, tenant_id: str, query, conversation_id: str | None = None, app_id: str | None = None
cls,
tenant_id: str,
query,
conversation_id: str | None = None,
app_id: str | None = None,
message_id: str | None = None,
):
prompt = CONVERSATION_TITLE_PROMPT
@@ -239,6 +244,7 @@ class LLMGenerator:
TraceTask(
TraceTaskName.GENERATE_NAME_TRACE,
conversation_id=conversation_id,
message_id=message_id,
generate_conversation_name=name,
inputs=prompt,
timer=timer,
+8
View File
@@ -54,3 +54,11 @@ class BaseTracingConfig(BaseModel):
OPS_FILE_PATH = "ops_trace/"
OPS_TRACE_FAILED_KEY = "FAILED_OPS_TRACE"
def ops_trace_payload_path(app_id: str, file_id: str) -> str:
return f"{OPS_FILE_PATH}{app_id}/{file_id}.json"
def workflow_final_trace_file_id(workflow_run_id: str) -> str:
return f"workflow-final-{workflow_run_id}"
+1
View File
@@ -9,6 +9,7 @@ from core.helper.trace_id_helper import ParentTraceContext
class BaseTraceInfo(BaseModel):
operation_id: str | None = None
message_id: str | None = None
message_data: Any | None = None
inputs: Union[str, dict[str, Any], list[Any]] | None = None
+8
View File
@@ -9,6 +9,14 @@ class RetryableTraceDispatchError(RuntimeError):
"""Base class for transient trace dispatch failures that Celery may retry."""
class TraceParentContextAccessError(RetryableTraceDispatchError):
"""Raised when unified parent context storage is temporarily unavailable."""
class InvalidTraceParentContextError(RuntimeError):
"""Raised when stored unified parent context cannot be safely restored."""
class PendingTraceParentContextError(RetryableTraceDispatchError):
"""Raised when a nested trace arrives before its parent span context is available."""
+84 -36
View File
@@ -16,14 +16,16 @@ from pydantic import TypeAdapter
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
from configs import dify_config
from core.helper.encrypter import batch_decrypt_token, encrypt_token, obfuscated_token
from core.helper.trace_id_helper import ParentTraceContext
from core.ops.entities.config_entity import (
OPS_FILE_PATH,
BaseTracingConfig,
TracingProviderEnum,
ops_trace_payload_path,
)
from core.ops.entities.trace_entity import (
BaseTraceInfo,
DatasetRetrievalTraceInfo,
DraftNodeExecutionTrace,
GenerateNameTraceInfo,
@@ -37,6 +39,7 @@ from core.ops.entities.trace_entity import (
WorkflowNodeTraceInfo,
WorkflowTraceInfo,
)
from core.ops.unified_trace.registry import UnifiedProviderConfigEntry, unified_provider_config_map
from core.ops.utils import JSON_DICT_ADAPTER, get_message_data
from extensions.ext_database import db
from extensions.ext_storage import storage
@@ -344,6 +347,22 @@ class OpsTraceManager:
decrypted_configs_cache: LRUCache = LRUCache(maxsize=128)
_decryption_cache_lock = threading.RLock()
@classmethod
def _get_dispatch_entry(
cls, tracing_provider: str
) -> tuple[str, TracingProviderConfigEntry | UnifiedProviderConfigEntry]:
"""Select one tracing mode before constructing an instance.
Registered unified providers never fall back after construction or dispatch starts.
Unregistered providers continue through the legacy registry.
"""
if dify_config.OPS_TRACE_UNIFIED_ENABLED:
try:
return "unified", unified_provider_config_map[tracing_provider]
except KeyError:
pass
return "legacy", provider_config_map[tracing_provider]
@classmethod
def encrypt_tracing_config(
cls, tenant_id: str, tracing_provider: str, tracing_config: dict[str, Any], current_trace_config=None
@@ -526,17 +545,16 @@ class OpsTraceManager:
if not decrypt_trace_config:
return None
trace_instance, config_class = (
provider_config_map[tracing_provider]["trace_instance"],
provider_config_map[tracing_provider]["config_class"],
)
decrypt_trace_config_key = json.dumps(decrypt_trace_config, sort_keys=True)
tracing_instance = cls.ops_trace_instances_cache.get(decrypt_trace_config_key)
mode, dispatch_entry = cls._get_dispatch_entry(tracing_provider)
trace_instance = dispatch_entry["trace_instance"]
config_class = dispatch_entry["config_class"]
cache_key = (mode, tracing_provider, json.dumps(decrypt_trace_config, sort_keys=True))
tracing_instance = cls.ops_trace_instances_cache.get(cache_key)
if tracing_instance is None:
# create new tracing_instance and update the cache if it absent
# The mode is part of the key so unified and legacy clients never share mutable SDK state.
tracing_instance = trace_instance(config_class(**decrypt_trace_config))
cls.ops_trace_instances_cache[decrypt_trace_config_key] = tracing_instance
logger.info("new tracing_instance for app_id: %s", app_id)
cls.ops_trace_instances_cache[cache_key] = tracing_instance
logger.info("new %s tracing_instance for app_id: %s", mode, app_id)
return tracing_instance
@classmethod
@@ -739,6 +757,8 @@ class TraceTask:
trace_type: Any,
message_id: str | None = None,
workflow_execution: "WorkflowExecution | None" = None,
workflow_run_id: str | None = None,
workflow_total_tokens: int | None = None,
conversation_id: str | None = None,
user_id: str | None = None,
timer: Any | None = None,
@@ -746,8 +766,8 @@ class TraceTask:
):
self.trace_type = trace_type
self.message_id = message_id
self.workflow_run_id = workflow_execution.id_ if workflow_execution else None
self.workflow_total_tokens: int | None = workflow_execution.total_tokens if workflow_execution else None
self.workflow_run_id = workflow_execution.id_ if workflow_execution else workflow_run_id
self.workflow_total_tokens = workflow_execution.total_tokens if workflow_execution else workflow_total_tokens
self.conversation_id = conversation_id
self.user_id = user_id
self.timer = timer
@@ -1275,6 +1295,7 @@ class TraceTask:
generate_name_trace_info = GenerateNameTraceInfo(
trace_id=self.trace_id,
message_id=self.message_id,
conversation_id=conversation_id,
inputs=inputs,
outputs=generate_conversation_name,
@@ -1539,30 +1560,57 @@ class TraceQueueManager:
trace_manager_timer.daemon = False
trace_manager_timer.start()
def _resolve_storage_id(self, task: TraceTask) -> str | None:
storage_id = task.app_id
if storage_id is not None:
return storage_id
tenant_id = task.kwargs.get("tenant_id")
if tenant_id:
return f"tenant-{tenant_id}"
logger.warning("Skipping trace without app_id or tenant_id, trace_type: %s", task.trace_type)
return None
def persist_trace_task(self, task: TraceTask, *, file_id: str | None = None) -> dict[str, str] | None:
if not (self._enterprise_telemetry_enabled or self.trace_instance):
return None
task.app_id = self.app_id
storage_id = self._resolve_storage_id(task)
if storage_id is None:
return None
resolved_file_id = file_id or uuid4().hex
trace_info = task.execute()
if isinstance(trace_info, BaseTraceInfo) and trace_info.operation_id is None:
trace_info = trace_info.model_copy(update={"operation_id": str(uuid4())})
task_data = TaskData(
app_id=storage_id,
trace_info_type=type(trace_info).__name__,
trace_info=trace_info.model_dump() if trace_info else None,
)
storage.save(
ops_trace_payload_path(storage_id, resolved_file_id),
task_data.model_dump_json().encode("utf-8"),
)
return {"file_id": resolved_file_id, "app_id": storage_id}
def enqueue_persisted_trace(self, file_info: dict[str, str]) -> None:
process_trace_tasks.apply_async(
args=(file_info,),
retry=True,
retry_policy={
"max_retries": 3,
"interval_start": 0,
"interval_step": 1,
"interval_max": 2,
},
)
def send_to_celery(self, tasks: list[TraceTask]):
with self.flask_app.app_context():
for task in tasks:
storage_id = task.app_id
if storage_id is None:
tenant_id = task.kwargs.get("tenant_id")
if tenant_id:
storage_id = f"tenant-{tenant_id}"
else:
logger.warning("Skipping trace without app_id or tenant_id, trace_type: %s", task.trace_type)
continue
file_id = uuid4().hex
trace_info = task.execute()
task_data = TaskData(
app_id=storage_id,
trace_info_type=type(trace_info).__name__,
trace_info=trace_info.model_dump() if trace_info else None,
)
file_path = f"{OPS_FILE_PATH}{storage_id}/{file_id}.json"
storage.save(file_path, task_data.model_dump_json().encode("utf-8"))
file_info = {
"file_id": file_id,
"app_id": storage_id,
}
process_trace_tasks.delay(file_info) # type: ignore
file_info = self.persist_trace_task(task)
if file_info is not None:
self.enqueue_persisted_trace(file_info)
+1
View File
@@ -0,0 +1 @@
"""Provider-neutral trace construction and dispatch for opt-in unified tracing."""
+79
View File
@@ -0,0 +1,79 @@
"""Provider-independent trace entities emitted by the unified trace builder."""
from datetime import datetime
from enum import StrEnum
from typing import Any, Self
from pydantic import BaseModel, ConfigDict, Field, model_validator
from core.helper.trace_id_helper import ParentTraceContext
class CanonicalSpanKind(StrEnum):
CHAIN = "chain"
LLM = "llm"
RETRIEVER = "retriever"
TOOL = "tool"
AGENT = "agent"
class CanonicalSpanStatus(StrEnum):
OK = "ok"
ERROR = "error"
class CanonicalSpan(BaseModel):
"""One provider-neutral operation with an explicit parent."""
id: str
parent_id: str | None
name: str
kind: CanonicalSpanKind
start_time: datetime
end_time: datetime | None
inputs: Any = None
outputs: Any = None
status: CanonicalSpanStatus
error: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
synthetic: bool = False
can_parent_workflow: bool = False
publishes_parent_context: bool = False
links: tuple[str, ...] = ()
model_config = ConfigDict(extra="forbid", frozen=True)
class CanonicalTrace(BaseModel):
"""A parent-before-child span tree ready for a provider adapter."""
trace_id: str
session_id: str
root_span_id: str
spans: tuple[CanonicalSpan, ...]
external_parent: ParentTraceContext | None = None
required_parent_context_id: str | None = None
model_config = ConfigDict(extra="forbid", frozen=True)
@model_validator(mode="after")
def validate_fragment(self) -> Self:
if self.external_parent is not None and self.required_parent_context_id is not None:
raise ValueError("canonical trace cannot require two external parent modes")
seen: set[str] = set()
root_seen = False
for span in self.spans:
if span.id in seen:
raise ValueError(f"duplicate canonical span id: {span.id}")
if span.id == self.root_span_id:
root_seen = True
if span.parent_id is not None:
raise ValueError("canonical trace root cannot have a local parent")
elif span.parent_id is None or span.parent_id not in seen:
raise ValueError(f"canonical span parent must appear first: {span.id}")
seen.add(span.id)
if not root_seen:
raise ValueError("canonical trace root is missing")
return self
+194
View File
@@ -0,0 +1,194 @@
"""Deterministically reconstruct provider-neutral workflow execution hierarchy."""
import re
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Any, Literal
from graphon.entities import WorkflowNodeExecution
_WRAPPER_INDEX_PATTERN = re.compile(r"^[A-Za-z0-9_.:-]+$")
_WRAPPER_FIELDS: tuple[tuple[Literal["iteration", "loop"], str, str], ...] = (
("iteration", "iteration_id", "iteration_index"),
("loop", "loop_id", "loop_index"),
)
WorkflowExecutionLike = WorkflowNodeExecution
def _read_attribute(value: object, name: str, default: Any = None) -> Any:
"""Read fields shared by persisted executions and legacy trace objects."""
return getattr(value, name, default) # guard-ignore: no-new-getattr -- supports legacy trace objects
@dataclass(frozen=True)
class WrapperKey:
kind: Literal["iteration", "loop"]
container_execution_id: str
index: str
@dataclass(frozen=True)
class WrapperSpec:
id: str
key: WrapperKey
parent_execution_id: str
child_execution_ids: frozenset[str]
start_time: datetime
end_time: datetime
has_error: bool
@dataclass(frozen=True)
class WorkflowHierarchy:
parent_by_execution_id: Mapping[str, str]
wrapper_by_child_execution_id: Mapping[str, WrapperSpec]
wrappers: tuple[WrapperSpec, ...]
def execution_id(execution: WorkflowExecutionLike) -> str:
"""Return the persisted execution identifier used as a canonical span ID."""
return str(_read_attribute(execution, "id") or execution.node_execution_id)
def execution_metadata(execution: object) -> Mapping[str, Any]:
"""Read metadata from repository models and older trace test doubles."""
value = _read_attribute(execution, "execution_metadata_dict")
if not isinstance(value, Mapping):
value = _read_attribute(execution, "metadata")
if not isinstance(value, Mapping):
return {}
return {str(key): item for key, item in value.items()}
def _unique_execution_by_node_id(executions: Sequence[WorkflowExecutionLike]) -> dict[str, str]:
result: dict[str, str] = {}
ambiguous: set[str] = set()
for item in executions:
node_id = item.node_id
if not isinstance(node_id, str) or node_id in ambiguous:
continue
item_execution_id = execution_id(item)
previous = result.get(node_id)
if previous is None:
result[node_id] = item_execution_id
elif previous != item_execution_id:
result.pop(node_id, None)
ambiguous.add(node_id)
return result
def _metadata_or_attr(execution: object, metadata: Mapping[str, Any], key: str) -> Any:
value = metadata.get(key)
return value if value is not None else _read_attribute(execution, key)
def _normalize_index(value: Any) -> str | None:
if isinstance(value, bool):
return None
if isinstance(value, int):
return str(value) if value >= 0 else None
if isinstance(value, str) and _WRAPPER_INDEX_PATTERN.fullmatch(value):
return value
return None
def _finished_at(execution: WorkflowExecutionLike) -> datetime:
explicit_end = _read_attribute(execution, "end_time")
if isinstance(explicit_end, datetime):
return explicit_end
started_at = execution.created_at or _read_attribute(execution, "start_time") or datetime.now()
return started_at + timedelta(seconds=execution.elapsed_time or 0)
def _failed(execution: WorkflowExecutionLike) -> bool:
status = _read_attribute(execution.status, "value", execution.status)
return status == "failed"
def _remove_cycles(parent_by_execution_id: dict[str, str]) -> None:
cyclic: set[str] = set()
for start in sorted(parent_by_execution_id):
path: list[str] = []
position: dict[str, int] = {}
current = start
while current in parent_by_execution_id:
if current in position:
cyclic.update(path[position[current] :])
break
position[current] = len(path)
path.append(current)
current = parent_by_execution_id[current]
for execution in cyclic:
parent_by_execution_id.pop(execution, None)
def build_workflow_hierarchy(executions: Sequence[WorkflowExecutionLike]) -> WorkflowHierarchy:
"""Build stable parents and synthetic loop/iteration wrappers.
Ambiguous repeated graph node IDs and cyclic predecessor data are dropped
rather than guessed. Their spans consequently fall back to the workflow root.
"""
execution_by_node_id = _unique_execution_by_node_id(executions)
parent_by_execution_id: dict[str, str] = {}
for item in executions:
item_execution_id = execution_id(item)
predecessor_node_id = item.predecessor_node_id
parent_execution_id = (
execution_by_node_id.get(predecessor_node_id) if isinstance(predecessor_node_id, str) else None
)
if parent_execution_id is None:
metadata = execution_metadata(item)
for structured_key in ("iteration_id", "loop_id"):
container_node_id = _metadata_or_attr(item, metadata, structured_key)
if isinstance(container_node_id, str):
parent_execution_id = execution_by_node_id.get(container_node_id)
if parent_execution_id is not None:
break
if parent_execution_id and parent_execution_id != item_execution_id:
parent_by_execution_id[item_execution_id] = parent_execution_id
_remove_cycles(parent_by_execution_id)
grouped: dict[WrapperKey, list[WorkflowExecutionLike]] = {}
for item in executions:
metadata = execution_metadata(item)
for kind, container_key, index_key in _WRAPPER_FIELDS:
container_node_id = _metadata_or_attr(item, metadata, container_key)
index = _normalize_index(_metadata_or_attr(item, metadata, index_key))
if not isinstance(container_node_id, str) or index is None:
continue
container_execution_id = execution_by_node_id.get(container_node_id)
if container_execution_id is None or container_execution_id == execution_id(item):
continue
wrapper_key = WrapperKey(kind=kind, container_execution_id=container_execution_id, index=index)
grouped.setdefault(wrapper_key, []).append(item)
break
wrappers: list[WrapperSpec] = []
wrapper_by_child_execution_id: dict[str, WrapperSpec] = {}
for wrapper_key in sorted(grouped, key=lambda item: (item.kind, item.container_execution_id, item.index)):
children = grouped[wrapper_key]
child_ids = frozenset(execution_id(item) for item in children)
wrapper = WrapperSpec(
id=f"{wrapper_key.kind}:{wrapper_key.container_execution_id}:{wrapper_key.index}",
key=wrapper_key,
parent_execution_id=wrapper_key.container_execution_id,
child_execution_ids=child_ids,
start_time=min(item.created_at or datetime.now() for item in children),
end_time=max(_finished_at(item) for item in children),
has_error=any(_failed(item) for item in children),
)
wrappers.append(wrapper)
for child_id in child_ids:
wrapper_by_child_execution_id[child_id] = wrapper
parent_by_execution_id[child_id] = wrapper.id
return WorkflowHierarchy(
parent_by_execution_id=parent_by_execution_id,
wrapper_by_child_execution_id=wrapper_by_child_execution_id,
wrappers=tuple(wrappers),
)
@@ -0,0 +1,249 @@
"""Coordinate nested-workflow provider parent contexts through Redis.
The coordinator owns storage, compatibility decisions, validation, and retry
signals. Provider adapters only create and consume their opaque context fields.
"""
import hashlib
import json
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from enum import StrEnum
from typing import Literal, Protocol
from pydantic import BaseModel, ConfigDict, ValidationError
from sqlalchemy import select
from sqlalchemy.orm import Session
from configs import dify_config
from core.helper.trace_id_helper import ParentTraceContext
from core.ops.exceptions import (
InvalidTraceParentContextError,
PendingTraceParentContextError,
TraceParentContextAccessError,
)
from extensions.ext_database import db
from models.model import App, TraceAppConfig
from models.workflow import WorkflowRun
_PARENT_CONTEXT_KEY_PREFIX = "trace:unified:parent:"
class RedisParentContextStore(Protocol):
def setex(self, name: str, time: int, value: str) -> object:
raise NotImplementedError
def get(self, name: str) -> bytes | str | None:
raise NotImplementedError
class ProviderParentContext(BaseModel):
"""Versioned envelope containing the minimum provider restoration state."""
version: Literal[1] = 1
provider: str
scope: str
trace_id: str
parent_id: str
provider_context: dict[str, str]
model_config = ConfigDict(extra="forbid", frozen=True)
@dataclass(frozen=True)
class ParentDestination:
provider: str
scope: str
unified: bool
class ParentResolutionKind(StrEnum):
RESTORED = "restored"
LINKED_ROOT = "linked_root"
@dataclass(frozen=True)
class ParentResolution:
kind: ParentResolutionKind
context: ProviderParentContext | None = None
linked_parent: ParentTraceContext | None = None
@classmethod
def restored(cls, context: ProviderParentContext) -> "ParentResolution":
return cls(kind=ParentResolutionKind.RESTORED, context=context)
@classmethod
def linked_root(cls, parent: ParentTraceContext) -> "ParentResolution":
return cls(kind=ParentResolutionKind.LINKED_ROOT, linked_parent=parent)
ParentDestinationResolver = Callable[[str], ParentDestination | None]
def destination_scope(provider: str, endpoint: str, project: str) -> str:
"""Return a stable non-secret fingerprint for a provider destination."""
value = f"{provider}\0{endpoint.rstrip('/')}\0{project}"
return hashlib.sha256(value.encode()).hexdigest()
def parent_destination_from_config(
provider: str,
tracing_config: Mapping[str, object],
*,
unified: bool,
) -> ParentDestination:
"""Build destination compatibility from non-secret persisted fields."""
endpoint = tracing_config.get("endpoint")
project = tracing_config.get("project")
return ParentDestination(
provider=provider,
scope=destination_scope(
provider,
endpoint if isinstance(endpoint, str) else "",
project if isinstance(project, str) else "",
),
unified=unified,
)
def resolve_parent_destination(parent_workflow_run_id: str) -> ParentDestination | None:
"""Resolve whether a parent workflow can publish compatible unified context."""
with Session(db.engine) as session:
workflow_run = session.get(WorkflowRun, parent_workflow_run_id)
if workflow_run is None:
return None
app = session.get(App, workflow_run.app_id)
if app is None or not app.tracing:
return None
try:
app_tracing = json.loads(app.tracing)
except (TypeError, json.JSONDecodeError):
return None
if not isinstance(app_tracing, Mapping) or not app_tracing.get("enabled"):
return None
provider = app_tracing.get("tracing_provider")
if not isinstance(provider, str):
return None
trace_config = session.scalar(
select(TraceAppConfig)
.where(TraceAppConfig.app_id == app.id, TraceAppConfig.tracing_provider == provider)
.limit(1)
)
if trace_config is None or not isinstance(trace_config.tracing_config, Mapping):
return None
unified = False
if dify_config.OPS_TRACE_UNIFIED_ENABLED:
from core.ops.unified_trace.registry import unified_provider_config_map
try:
unified_provider_config_map[provider]
unified = True
except KeyError:
pass
return parent_destination_from_config(provider, trace_config.tracing_config, unified=unified)
class ParentContextCoordinator:
"""Publish and resolve cross-task parent contexts for unified providers."""
def __init__(
self,
store: RedisParentContextStore,
resolve_parent_destination: ParentDestinationResolver,
) -> None:
self._store = store
self._resolve_parent_destination = resolve_parent_destination
@staticmethod
def _key(parent_node_execution_id: str) -> str:
return f"{_PARENT_CONTEXT_KEY_PREFIX}{parent_node_execution_id}"
def publish(self, parent_node_execution_id: str, context: ProviderParentContext) -> None:
"""Persist an accepted provider parent so a nested task can restore it."""
try:
self._store.setex(
self._key(parent_node_execution_id),
dify_config.OPS_TRACE_PARENT_CONTEXT_TTL_SECONDS,
context.model_dump_json(),
)
except Exception as error:
raise TraceParentContextAccessError(
f"Could not publish unified parent context for node_execution_id={parent_node_execution_id}"
) from error
def resolve(
self,
parent: ParentTraceContext,
*,
expected_provider: str,
expected_scope: str,
) -> ParentResolution:
"""Restore compatible context or explicitly select a linked new root."""
destination = self._resolve_parent_destination(parent.parent_workflow_run_id)
if (
destination is None
or not destination.unified
or destination.provider != expected_provider
or destination.scope != expected_scope
):
return ParentResolution.linked_root(parent)
parent_node_execution_id = parent.parent_node_execution_id
if not parent_node_execution_id:
raise InvalidTraceParentContextError("Nested workflow parent context has no node execution ID")
return ParentResolution.restored(
self._restore(
parent_node_execution_id,
expected_provider=expected_provider,
expected_scope=expected_scope,
)
)
def resolve_required(
self,
parent_context_id: str,
*,
expected_provider: str,
expected_scope: str,
) -> ParentResolution:
"""Restore a parent context that must exist for an asynchronous child."""
return ParentResolution.restored(
self._restore(
parent_context_id,
expected_provider=expected_provider,
expected_scope=expected_scope,
)
)
def _restore(
self,
parent_context_id: str,
*,
expected_provider: str,
expected_scope: str,
) -> ProviderParentContext:
try:
raw_context = self._store.get(self._key(parent_context_id))
except Exception as error:
raise TraceParentContextAccessError(
f"Could not read unified parent context for parent_context_id={parent_context_id}"
) from error
if raw_context is None:
raise PendingTraceParentContextError(parent_context_id)
try:
context = ProviderParentContext.model_validate_json(raw_context)
except (ValidationError, ValueError, TypeError) as error:
raise InvalidTraceParentContextError(
f"Invalid unified parent context for parent_context_id={parent_context_id}"
) from error
if context.provider != expected_provider or context.scope != expected_scope:
raise InvalidTraceParentContextError(
"Stored unified parent context does not match the expected provider destination: "
f"parent_context_id={parent_context_id}"
)
return context
+82
View File
@@ -0,0 +1,82 @@
"""Unified trace runtime and provider adapter contract."""
from collections.abc import Callable
from typing import Protocol, override
from core.ops.base_trace_instance import BaseTraceInstance
from core.ops.entities.config_entity import BaseTracingConfig
from core.ops.entities.trace_entity import BaseTraceInfo
from core.ops.unified_trace.entities import CanonicalTrace
from core.ops.unified_trace.parent_context import (
ParentContextCoordinator,
ParentResolution,
ProviderParentContext,
)
from core.ops.unified_trace.trace_builder import CanonicalTraceBuilder
ParentContextPublisher = Callable[[str, ProviderParentContext], None]
class UnifiedTraceAdapter(Protocol):
@property
def provider_name(self) -> str:
raise NotImplementedError
@property
def scope(self) -> str:
raise NotImplementedError
def emit(
self,
trace: CanonicalTrace,
parent: ParentResolution | None,
publish_parent_context: ParentContextPublisher,
) -> None:
"""Emit one Core-resolved, parent-first canonical fragment.
Return only after the provider-specific synchronous acceptance step succeeds.
Raise RetryableTraceDispatchError when acceptance is unconfirmed because of a
recoverable provider or transport failure. Raise another exception for a
terminal failure. Publish parent context only after the corresponding provider
parent span has been accepted.
"""
raise NotImplementedError
class UnifiedTraceInstance(BaseTraceInstance):
"""Build, coordinate, and emit a trace without a legacy fallback path."""
def __init__(
self,
trace_config: BaseTracingConfig,
*,
builder: CanonicalTraceBuilder,
adapter: UnifiedTraceAdapter,
coordinator: ParentContextCoordinator,
) -> None:
super().__init__(trace_config)
self._builder = builder
self._adapter = adapter
self._coordinator = coordinator
@override
def trace(self, trace_info: BaseTraceInfo) -> None:
canonical_trace = self._builder.build(trace_info)
if canonical_trace is None:
return
parent_resolution = None
if canonical_trace.external_parent is not None:
parent_resolution = self._coordinator.resolve(
canonical_trace.external_parent,
expected_provider=self._adapter.provider_name,
expected_scope=self._adapter.scope,
)
elif canonical_trace.required_parent_context_id is not None:
parent_resolution = self._coordinator.resolve_required(
canonical_trace.required_parent_context_id,
expected_provider=self._adapter.provider_name,
expected_scope=self._adapter.scope,
)
self._adapter.emit(canonical_trace, parent_resolution, self._coordinator.publish)
+35
View File
@@ -0,0 +1,35 @@
"""Lazy registry for providers implemented by the unified tracing path."""
import collections
from typing import TypedDict, override
from core.ops.base_trace_instance import BaseTraceInstance
from core.ops.entities.config_entity import BaseTracingConfig, TracingProviderEnum
class UnifiedProviderConfigEntry(TypedDict):
config_class: type[BaseTracingConfig]
trace_instance: type[BaseTraceInstance]
class UnifiedTraceProviderConfigMap(collections.UserDict[str, UnifiedProviderConfigEntry]):
"""Resolve unified providers without importing their SDKs until selected."""
@override
def __getitem__(self, key: str) -> UnifiedProviderConfigEntry:
match key:
case TracingProviderEnum.PHOENIX:
from dify_trace_arize_phoenix.config import PhoenixConfig
from dify_trace_arize_phoenix.unified_trace import UnifiedPhoenixTrace
return {"config_class": PhoenixConfig, "trace_instance": UnifiedPhoenixTrace}
case TracingProviderEnum.LANGSMITH:
from dify_trace_langsmith.config import LangSmithConfig
from dify_trace_langsmith.unified_trace import UnifiedLangSmithTrace
return {"config_class": LangSmithConfig, "trace_instance": UnifiedLangSmithTrace}
case _:
raise KeyError(f"Unified tracing provider is not registered: {key}")
unified_provider_config_map = UnifiedTraceProviderConfigMap()
+452
View File
@@ -0,0 +1,452 @@
"""Build provider-neutral traces from core ops trace entities.
Workflow persistence is accessed through an injected loader. Provider adapters
therefore receive a complete parent-first tree and never query Dify models.
"""
from collections.abc import Callable, Mapping, Sequence
from datetime import datetime, timedelta
from typing import Any
from uuid import uuid4
from pydantic import ValidationError
from sqlalchemy.orm import sessionmaker
from core.app.workflow.retry_history import RETRY_HISTORY_PROCESS_DATA_KEY
from core.helper.trace_id_helper import ParentTraceContext
from core.ops.entities.trace_entity import (
BaseTraceInfo,
DatasetRetrievalTraceInfo,
GenerateNameTraceInfo,
MessageTraceInfo,
ModerationTraceInfo,
SuggestedQuestionTraceInfo,
ToolTraceInfo,
WorkflowTraceInfo,
)
from core.ops.unified_trace.entities import CanonicalSpan, CanonicalSpanKind, CanonicalSpanStatus, CanonicalTrace
from core.ops.unified_trace.hierarchy import (
WorkflowExecutionLike,
build_workflow_hierarchy,
execution_id,
execution_metadata,
)
from core.repositories import DifyCoreRepositoryFactory
from extensions.ext_database import db
from models import Account
from models.workflow import WorkflowNodeExecutionTriggeredFrom
WorkflowExecutionLoader = Callable[[WorkflowTraceInfo], Sequence[WorkflowExecutionLike]]
ServiceAccountResolver = Callable[[str], Account]
class RepositoryWorkflowExecutionLoader:
"""Load one workflow's executions through the tenant-scoped core repository."""
def __init__(self, get_service_account: ServiceAccountResolver) -> None:
self._get_service_account = get_service_account
def __call__(self, trace_info: WorkflowTraceInfo) -> Sequence[WorkflowExecutionLike]:
app_id = trace_info.metadata.get("app_id")
if not isinstance(app_id, str) or not app_id:
raise ValueError("No app_id found in workflow trace metadata")
repository = DifyCoreRepositoryFactory.create_workflow_node_execution_repository(
session_factory=sessionmaker(bind=db.engine),
tenant_id=trace_info.tenant_id,
user=self._get_service_account(app_id),
app_id=app_id,
triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN,
)
return repository.get_by_workflow_execution(workflow_execution_id=trace_info.workflow_run_id)
_NODE_KIND: dict[str, CanonicalSpanKind] = {
"llm": CanonicalSpanKind.LLM,
"knowledge-retrieval": CanonicalSpanKind.RETRIEVER,
"tool": CanonicalSpanKind.TOOL,
"agent": CanonicalSpanKind.AGENT,
}
_RETRY_SUMMARY_FIELDS = ("retry_index", "error", "elapsed_time", "created_at", "finished_at")
def _read_attribute(value: object, name: str, default: Any = None) -> Any:
"""Read fields shared by persisted trace models and legacy trace objects."""
return getattr(value, name, default) # guard-ignore: no-new-getattr -- supports legacy trace objects
def _retry_metadata(process_data: Mapping[str, Any]) -> dict[str, Any]:
raw_history = process_data.get(RETRY_HISTORY_PROCESS_DATA_KEY)
if not isinstance(raw_history, list):
return {}
attempts: list[dict[str, Any]] = []
for raw_attempt in raw_history:
if not isinstance(raw_attempt, Mapping):
continue
retry_index = raw_attempt.get("retry_index")
if isinstance(retry_index, bool) or not isinstance(retry_index, int) or retry_index <= 0:
continue
attempts.append({field: raw_attempt.get(field) for field in _RETRY_SUMMARY_FIELDS})
return {"retry_count": len(attempts), "retry_attempts": attempts} if attempts else {}
def resolve_session_id(trace_info: WorkflowTraceInfo | MessageTraceInfo) -> str:
"""Resolve an explicit trace session before stable Dify fallbacks."""
custom_session_id = trace_info.metadata.get("trace_session_id")
if isinstance(custom_session_id, str) and custom_session_id:
return custom_session_id
if isinstance(trace_info, WorkflowTraceInfo):
if trace_info.conversation_id:
return trace_info.conversation_id
parent_workflow_run_id, _ = trace_info.resolved_parent_context
return parent_workflow_run_id or trace_info.workflow_run_id
if trace_info.message_data is None:
return ""
conversation_id = _read_attribute(trace_info.message_data, "conversation_id")
return conversation_id if isinstance(conversation_id, str) else ""
def _status(error: str | None, status: Any = None) -> CanonicalSpanStatus:
status_value = _read_attribute(status, "value", status)
return CanonicalSpanStatus.ERROR if error or status_value in {"failed", "exception"} else CanonicalSpanStatus.OK
def _external_parent(trace_info: WorkflowTraceInfo) -> ParentTraceContext | None:
value = trace_info.metadata.get("parent_trace_context")
if isinstance(value, ParentTraceContext):
return value
if isinstance(value, Mapping):
try:
return ParentTraceContext.model_validate(value)
except ValidationError:
return None
return None
def _started_at(value: datetime | None) -> datetime:
return value or datetime.now()
def _single_session_id(trace_info: BaseTraceInfo) -> str:
value = trace_info.metadata.get("trace_session_id")
return value if isinstance(value, str) else ""
class CanonicalTraceBuilder:
"""Convert supported ops trace entities into canonical parent-first trees."""
def __init__(self, load_workflow_executions: WorkflowExecutionLoader) -> None:
self._load_workflow_executions = load_workflow_executions
def build(self, trace_info: BaseTraceInfo) -> CanonicalTrace | None:
match trace_info:
case WorkflowTraceInfo():
return self._build_workflow(trace_info)
case MessageTraceInfo():
return self._build_message(trace_info)
case ModerationTraceInfo():
return self._build_moderation(trace_info)
case SuggestedQuestionTraceInfo():
return self._build_suggested_question(trace_info)
case DatasetRetrievalTraceInfo():
return self._build_dataset_retrieval(trace_info)
case ToolTraceInfo():
return self._build_tool(trace_info)
case GenerateNameTraceInfo():
return self._build_generate_name(trace_info)
case _:
return None
def _build_workflow(self, trace_info: WorkflowTraceInfo) -> CanonicalTrace:
executions = self._load_workflow_executions(trace_info)
hierarchy = build_workflow_hierarchy(executions)
workflow_data = trace_info.workflow_data
workflow_start = _started_at(_read_attribute(workflow_data, "created_at") or trace_info.start_time)
workflow_end = _read_attribute(workflow_data, "finished_at") or trace_info.end_time
message_span_id = trace_info.message_id
workflow_span_id = trace_info.workflow_run_id
spans: dict[str, CanonicalSpan] = {}
if message_span_id:
spans[message_span_id] = CanonicalSpan(
id=message_span_id,
parent_id=None,
name=f"chatflow_{trace_info.workflow_run_id}",
kind=CanonicalSpanKind.CHAIN,
start_time=_started_at(trace_info.start_time or workflow_start),
end_time=trace_info.end_time or workflow_end,
inputs=trace_info.query or dict(trace_info.workflow_run_inputs),
outputs=dict(trace_info.workflow_run_outputs),
status=_status(trace_info.error),
error=trace_info.error or None,
metadata={**trace_info.metadata, "trace_entity_type": "message"},
publishes_parent_context=True,
)
root_span_id = message_span_id
workflow_parent_id: str | None = message_span_id
else:
root_span_id = workflow_span_id
workflow_parent_id = None
spans[workflow_span_id] = CanonicalSpan(
id=workflow_span_id,
parent_id=workflow_parent_id,
name=f"workflow_{workflow_span_id}",
kind=CanonicalSpanKind.CHAIN,
start_time=workflow_start,
end_time=workflow_end,
inputs=dict(trace_info.workflow_run_inputs),
outputs=dict(trace_info.workflow_run_outputs),
status=_status(trace_info.error, trace_info.workflow_run_status),
error=trace_info.error or None,
metadata={
**trace_info.metadata,
"workflow_id": trace_info.workflow_id,
"workflow_run_id": workflow_span_id,
"workflow_app_log_id": trace_info.workflow_app_log_id,
"total_tokens": trace_info.total_tokens,
},
)
execution_by_id = {execution_id(item): item for item in executions}
for wrapper in hierarchy.wrappers:
spans[wrapper.id] = CanonicalSpan(
id=wrapper.id,
parent_id=wrapper.parent_execution_id,
name=f"{wrapper.key.kind}[{wrapper.key.index}]",
kind=CanonicalSpanKind.CHAIN,
start_time=wrapper.start_time,
end_time=wrapper.end_time,
status=CanonicalSpanStatus.ERROR if wrapper.has_error else CanonicalSpanStatus.OK,
error="wrapper child failed" if wrapper.has_error else None,
metadata={
"wrapper_type": wrapper.key.kind,
"wrapper_index": wrapper.key.index,
"container_execution_id": wrapper.parent_execution_id,
},
synthetic=True,
)
for item_execution_id, item in execution_by_id.items():
process_data = _read_attribute(item, "process_data") or {}
outputs = _read_attribute(item, "outputs") or {}
node_type = str(_read_attribute(item, "node_type", ""))
error = _read_attribute(item, "error")
started_at = _started_at(_read_attribute(item, "created_at"))
elapsed_time = _read_attribute(item, "elapsed_time") or 0
metadata = dict(execution_metadata(item))
metadata.update(
{
"node_id": _read_attribute(item, "node_id", ""),
"node_execution_id": item_execution_id,
"node_type": node_type,
"status": _read_attribute(item, "status", ""),
"model_provider": process_data.get("model_provider"),
"model_name": process_data.get("model_name"),
}
)
usage = process_data.get("usage") or (outputs.get("usage") if isinstance(outputs, Mapping) else None) or {}
if isinstance(usage, Mapping):
metadata.update(
{
"prompt_tokens": usage.get("prompt_tokens", 0),
"completion_tokens": usage.get("completion_tokens", 0),
"total_tokens": usage.get("total_tokens", 0),
}
)
metadata.update(_retry_metadata(process_data))
title = _read_attribute(item, "title")
name = f"{node_type}_{title}" if isinstance(title, str) and title else node_type
spans[item_execution_id] = CanonicalSpan(
id=item_execution_id,
parent_id=hierarchy.parent_by_execution_id.get(item_execution_id, workflow_span_id),
name=name,
kind=_NODE_KIND.get(node_type, CanonicalSpanKind.CHAIN),
start_time=started_at,
end_time=started_at + timedelta(seconds=elapsed_time),
inputs=process_data.get("prompts", []) if node_type == "llm" else _read_attribute(item, "inputs") or {},
outputs=outputs,
status=_status(error, _read_attribute(item, "status")),
error=error,
metadata=metadata,
can_parent_workflow=node_type == "tool",
)
ordered: list[CanonicalSpan] = []
emitted: set[str] = set()
def emit(span_id: str) -> None:
if span_id in emitted:
return
span = spans[span_id]
if span.parent_id in spans:
emit(span.parent_id)
emitted.add(span_id)
ordered.append(span)
for span_id in sorted(spans):
emit(span_id)
return CanonicalTrace(
trace_id=trace_info.resolved_trace_id or root_span_id,
session_id=resolve_session_id(trace_info),
root_span_id=root_span_id,
spans=tuple(ordered),
external_parent=_external_parent(trace_info),
)
def _single_trace(
self,
trace_info: BaseTraceInfo,
*,
name: str,
kind: CanonicalSpanKind,
inputs: Any,
outputs: Any,
error: str | None = None,
start_time: datetime | None = None,
end_time: datetime | None = None,
parent_context_id: str | None = None,
span_id: str | None = None,
session_id: str | None = None,
) -> CanonicalTrace:
operation_id = span_id or trace_info.operation_id or str(uuid4())
trace_id = trace_info.resolved_trace_id or parent_context_id or operation_id
span = CanonicalSpan(
id=operation_id,
parent_id=None,
name=name,
kind=kind,
start_time=_started_at(start_time or trace_info.start_time),
end_time=end_time or trace_info.end_time,
inputs=inputs,
outputs=outputs,
status=_status(error),
error=error,
metadata=dict(trace_info.metadata),
)
return CanonicalTrace(
trace_id=trace_id,
session_id=session_id if session_id is not None else _single_session_id(trace_info),
root_span_id=operation_id,
spans=(span,),
required_parent_context_id=parent_context_id,
)
def _build_message(self, trace_info: MessageTraceInfo) -> CanonicalTrace | None:
message = trace_info.message_data
if message is None:
return None
message_id = trace_info.message_id or str(_read_attribute(message, "id", "")) or str(uuid4())
started_at = _started_at(trace_info.start_time or _read_attribute(message, "created_at"))
ended_at = trace_info.end_time or _read_attribute(message, "updated_at")
answer = _read_attribute(message, "answer", trace_info.outputs)
message_error = trace_info.error or _read_attribute(message, "error")
metadata = {
**trace_info.metadata,
"trace_entity_type": "message",
"model_provider": _read_attribute(message, "model_provider"),
"model_name": _read_attribute(message, "model_id"),
"prompt_tokens": trace_info.message_tokens,
"completion_tokens": trace_info.answer_tokens,
"total_tokens": trace_info.total_tokens,
}
spans: list[CanonicalSpan] = [
CanonicalSpan(
id=message_id,
parent_id=None,
name="message",
kind=CanonicalSpanKind.CHAIN,
start_time=started_at,
end_time=ended_at,
inputs=trace_info.inputs,
outputs=answer,
status=_status(message_error),
error=message_error,
metadata=metadata,
publishes_parent_context=True,
),
CanonicalSpan(
id=f"{message_id}:llm",
parent_id=message_id,
name="llm",
kind=CanonicalSpanKind.LLM,
start_time=started_at,
end_time=ended_at,
inputs=trace_info.inputs,
outputs=trace_info.outputs if trace_info.outputs is not None else answer,
status=_status(message_error),
error=message_error,
metadata=metadata,
synthetic=True,
),
]
return CanonicalTrace(
trace_id=trace_info.resolved_trace_id or message_id,
session_id=resolve_session_id(trace_info),
root_span_id=message_id,
spans=tuple(spans),
)
def _build_moderation(self, trace_info: ModerationTraceInfo) -> CanonicalTrace | None:
if trace_info.message_data is None:
return None
return self._single_trace(
trace_info,
name="moderation",
kind=CanonicalSpanKind.TOOL,
inputs=trace_info.inputs,
outputs={"action": trace_info.action, "flagged": trace_info.flagged},
parent_context_id=trace_info.message_id,
)
def _build_suggested_question(self, trace_info: SuggestedQuestionTraceInfo) -> CanonicalTrace | None:
if trace_info.message_data is None:
return None
return self._single_trace(
trace_info,
name="suggested_question",
kind=CanonicalSpanKind.TOOL,
inputs=trace_info.inputs,
outputs=trace_info.suggested_question,
error=trace_info.error,
parent_context_id=trace_info.message_id,
)
def _build_dataset_retrieval(self, trace_info: DatasetRetrievalTraceInfo) -> CanonicalTrace | None:
if trace_info.message_data is None:
return None
return self._single_trace(
trace_info,
name="dataset_retrieval",
kind=CanonicalSpanKind.RETRIEVER,
inputs=trace_info.inputs,
outputs={"documents": trace_info.documents},
error=trace_info.error,
parent_context_id=trace_info.message_id,
)
def _build_tool(self, trace_info: ToolTraceInfo) -> CanonicalTrace:
return self._single_trace(
trace_info,
name=trace_info.tool_name,
kind=CanonicalSpanKind.TOOL,
inputs=trace_info.tool_inputs,
outputs=trace_info.tool_outputs,
error=trace_info.error,
parent_context_id=trace_info.message_id,
)
def _build_generate_name(self, trace_info: GenerateNameTraceInfo) -> CanonicalTrace:
return self._single_trace(
trace_info,
name="generate_name",
kind=CanonicalSpanKind.TOOL,
inputs=trace_info.inputs,
outputs=trace_info.outputs,
parent_context_id=trace_info.message_id,
session_id=_single_session_id(trace_info) or trace_info.conversation_id or "",
)