respan-instrumentation-dify 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- respan_instrumentation_dify-0.1.0/PKG-INFO +65 -0
- respan_instrumentation_dify-0.1.0/README.md +45 -0
- respan_instrumentation_dify-0.1.0/pyproject.toml +29 -0
- respan_instrumentation_dify-0.1.0/src/respan_instrumentation_dify/__init__.py +7 -0
- respan_instrumentation_dify-0.1.0/src/respan_instrumentation_dify/_constants.py +68 -0
- respan_instrumentation_dify-0.1.0/src/respan_instrumentation_dify/_context.py +51 -0
- respan_instrumentation_dify-0.1.0/src/respan_instrumentation_dify/_instrumentation.py +300 -0
- respan_instrumentation_dify-0.1.0/src/respan_instrumentation_dify/_otel_emitter.py +124 -0
- respan_instrumentation_dify-0.1.0/src/respan_instrumentation_dify/_translator.py +514 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: respan-instrumentation-dify
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Respan instrumentation plugin for Dify
|
|
5
|
+
License: Apache 2.0
|
|
6
|
+
Author: Respan
|
|
7
|
+
Author-email: team@respan.ai
|
|
8
|
+
Requires-Python: >=3.11,<3.14
|
|
9
|
+
Classifier: License :: Other/Proprietary License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Requires-Dist: dify-client (>=0.1.10)
|
|
15
|
+
Requires-Dist: opentelemetry-semantic-conventions-ai (>=0.4.1)
|
|
16
|
+
Requires-Dist: respan-sdk (>=2.6.1)
|
|
17
|
+
Requires-Dist: respan-tracing (>=2.17.0,<3.0.0)
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# Respan Dify instrumentation
|
|
21
|
+
|
|
22
|
+
Trace the `dify-client` Python package with Respan.
|
|
23
|
+
|
|
24
|
+
This package patches Dify's Service API client methods and emits canonical
|
|
25
|
+
Respan spans for chat, completion, workflow, file, feedback, application, and
|
|
26
|
+
conversation requests. Streaming responses are traced when the returned stream
|
|
27
|
+
is consumed.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install respan-ai respan-instrumentation-dify dify-client
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
import os
|
|
39
|
+
|
|
40
|
+
from dify_client import ChatClient
|
|
41
|
+
from respan import Respan
|
|
42
|
+
from respan_instrumentation_dify import DifyInstrumentor
|
|
43
|
+
|
|
44
|
+
respan = Respan(
|
|
45
|
+
api_key=os.environ["RESPAN_API_KEY"],
|
|
46
|
+
instrumentations=[DifyInstrumentor()],
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
client = ChatClient(os.environ["DIFY_CHAT_API_KEY"])
|
|
50
|
+
response = client.create_chat_message(
|
|
51
|
+
inputs={},
|
|
52
|
+
query="Reply with one concise sentence about tracing.",
|
|
53
|
+
user="respan-example-user",
|
|
54
|
+
response_mode="blocking",
|
|
55
|
+
)
|
|
56
|
+
response.raise_for_status()
|
|
57
|
+
print(response.json()["answer"])
|
|
58
|
+
|
|
59
|
+
respan.flush()
|
|
60
|
+
respan.shutdown()
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Use `Respan.propagate_attributes(...)` to attach customer, thread, trace group,
|
|
64
|
+
metadata, or prompt context to Dify spans.
|
|
65
|
+
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Respan Dify instrumentation
|
|
2
|
+
|
|
3
|
+
Trace the `dify-client` Python package with Respan.
|
|
4
|
+
|
|
5
|
+
This package patches Dify's Service API client methods and emits canonical
|
|
6
|
+
Respan spans for chat, completion, workflow, file, feedback, application, and
|
|
7
|
+
conversation requests. Streaming responses are traced when the returned stream
|
|
8
|
+
is consumed.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install respan-ai respan-instrumentation-dify dify-client
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Usage
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
import os
|
|
20
|
+
|
|
21
|
+
from dify_client import ChatClient
|
|
22
|
+
from respan import Respan
|
|
23
|
+
from respan_instrumentation_dify import DifyInstrumentor
|
|
24
|
+
|
|
25
|
+
respan = Respan(
|
|
26
|
+
api_key=os.environ["RESPAN_API_KEY"],
|
|
27
|
+
instrumentations=[DifyInstrumentor()],
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
client = ChatClient(os.environ["DIFY_CHAT_API_KEY"])
|
|
31
|
+
response = client.create_chat_message(
|
|
32
|
+
inputs={},
|
|
33
|
+
query="Reply with one concise sentence about tracing.",
|
|
34
|
+
user="respan-example-user",
|
|
35
|
+
response_mode="blocking",
|
|
36
|
+
)
|
|
37
|
+
response.raise_for_status()
|
|
38
|
+
print(response.json()["answer"])
|
|
39
|
+
|
|
40
|
+
respan.flush()
|
|
41
|
+
respan.shutdown()
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Use `Respan.propagate_attributes(...)` to attach customer, thread, trace group,
|
|
45
|
+
metadata, or prompt context to Dify spans.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "respan-instrumentation-dify"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Respan instrumentation plugin for Dify"
|
|
5
|
+
authors = ["Respan <team@respan.ai>"]
|
|
6
|
+
license = "Apache 2.0"
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
packages = [
|
|
9
|
+
{ include = "respan_instrumentation_dify", from = "./src" },
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[tool.poetry.dependencies]
|
|
13
|
+
python = ">=3.11,<3.14"
|
|
14
|
+
respan-tracing = "^2.17.0"
|
|
15
|
+
respan-sdk = ">=2.6.1"
|
|
16
|
+
dify-client = ">=0.1.10"
|
|
17
|
+
opentelemetry-semantic-conventions-ai = ">=0.4.1"
|
|
18
|
+
|
|
19
|
+
[tool.poetry.plugins."respan.instrumentations"]
|
|
20
|
+
dify = "respan_instrumentation_dify:DifyInstrumentor"
|
|
21
|
+
|
|
22
|
+
[tool.pytest.ini_options]
|
|
23
|
+
markers = [
|
|
24
|
+
"integration: live network tests (requires API keys)",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[build-system]
|
|
28
|
+
requires = ["poetry-core"]
|
|
29
|
+
build-backend = "poetry.core.masonry.api"
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Dify instrumentation constants."""
|
|
2
|
+
|
|
3
|
+
from respan_sdk.constants.span_attributes import (
|
|
4
|
+
RESPAN_SPAN_HANDOFFS,
|
|
5
|
+
RESPAN_SPAN_TOOL_CALLS,
|
|
6
|
+
RESPAN_SPAN_TOOLS,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
DIFY_INSTRUMENTATION_NAME = "dify"
|
|
10
|
+
DIFY_CLIENT_MODULE = "dify_client.client"
|
|
11
|
+
DIFY_CHAT_SPAN_NAME = "dify.chat"
|
|
12
|
+
DIFY_COMPLETION_SPAN_NAME = "dify.completion"
|
|
13
|
+
DIFY_WORKFLOW_SPAN_NAME = "dify.workflow"
|
|
14
|
+
DIFY_API_SPAN_NAME = "dify.request"
|
|
15
|
+
|
|
16
|
+
ANSWER_KEY = "answer"
|
|
17
|
+
CONVERSATION_ID_KEY = "conversation_id"
|
|
18
|
+
CREATED_AT_KEY = "created_at"
|
|
19
|
+
DATA_KEY = "data"
|
|
20
|
+
ENDPOINT_KEY = "endpoint"
|
|
21
|
+
EVENT_KEY = "event"
|
|
22
|
+
FILES_KEY = "files"
|
|
23
|
+
ID_KEY = "id"
|
|
24
|
+
INPUTS_KEY = "inputs"
|
|
25
|
+
LATENCY_KEY = "latency"
|
|
26
|
+
MESSAGE_ID_KEY = "message_id"
|
|
27
|
+
METADATA_KEY = "metadata"
|
|
28
|
+
METHOD_KEY = "method"
|
|
29
|
+
MODE_KEY = "mode"
|
|
30
|
+
PARAMS_KEY = "params"
|
|
31
|
+
QUERY_KEY = "query"
|
|
32
|
+
RESPONSE_MODE_KEY = "response_mode"
|
|
33
|
+
STATUS_KEY = "status"
|
|
34
|
+
TASK_ID_KEY = "task_id"
|
|
35
|
+
TOTAL_TOKENS_KEY = "total_tokens"
|
|
36
|
+
USAGE_KEY = "usage"
|
|
37
|
+
USER_KEY = "user"
|
|
38
|
+
WORKFLOW_RUN_ID_KEY = "workflow_run_id"
|
|
39
|
+
|
|
40
|
+
PROMPT_TOKENS_KEY = "prompt_tokens"
|
|
41
|
+
COMPLETION_TOKENS_KEY = "completion_tokens"
|
|
42
|
+
OUTPUTS_KEY = "outputs"
|
|
43
|
+
ERROR_KEY = "error"
|
|
44
|
+
|
|
45
|
+
CHAT_MESSAGES_ENDPOINT = "/chat-messages"
|
|
46
|
+
COMPLETION_MESSAGES_ENDPOINT = "/completion-messages"
|
|
47
|
+
WORKFLOW_RUN_ENDPOINT = "/workflows/run"
|
|
48
|
+
FILES_UPLOAD_ENDPOINT = "/files/upload"
|
|
49
|
+
PARAMETERS_ENDPOINT = "/parameters"
|
|
50
|
+
MESSAGES_ENDPOINT = "/messages"
|
|
51
|
+
CONVERSATIONS_ENDPOINT = "/conversations"
|
|
52
|
+
|
|
53
|
+
STREAMING_RESPONSE_MODE = "streaming"
|
|
54
|
+
|
|
55
|
+
OFF_CONTRACT_ALIASES = {
|
|
56
|
+
"completion_tokens",
|
|
57
|
+
"has_tool_calls",
|
|
58
|
+
"model",
|
|
59
|
+
"parallel_tool_calls",
|
|
60
|
+
"prompt_tokens",
|
|
61
|
+
RESPAN_SPAN_HANDOFFS,
|
|
62
|
+
RESPAN_SPAN_TOOL_CALLS,
|
|
63
|
+
RESPAN_SPAN_TOOLS,
|
|
64
|
+
"span_tools",
|
|
65
|
+
"tool_calls",
|
|
66
|
+
"tools",
|
|
67
|
+
"total_request_tokens",
|
|
68
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Package-local context for Dify-specific call options."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextvars
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
_RESPAN_PARAMS: contextvars.ContextVar[dict[str, Any]] = contextvars.ContextVar(
|
|
12
|
+
"respan_dify_params",
|
|
13
|
+
default={},
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _to_mapping(value: Any) -> Mapping[str, Any] | None:
|
|
18
|
+
if value is None:
|
|
19
|
+
return None
|
|
20
|
+
if isinstance(value, Mapping):
|
|
21
|
+
return value
|
|
22
|
+
for method_name in ("model_dump", "dict"):
|
|
23
|
+
method = getattr(value, method_name, None)
|
|
24
|
+
if callable(method):
|
|
25
|
+
try:
|
|
26
|
+
converted = method()
|
|
27
|
+
except Exception:
|
|
28
|
+
continue
|
|
29
|
+
if isinstance(converted, Mapping):
|
|
30
|
+
return converted
|
|
31
|
+
value_dict = getattr(value, "__dict__", None)
|
|
32
|
+
if isinstance(value_dict, Mapping):
|
|
33
|
+
return value_dict
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@contextmanager
|
|
38
|
+
def use_respan_params(value: Any):
|
|
39
|
+
"""Temporarily attach optional Respan params to the current Dify call."""
|
|
40
|
+
mapping = _to_mapping(value) or {}
|
|
41
|
+
parent = _RESPAN_PARAMS.get()
|
|
42
|
+
merged = {**parent, **dict(mapping)}
|
|
43
|
+
token = _RESPAN_PARAMS.set(merged)
|
|
44
|
+
try:
|
|
45
|
+
yield
|
|
46
|
+
finally:
|
|
47
|
+
_RESPAN_PARAMS.reset(token)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def read_respan_params() -> dict[str, Any]:
|
|
51
|
+
return dict(_RESPAN_PARAMS.get())
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""Dify SDK instrumentation plugin for Respan."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from respan_instrumentation_dify._constants import (
|
|
11
|
+
DIFY_CLIENT_MODULE,
|
|
12
|
+
DIFY_INSTRUMENTATION_NAME,
|
|
13
|
+
)
|
|
14
|
+
from respan_instrumentation_dify._context import use_respan_params
|
|
15
|
+
from respan_instrumentation_dify._otel_emitter import capture_call_context
|
|
16
|
+
from respan_instrumentation_dify._otel_emitter import emit_dify_span
|
|
17
|
+
from respan_tracing.core.tracer import RespanTracer
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
_PATCHED_METHODS: dict[tuple[type[Any], str], Any] = {}
|
|
22
|
+
_IS_PATCHED = False
|
|
23
|
+
_INCLUDE_CONTENT = True
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _arg(args: tuple[Any, ...], index: int, kwargs: dict[str, Any], name: str, default: Any = None) -> Any:
|
|
27
|
+
if len(args) > index:
|
|
28
|
+
return args[index]
|
|
29
|
+
return kwargs.get(name, default)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _pop_respan_params(kwargs: dict[str, Any]) -> Any:
|
|
33
|
+
return kwargs.pop("respan_params", None)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _parse_sse_event(line: Any) -> Any:
|
|
37
|
+
if isinstance(line, bytes):
|
|
38
|
+
text = line.decode("utf-8", errors="replace")
|
|
39
|
+
else:
|
|
40
|
+
text = str(line)
|
|
41
|
+
|
|
42
|
+
text = text.strip()
|
|
43
|
+
if not text:
|
|
44
|
+
return None
|
|
45
|
+
if text.startswith("data:"):
|
|
46
|
+
text = text.split("data:", maxsplit=1)[1].strip()
|
|
47
|
+
if text == "[DONE]":
|
|
48
|
+
return None
|
|
49
|
+
try:
|
|
50
|
+
return json.loads(text)
|
|
51
|
+
except Exception:
|
|
52
|
+
return text
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class _InstrumentedStreamingResponse:
|
|
56
|
+
def __init__(self, *, response: Any, call_context: Any) -> None:
|
|
57
|
+
self._response = response
|
|
58
|
+
self._call_context = call_context
|
|
59
|
+
self._events: list[Any] = []
|
|
60
|
+
self._emitted = False
|
|
61
|
+
|
|
62
|
+
def __getattr__(self, name: str) -> Any:
|
|
63
|
+
return getattr(self._response, name)
|
|
64
|
+
|
|
65
|
+
def __enter__(self) -> "_InstrumentedStreamingResponse":
|
|
66
|
+
enter = getattr(self._response, "__enter__", None)
|
|
67
|
+
if callable(enter):
|
|
68
|
+
enter()
|
|
69
|
+
return self
|
|
70
|
+
|
|
71
|
+
def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> Any:
|
|
72
|
+
if exc is not None:
|
|
73
|
+
self._emit_once(error=exc if isinstance(exc, Exception) else Exception(str(exc)))
|
|
74
|
+
else:
|
|
75
|
+
self._emit_once(error=None)
|
|
76
|
+
exit_method = getattr(self._response, "__exit__", None)
|
|
77
|
+
if callable(exit_method):
|
|
78
|
+
return exit_method(exc_type, exc, tb)
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
def close(self) -> Any:
|
|
82
|
+
self._emit_once(error=None)
|
|
83
|
+
return self._response.close()
|
|
84
|
+
|
|
85
|
+
def iter_lines(self, *args: Any, **kwargs: Any) -> Any:
|
|
86
|
+
try:
|
|
87
|
+
for line in self._response.iter_lines(*args, **kwargs):
|
|
88
|
+
event = _parse_sse_event(line)
|
|
89
|
+
if event is not None:
|
|
90
|
+
self._events.append(event)
|
|
91
|
+
yield line
|
|
92
|
+
except Exception as exc:
|
|
93
|
+
self._emit_once(error=exc)
|
|
94
|
+
raise
|
|
95
|
+
else:
|
|
96
|
+
self._emit_once(error=None)
|
|
97
|
+
|
|
98
|
+
def iter_content(self, *args: Any, **kwargs: Any) -> Any:
|
|
99
|
+
chunks: list[Any] = []
|
|
100
|
+
try:
|
|
101
|
+
for chunk in self._response.iter_content(*args, **kwargs):
|
|
102
|
+
chunks.append(chunk)
|
|
103
|
+
yield chunk
|
|
104
|
+
except Exception as exc:
|
|
105
|
+
self._emit_once(error=exc)
|
|
106
|
+
raise
|
|
107
|
+
else:
|
|
108
|
+
if not self._events:
|
|
109
|
+
self._events.extend(_parse_sse_event(chunk) for chunk in chunks)
|
|
110
|
+
self._events = [event for event in self._events if event is not None]
|
|
111
|
+
self._emit_once(error=None)
|
|
112
|
+
|
|
113
|
+
def _emit_once(self, *, error: Exception | None) -> None:
|
|
114
|
+
if self._emitted:
|
|
115
|
+
return
|
|
116
|
+
self._emitted = True
|
|
117
|
+
emit_dify_span(
|
|
118
|
+
call_context=self._call_context,
|
|
119
|
+
response=self._response,
|
|
120
|
+
stream_events=self._events,
|
|
121
|
+
error=error,
|
|
122
|
+
include_content=_INCLUDE_CONTENT,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _wrap_send_request(original: Any) -> Any:
|
|
127
|
+
def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
|
|
128
|
+
respan_params = _pop_respan_params(kwargs=kwargs)
|
|
129
|
+
with use_respan_params(respan_params):
|
|
130
|
+
method = _arg(args, 0, kwargs, "method", "")
|
|
131
|
+
endpoint = _arg(args, 1, kwargs, "endpoint", "")
|
|
132
|
+
request_json = _arg(args, 2, kwargs, "json")
|
|
133
|
+
request_params = _arg(args, 3, kwargs, "params")
|
|
134
|
+
stream = bool(_arg(args, 4, kwargs, "stream", False))
|
|
135
|
+
call_context = capture_call_context(
|
|
136
|
+
method=str(method),
|
|
137
|
+
endpoint=str(endpoint),
|
|
138
|
+
request_json=request_json,
|
|
139
|
+
request_params=request_params,
|
|
140
|
+
stream=stream,
|
|
141
|
+
)
|
|
142
|
+
try:
|
|
143
|
+
response = original(self, *args, **kwargs)
|
|
144
|
+
except Exception as exc:
|
|
145
|
+
emit_dify_span(
|
|
146
|
+
call_context=call_context,
|
|
147
|
+
error=exc,
|
|
148
|
+
include_content=_INCLUDE_CONTENT,
|
|
149
|
+
)
|
|
150
|
+
raise
|
|
151
|
+
|
|
152
|
+
if stream:
|
|
153
|
+
return _InstrumentedStreamingResponse(
|
|
154
|
+
response=response,
|
|
155
|
+
call_context=call_context,
|
|
156
|
+
)
|
|
157
|
+
emit_dify_span(
|
|
158
|
+
call_context=call_context,
|
|
159
|
+
response=response,
|
|
160
|
+
include_content=_INCLUDE_CONTENT,
|
|
161
|
+
)
|
|
162
|
+
return response
|
|
163
|
+
|
|
164
|
+
return wrapper
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _wrap_send_request_with_files(original: Any) -> Any:
|
|
168
|
+
def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
|
|
169
|
+
respan_params = _pop_respan_params(kwargs=kwargs)
|
|
170
|
+
with use_respan_params(respan_params):
|
|
171
|
+
method = _arg(args, 0, kwargs, "method", "")
|
|
172
|
+
endpoint = _arg(args, 1, kwargs, "endpoint", "")
|
|
173
|
+
request_data = _arg(args, 2, kwargs, "data")
|
|
174
|
+
files = _arg(args, 3, kwargs, "files")
|
|
175
|
+
call_context = capture_call_context(
|
|
176
|
+
method=str(method),
|
|
177
|
+
endpoint=str(endpoint),
|
|
178
|
+
request_data=request_data,
|
|
179
|
+
files=files,
|
|
180
|
+
)
|
|
181
|
+
try:
|
|
182
|
+
response = original(self, *args, **kwargs)
|
|
183
|
+
except Exception as exc:
|
|
184
|
+
emit_dify_span(
|
|
185
|
+
call_context=call_context,
|
|
186
|
+
error=exc,
|
|
187
|
+
include_content=_INCLUDE_CONTENT,
|
|
188
|
+
)
|
|
189
|
+
raise
|
|
190
|
+
emit_dify_span(
|
|
191
|
+
call_context=call_context,
|
|
192
|
+
response=response,
|
|
193
|
+
include_content=_INCLUDE_CONTENT,
|
|
194
|
+
)
|
|
195
|
+
return response
|
|
196
|
+
|
|
197
|
+
return wrapper
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _wrap_high_level_method(original: Any) -> Any:
|
|
201
|
+
def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
|
|
202
|
+
respan_params = _pop_respan_params(kwargs=kwargs)
|
|
203
|
+
with use_respan_params(respan_params):
|
|
204
|
+
return original(self, *args, **kwargs)
|
|
205
|
+
|
|
206
|
+
return wrapper
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _patch_method(cls: type[Any], method_name: str, wrapper_factory: Any) -> None:
|
|
210
|
+
key = (cls, method_name)
|
|
211
|
+
if key in _PATCHED_METHODS:
|
|
212
|
+
return
|
|
213
|
+
original = getattr(cls, method_name, None)
|
|
214
|
+
if original is None:
|
|
215
|
+
return
|
|
216
|
+
_PATCHED_METHODS[key] = original
|
|
217
|
+
setattr(cls, method_name, wrapper_factory(original))
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class DifyInstrumentor:
|
|
221
|
+
"""Respan instrumentor for the Dify Python Service API client."""
|
|
222
|
+
|
|
223
|
+
name = DIFY_INSTRUMENTATION_NAME
|
|
224
|
+
|
|
225
|
+
def __init__(self, *, include_content: bool = True) -> None:
|
|
226
|
+
self._include_content = include_content
|
|
227
|
+
self._is_instrumented = False
|
|
228
|
+
|
|
229
|
+
@staticmethod
|
|
230
|
+
def _is_respan_tracing_enabled() -> bool:
|
|
231
|
+
tracer = getattr(RespanTracer, "_instance", None)
|
|
232
|
+
if tracer is None:
|
|
233
|
+
return True
|
|
234
|
+
return bool(getattr(tracer, "is_enabled", True))
|
|
235
|
+
|
|
236
|
+
def activate(self) -> None:
|
|
237
|
+
"""Monkey-patch the Dify client."""
|
|
238
|
+
global _IS_PATCHED, _INCLUDE_CONTENT
|
|
239
|
+
if self._is_instrumented:
|
|
240
|
+
return
|
|
241
|
+
if not self._is_respan_tracing_enabled():
|
|
242
|
+
logger.info("Dify instrumentation skipped because Respan tracing is disabled")
|
|
243
|
+
return
|
|
244
|
+
|
|
245
|
+
try:
|
|
246
|
+
module = importlib.import_module(DIFY_CLIENT_MODULE)
|
|
247
|
+
dify_client_cls = getattr(module, "DifyClient")
|
|
248
|
+
chat_client_cls = getattr(module, "ChatClient")
|
|
249
|
+
completion_client_cls = getattr(module, "CompletionClient")
|
|
250
|
+
except (AttributeError, ImportError) as exc:
|
|
251
|
+
logger.warning(
|
|
252
|
+
"Failed to activate Dify instrumentation - missing dependency: %s",
|
|
253
|
+
exc,
|
|
254
|
+
)
|
|
255
|
+
return
|
|
256
|
+
|
|
257
|
+
_INCLUDE_CONTENT = self._include_content
|
|
258
|
+
|
|
259
|
+
if not _IS_PATCHED:
|
|
260
|
+
_patch_method(dify_client_cls, "_send_request", _wrap_send_request)
|
|
261
|
+
_patch_method(
|
|
262
|
+
dify_client_cls,
|
|
263
|
+
"_send_request_with_files",
|
|
264
|
+
_wrap_send_request_with_files,
|
|
265
|
+
)
|
|
266
|
+
for method_name in (
|
|
267
|
+
"message_feedback",
|
|
268
|
+
"get_application_parameters",
|
|
269
|
+
"file_upload",
|
|
270
|
+
):
|
|
271
|
+
_patch_method(dify_client_cls, method_name, _wrap_high_level_method)
|
|
272
|
+
for method_name in (
|
|
273
|
+
"create_chat_message",
|
|
274
|
+
"get_conversation_messages",
|
|
275
|
+
"get_conversations",
|
|
276
|
+
"rename_conversation",
|
|
277
|
+
):
|
|
278
|
+
_patch_method(chat_client_cls, method_name, _wrap_high_level_method)
|
|
279
|
+
_patch_method(
|
|
280
|
+
completion_client_cls,
|
|
281
|
+
"create_completion_message",
|
|
282
|
+
_wrap_high_level_method,
|
|
283
|
+
)
|
|
284
|
+
_IS_PATCHED = True
|
|
285
|
+
|
|
286
|
+
self._is_instrumented = True
|
|
287
|
+
logger.info("Dify instrumentation activated")
|
|
288
|
+
|
|
289
|
+
def deactivate(self) -> None:
|
|
290
|
+
"""Restore patched Dify client methods."""
|
|
291
|
+
global _IS_PATCHED
|
|
292
|
+
for (cls, method_name), original in list(_PATCHED_METHODS.items()):
|
|
293
|
+
setattr(cls, method_name, original)
|
|
294
|
+
_PATCHED_METHODS.clear()
|
|
295
|
+
_IS_PATCHED = False
|
|
296
|
+
self._is_instrumented = False
|
|
297
|
+
logger.info("Dify instrumentation deactivated")
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
DifyAIInstrumentor = DifyInstrumentor
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Build and inject Dify spans into the active Respan OTEL pipeline."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from opentelemetry import context as context_api
|
|
10
|
+
from opentelemetry import trace
|
|
11
|
+
from opentelemetry.semconv_ai import SpanAttributes
|
|
12
|
+
|
|
13
|
+
from respan_instrumentation_dify._context import read_respan_params
|
|
14
|
+
from respan_instrumentation_dify._translator import build_dify_span_data
|
|
15
|
+
from respan_sdk.utils.data_processing.id_processing import (
|
|
16
|
+
format_span_id,
|
|
17
|
+
format_trace_id,
|
|
18
|
+
)
|
|
19
|
+
from respan_tracing.utils.span_factory import build_readable_span
|
|
20
|
+
from respan_tracing.utils.span_factory import inject_span
|
|
21
|
+
from respan_tracing.utils.span_factory import read_propagated_attributes
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class DifyCallContext:
|
|
26
|
+
method: str
|
|
27
|
+
endpoint: str
|
|
28
|
+
request_json: Any = None
|
|
29
|
+
request_params: Any = None
|
|
30
|
+
request_data: Any = None
|
|
31
|
+
files: Any = None
|
|
32
|
+
stream: bool = False
|
|
33
|
+
start_time_ns: int = field(default_factory=time.time_ns)
|
|
34
|
+
trace_id: str | None = None
|
|
35
|
+
parent_id: str | None = None
|
|
36
|
+
workflow_name: str | None = None
|
|
37
|
+
propagated_attributes: dict[str, Any] = field(default_factory=dict)
|
|
38
|
+
respan_params: dict[str, Any] = field(default_factory=dict)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _current_otel_parent() -> tuple[str | None, str | None]:
|
|
42
|
+
current_span = trace.get_current_span()
|
|
43
|
+
try:
|
|
44
|
+
span_context = current_span.get_span_context()
|
|
45
|
+
except Exception:
|
|
46
|
+
return None, None
|
|
47
|
+
|
|
48
|
+
trace_id = getattr(span_context, "trace_id", 0)
|
|
49
|
+
span_id = getattr(span_context, "span_id", 0)
|
|
50
|
+
if not isinstance(trace_id, int) or not isinstance(span_id, int):
|
|
51
|
+
return None, None
|
|
52
|
+
if trace_id == 0 or span_id == 0:
|
|
53
|
+
return None, None
|
|
54
|
+
return format_trace_id(trace_id=trace_id), format_span_id(span_id=span_id)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def capture_call_context(
|
|
58
|
+
*,
|
|
59
|
+
method: str,
|
|
60
|
+
endpoint: str,
|
|
61
|
+
request_json: Any = None,
|
|
62
|
+
request_params: Any = None,
|
|
63
|
+
request_data: Any = None,
|
|
64
|
+
files: Any = None,
|
|
65
|
+
stream: bool = False,
|
|
66
|
+
) -> DifyCallContext:
|
|
67
|
+
trace_id, parent_id = _current_otel_parent()
|
|
68
|
+
workflow_name = context_api.get_value(SpanAttributes.TRACELOOP_ENTITY_NAME)
|
|
69
|
+
return DifyCallContext(
|
|
70
|
+
method=method,
|
|
71
|
+
endpoint=endpoint,
|
|
72
|
+
request_json=request_json,
|
|
73
|
+
request_params=request_params,
|
|
74
|
+
request_data=request_data,
|
|
75
|
+
files=files,
|
|
76
|
+
stream=stream,
|
|
77
|
+
trace_id=trace_id,
|
|
78
|
+
parent_id=parent_id,
|
|
79
|
+
workflow_name=str(workflow_name) if workflow_name else None,
|
|
80
|
+
propagated_attributes=read_propagated_attributes(),
|
|
81
|
+
respan_params=read_respan_params(),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def emit_dify_span(
|
|
86
|
+
*,
|
|
87
|
+
call_context: DifyCallContext,
|
|
88
|
+
response: Any = None,
|
|
89
|
+
stream_events: list[Any] | None = None,
|
|
90
|
+
error: Exception | None = None,
|
|
91
|
+
include_content: bool = True,
|
|
92
|
+
) -> None:
|
|
93
|
+
span_name, attributes = build_dify_span_data(
|
|
94
|
+
method=call_context.method,
|
|
95
|
+
endpoint=call_context.endpoint,
|
|
96
|
+
request_json=call_context.request_json,
|
|
97
|
+
request_params=call_context.request_params,
|
|
98
|
+
request_data=call_context.request_data,
|
|
99
|
+
files=call_context.files,
|
|
100
|
+
response=response,
|
|
101
|
+
stream_events=stream_events,
|
|
102
|
+
error=error,
|
|
103
|
+
include_content=include_content,
|
|
104
|
+
respan_params=call_context.respan_params,
|
|
105
|
+
propagated_attributes=call_context.propagated_attributes,
|
|
106
|
+
current_workflow_name=call_context.workflow_name,
|
|
107
|
+
)
|
|
108
|
+
status_code = getattr(response, "status_code", 200)
|
|
109
|
+
if not isinstance(status_code, int):
|
|
110
|
+
status_code = 200
|
|
111
|
+
if error is not None:
|
|
112
|
+
status_code = 500
|
|
113
|
+
span = build_readable_span(
|
|
114
|
+
name=span_name,
|
|
115
|
+
trace_id=call_context.trace_id,
|
|
116
|
+
parent_id=call_context.parent_id,
|
|
117
|
+
start_time_ns=call_context.start_time_ns,
|
|
118
|
+
end_time_ns=time.time_ns(),
|
|
119
|
+
attributes=attributes,
|
|
120
|
+
status_code=status_code,
|
|
121
|
+
error_message=str(error) if error is not None else None,
|
|
122
|
+
merge_propagated=False,
|
|
123
|
+
)
|
|
124
|
+
inject_span(span=span)
|
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
"""Translate Dify client requests and responses into Respan span attributes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Mapping, Sequence
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from opentelemetry.semconv._incubating.attributes import (
|
|
10
|
+
gen_ai_attributes as GenAIAttributes,
|
|
11
|
+
)
|
|
12
|
+
from opentelemetry.semconv_ai import LLMRequestTypeValues
|
|
13
|
+
from opentelemetry.semconv_ai import SpanAttributes
|
|
14
|
+
|
|
15
|
+
from respan_instrumentation_dify._constants import (
|
|
16
|
+
ANSWER_KEY,
|
|
17
|
+
CHAT_MESSAGES_ENDPOINT,
|
|
18
|
+
COMPLETION_MESSAGES_ENDPOINT,
|
|
19
|
+
COMPLETION_TOKENS_KEY,
|
|
20
|
+
CONVERSATION_ID_KEY,
|
|
21
|
+
DATA_KEY,
|
|
22
|
+
DIFY_API_SPAN_NAME,
|
|
23
|
+
DIFY_CHAT_SPAN_NAME,
|
|
24
|
+
DIFY_COMPLETION_SPAN_NAME,
|
|
25
|
+
DIFY_WORKFLOW_SPAN_NAME,
|
|
26
|
+
ENDPOINT_KEY,
|
|
27
|
+
ERROR_KEY,
|
|
28
|
+
EVENT_KEY,
|
|
29
|
+
FILES_KEY,
|
|
30
|
+
ID_KEY,
|
|
31
|
+
INPUTS_KEY,
|
|
32
|
+
LATENCY_KEY,
|
|
33
|
+
MESSAGE_ID_KEY,
|
|
34
|
+
METADATA_KEY,
|
|
35
|
+
METHOD_KEY,
|
|
36
|
+
MODE_KEY,
|
|
37
|
+
OFF_CONTRACT_ALIASES,
|
|
38
|
+
OUTPUTS_KEY,
|
|
39
|
+
PARAMS_KEY,
|
|
40
|
+
PROMPT_TOKENS_KEY,
|
|
41
|
+
QUERY_KEY,
|
|
42
|
+
RESPONSE_MODE_KEY,
|
|
43
|
+
STATUS_KEY,
|
|
44
|
+
TASK_ID_KEY,
|
|
45
|
+
TOTAL_TOKENS_KEY,
|
|
46
|
+
USAGE_KEY,
|
|
47
|
+
USER_KEY,
|
|
48
|
+
WORKFLOW_RUN_ID_KEY,
|
|
49
|
+
)
|
|
50
|
+
from respan_sdk.constants.llm_logging import LOG_TYPE_CHAT
|
|
51
|
+
from respan_sdk.constants.llm_logging import LOG_TYPE_TASK
|
|
52
|
+
from respan_sdk.constants.llm_logging import LOG_TYPE_TEXT
|
|
53
|
+
from respan_sdk.constants.llm_logging import LOG_TYPE_WORKFLOW
|
|
54
|
+
from respan_sdk.constants.llm_logging import LogMethodChoices
|
|
55
|
+
from respan_sdk.constants.span_attributes import (
|
|
56
|
+
RESPAN_CUSTOMER_PARAMS_ID,
|
|
57
|
+
RESPAN_LOG_METHOD,
|
|
58
|
+
RESPAN_LOG_TYPE,
|
|
59
|
+
RESPAN_METADATA,
|
|
60
|
+
RESPAN_SPAN_ATTRIBUTES_MAP,
|
|
61
|
+
RESPAN_THREADS_ID,
|
|
62
|
+
RESPAN_TRACE_GROUP_ID,
|
|
63
|
+
)
|
|
64
|
+
from respan_sdk.utils.serialization import serialize_value
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def safe_json(value: Any) -> str:
|
|
68
|
+
"""Serialize arbitrary Dify values into an OTEL-safe JSON string."""
|
|
69
|
+
try:
|
|
70
|
+
return json.dumps(
|
|
71
|
+
serialize_value(value=value),
|
|
72
|
+
default=str,
|
|
73
|
+
separators=(",", ":"),
|
|
74
|
+
)
|
|
75
|
+
except Exception:
|
|
76
|
+
return json.dumps(str(value), separators=(",", ":"))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _to_mapping(value: Any) -> Mapping[str, Any] | None:
|
|
80
|
+
if isinstance(value, Mapping):
|
|
81
|
+
return value
|
|
82
|
+
for method_name in ("model_dump", "to_dict", "dict", "json"):
|
|
83
|
+
method = getattr(value, method_name, None)
|
|
84
|
+
if not callable(method):
|
|
85
|
+
continue
|
|
86
|
+
try:
|
|
87
|
+
converted = method()
|
|
88
|
+
except Exception:
|
|
89
|
+
continue
|
|
90
|
+
if isinstance(converted, Mapping):
|
|
91
|
+
return converted
|
|
92
|
+
if isinstance(converted, str):
|
|
93
|
+
try:
|
|
94
|
+
parsed = json.loads(converted)
|
|
95
|
+
except Exception:
|
|
96
|
+
continue
|
|
97
|
+
if isinstance(parsed, Mapping):
|
|
98
|
+
return parsed
|
|
99
|
+
value_dict = getattr(value, "__dict__", None)
|
|
100
|
+
if isinstance(value_dict, Mapping):
|
|
101
|
+
return value_dict
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _get(value: Any, key: str, default: Any = None) -> Any:
|
|
106
|
+
mapping = _to_mapping(value)
|
|
107
|
+
if mapping is not None:
|
|
108
|
+
return mapping.get(key, default)
|
|
109
|
+
return getattr(value, key, default)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _status_code(response: Any) -> int:
|
|
113
|
+
status_code = getattr(response, "status_code", 200)
|
|
114
|
+
if isinstance(status_code, int):
|
|
115
|
+
return status_code
|
|
116
|
+
return 200
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _response_json(response: Any) -> Mapping[str, Any]:
|
|
120
|
+
if response is None:
|
|
121
|
+
return {}
|
|
122
|
+
json_method = getattr(response, "json", None)
|
|
123
|
+
if callable(json_method):
|
|
124
|
+
try:
|
|
125
|
+
value = json_method()
|
|
126
|
+
except Exception:
|
|
127
|
+
return {}
|
|
128
|
+
mapping = _to_mapping(value)
|
|
129
|
+
return mapping or {}
|
|
130
|
+
mapping = _to_mapping(response)
|
|
131
|
+
return mapping or {}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _endpoint_kind(endpoint: str) -> str:
|
|
135
|
+
if endpoint == CHAT_MESSAGES_ENDPOINT:
|
|
136
|
+
return "chat"
|
|
137
|
+
if endpoint == COMPLETION_MESSAGES_ENDPOINT:
|
|
138
|
+
return "completion"
|
|
139
|
+
if endpoint.startswith("/workflows/") or endpoint == "/workflows":
|
|
140
|
+
return "workflow"
|
|
141
|
+
return "api"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _default_span_name(endpoint: str) -> str:
|
|
145
|
+
kind = _endpoint_kind(endpoint)
|
|
146
|
+
if kind == "chat":
|
|
147
|
+
return DIFY_CHAT_SPAN_NAME
|
|
148
|
+
if kind == "completion":
|
|
149
|
+
return DIFY_COMPLETION_SPAN_NAME
|
|
150
|
+
if kind == "workflow":
|
|
151
|
+
return DIFY_WORKFLOW_SPAN_NAME
|
|
152
|
+
return f"{DIFY_API_SPAN_NAME}:{endpoint.strip('/') or 'root'}"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _log_type(endpoint: str) -> str:
|
|
156
|
+
kind = _endpoint_kind(endpoint)
|
|
157
|
+
if kind == "chat":
|
|
158
|
+
return LOG_TYPE_CHAT
|
|
159
|
+
if kind == "completion":
|
|
160
|
+
return LOG_TYPE_TEXT
|
|
161
|
+
if kind == "workflow":
|
|
162
|
+
return LOG_TYPE_WORKFLOW
|
|
163
|
+
return LOG_TYPE_TASK
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _request_input(request_json: Any, request_params: Any, request_data: Any) -> Any:
|
|
167
|
+
if request_json is not None:
|
|
168
|
+
return request_json
|
|
169
|
+
if request_data is not None:
|
|
170
|
+
return request_data
|
|
171
|
+
if request_params is not None:
|
|
172
|
+
return request_params
|
|
173
|
+
return {}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _string_content(value: Any) -> str:
|
|
177
|
+
if value is None:
|
|
178
|
+
return ""
|
|
179
|
+
if isinstance(value, str):
|
|
180
|
+
return value
|
|
181
|
+
return safe_json(value=value)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _completion_prompt(request_json: Mapping[str, Any]) -> str:
|
|
185
|
+
query = request_json.get(QUERY_KEY)
|
|
186
|
+
if query:
|
|
187
|
+
return _string_content(query)
|
|
188
|
+
inputs = request_json.get(INPUTS_KEY)
|
|
189
|
+
if isinstance(inputs, Mapping) and inputs.get(QUERY_KEY):
|
|
190
|
+
return _string_content(inputs.get(QUERY_KEY))
|
|
191
|
+
return _string_content(inputs)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _response_output(response_data: Mapping[str, Any], stream_events: Sequence[Any]) -> Any:
|
|
195
|
+
if stream_events:
|
|
196
|
+
text = _stream_answer(stream_events=stream_events)
|
|
197
|
+
if text:
|
|
198
|
+
return text
|
|
199
|
+
return stream_events
|
|
200
|
+
if ANSWER_KEY in response_data:
|
|
201
|
+
return response_data.get(ANSWER_KEY)
|
|
202
|
+
data = response_data.get(DATA_KEY)
|
|
203
|
+
if isinstance(data, Mapping):
|
|
204
|
+
if OUTPUTS_KEY in data:
|
|
205
|
+
return data.get(OUTPUTS_KEY)
|
|
206
|
+
if ERROR_KEY in data and data.get(ERROR_KEY):
|
|
207
|
+
return data.get(ERROR_KEY)
|
|
208
|
+
return response_data
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _metadata_usage(response_data: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
212
|
+
metadata = response_data.get(METADATA_KEY)
|
|
213
|
+
usage = _get(metadata, USAGE_KEY)
|
|
214
|
+
mapping = _to_mapping(usage)
|
|
215
|
+
if mapping is not None:
|
|
216
|
+
return mapping
|
|
217
|
+
|
|
218
|
+
data = response_data.get(DATA_KEY)
|
|
219
|
+
if isinstance(data, Mapping):
|
|
220
|
+
return data
|
|
221
|
+
return {}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _stream_answer(stream_events: Sequence[Any]) -> str:
|
|
225
|
+
parts: list[str] = []
|
|
226
|
+
for event in stream_events:
|
|
227
|
+
mapping = _to_mapping(event)
|
|
228
|
+
if mapping is None:
|
|
229
|
+
if isinstance(event, str) and event and not event.startswith("["):
|
|
230
|
+
parts.append(event)
|
|
231
|
+
continue
|
|
232
|
+
answer = mapping.get(ANSWER_KEY)
|
|
233
|
+
if answer:
|
|
234
|
+
parts.append(str(answer))
|
|
235
|
+
continue
|
|
236
|
+
data = mapping.get(DATA_KEY)
|
|
237
|
+
if isinstance(data, Mapping):
|
|
238
|
+
for key in ("text", ANSWER_KEY):
|
|
239
|
+
value = data.get(key)
|
|
240
|
+
if value:
|
|
241
|
+
parts.append(str(value))
|
|
242
|
+
break
|
|
243
|
+
return "".join(parts)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _stream_usage(stream_events: Sequence[Any]) -> Mapping[str, Any]:
|
|
247
|
+
for event in reversed(stream_events):
|
|
248
|
+
mapping = _to_mapping(event)
|
|
249
|
+
if mapping is None:
|
|
250
|
+
continue
|
|
251
|
+
usage = _metadata_usage(mapping)
|
|
252
|
+
if usage:
|
|
253
|
+
return usage
|
|
254
|
+
return {}
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _int_value(mapping: Mapping[str, Any], key: str) -> int | None:
|
|
258
|
+
value = mapping.get(key)
|
|
259
|
+
if isinstance(value, bool):
|
|
260
|
+
return None
|
|
261
|
+
if isinstance(value, int):
|
|
262
|
+
return value
|
|
263
|
+
if isinstance(value, float) and value.is_integer():
|
|
264
|
+
return int(value)
|
|
265
|
+
return None
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _set_metadata(attributes: dict[str, Any], key: str, value: Any) -> None:
|
|
269
|
+
if value is None:
|
|
270
|
+
return
|
|
271
|
+
if isinstance(value, bool):
|
|
272
|
+
attributes[f"{RESPAN_METADATA}.{key}"] = str(value).lower()
|
|
273
|
+
elif isinstance(value, (str, int, float)):
|
|
274
|
+
attributes[f"{RESPAN_METADATA}.{key}"] = str(value)
|
|
275
|
+
else:
|
|
276
|
+
attributes[f"{RESPAN_METADATA}.{key}"] = safe_json(value=value)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _apply_response_metadata(
|
|
280
|
+
attributes: dict[str, Any],
|
|
281
|
+
response_data: Mapping[str, Any],
|
|
282
|
+
) -> None:
|
|
283
|
+
for key in (
|
|
284
|
+
EVENT_KEY,
|
|
285
|
+
TASK_ID_KEY,
|
|
286
|
+
ID_KEY,
|
|
287
|
+
MESSAGE_ID_KEY,
|
|
288
|
+
CONVERSATION_ID_KEY,
|
|
289
|
+
WORKFLOW_RUN_ID_KEY,
|
|
290
|
+
MODE_KEY,
|
|
291
|
+
STATUS_KEY,
|
|
292
|
+
):
|
|
293
|
+
_set_metadata(attributes=attributes, key=f"dify.{key}", value=response_data.get(key))
|
|
294
|
+
|
|
295
|
+
data = response_data.get(DATA_KEY)
|
|
296
|
+
if isinstance(data, Mapping):
|
|
297
|
+
for key in ("workflow_id", "elapsed_time", "total_steps", TOTAL_TOKENS_KEY):
|
|
298
|
+
_set_metadata(attributes=attributes, key=f"dify.{key}", value=data.get(key))
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _apply_request_context(
|
|
302
|
+
attributes: dict[str, Any],
|
|
303
|
+
*,
|
|
304
|
+
request_json: Mapping[str, Any],
|
|
305
|
+
request_params: Mapping[str, Any],
|
|
306
|
+
) -> None:
|
|
307
|
+
user = request_json.get(USER_KEY) or request_params.get(USER_KEY)
|
|
308
|
+
if user:
|
|
309
|
+
attributes.setdefault(RESPAN_CUSTOMER_PARAMS_ID, str(user))
|
|
310
|
+
|
|
311
|
+
conversation_id = request_json.get(CONVERSATION_ID_KEY) or request_params.get(
|
|
312
|
+
CONVERSATION_ID_KEY
|
|
313
|
+
)
|
|
314
|
+
if conversation_id:
|
|
315
|
+
attributes.setdefault(RESPAN_THREADS_ID, str(conversation_id))
|
|
316
|
+
|
|
317
|
+
response_mode = request_json.get(RESPONSE_MODE_KEY)
|
|
318
|
+
if response_mode:
|
|
319
|
+
_set_metadata(
|
|
320
|
+
attributes=attributes,
|
|
321
|
+
key=f"dify.{RESPONSE_MODE_KEY}",
|
|
322
|
+
value=response_mode,
|
|
323
|
+
)
|
|
324
|
+
files = request_json.get(FILES_KEY)
|
|
325
|
+
if isinstance(files, Sequence) and not isinstance(files, (str, bytes, bytearray)):
|
|
326
|
+
_set_metadata(attributes=attributes, key="dify.files_count", value=len(files))
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _apply_usage(
|
|
330
|
+
attributes: dict[str, Any],
|
|
331
|
+
*,
|
|
332
|
+
usage: Mapping[str, Any],
|
|
333
|
+
) -> None:
|
|
334
|
+
prompt_tokens = _int_value(mapping=usage, key=PROMPT_TOKENS_KEY)
|
|
335
|
+
completion_tokens = _int_value(mapping=usage, key=COMPLETION_TOKENS_KEY)
|
|
336
|
+
total_tokens = _int_value(mapping=usage, key=TOTAL_TOKENS_KEY)
|
|
337
|
+
if total_tokens is None and (
|
|
338
|
+
prompt_tokens is not None or completion_tokens is not None
|
|
339
|
+
):
|
|
340
|
+
total_tokens = (prompt_tokens or 0) + (completion_tokens or 0)
|
|
341
|
+
|
|
342
|
+
if prompt_tokens is not None:
|
|
343
|
+
attributes[GenAIAttributes.GEN_AI_USAGE_INPUT_TOKENS] = prompt_tokens
|
|
344
|
+
attributes[SpanAttributes.LLM_USAGE_PROMPT_TOKENS] = prompt_tokens
|
|
345
|
+
if completion_tokens is not None:
|
|
346
|
+
attributes[GenAIAttributes.GEN_AI_USAGE_OUTPUT_TOKENS] = completion_tokens
|
|
347
|
+
attributes[SpanAttributes.LLM_USAGE_COMPLETION_TOKENS] = completion_tokens
|
|
348
|
+
if total_tokens is not None:
|
|
349
|
+
attributes[SpanAttributes.LLM_USAGE_TOTAL_TOKENS] = total_tokens
|
|
350
|
+
|
|
351
|
+
_set_metadata(attributes=attributes, key=f"dify.{LATENCY_KEY}", value=usage.get(LATENCY_KEY))
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _apply_llm_content(
|
|
355
|
+
attributes: dict[str, Any],
|
|
356
|
+
*,
|
|
357
|
+
endpoint: str,
|
|
358
|
+
request_json: Mapping[str, Any],
|
|
359
|
+
output: Any,
|
|
360
|
+
) -> None:
|
|
361
|
+
kind = _endpoint_kind(endpoint)
|
|
362
|
+
if kind not in {"chat", "completion"}:
|
|
363
|
+
return
|
|
364
|
+
|
|
365
|
+
attributes[SpanAttributes.LLM_SYSTEM] = "dify"
|
|
366
|
+
# Respan's backend prompt/completion parser is keyed by llm.request.type=chat
|
|
367
|
+
# even for text-style Dify completion apps, which return answer-shaped
|
|
368
|
+
# message payloads rather than raw completion choices.
|
|
369
|
+
attributes[SpanAttributes.LLM_REQUEST_TYPE] = LLMRequestTypeValues.CHAT.value
|
|
370
|
+
|
|
371
|
+
prompt_content = (
|
|
372
|
+
_string_content(request_json.get(QUERY_KEY))
|
|
373
|
+
if kind == "chat"
|
|
374
|
+
else _completion_prompt(request_json=request_json)
|
|
375
|
+
)
|
|
376
|
+
if prompt_content:
|
|
377
|
+
attributes[f"{SpanAttributes.LLM_PROMPTS}.0.role"] = "user"
|
|
378
|
+
attributes[f"{SpanAttributes.LLM_PROMPTS}.0.content"] = prompt_content
|
|
379
|
+
|
|
380
|
+
output_text = _string_content(output)
|
|
381
|
+
if output_text:
|
|
382
|
+
attributes[f"{SpanAttributes.LLM_COMPLETIONS}.0.role"] = "assistant"
|
|
383
|
+
attributes[f"{SpanAttributes.LLM_COMPLETIONS}.0.content"] = output_text
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _apply_respan_params(
|
|
387
|
+
*,
|
|
388
|
+
attributes: dict[str, Any],
|
|
389
|
+
respan_params: Mapping[str, Any],
|
|
390
|
+
current_workflow_name: str | None,
|
|
391
|
+
default_span_name: str,
|
|
392
|
+
) -> str:
|
|
393
|
+
span_name = str(respan_params.get("span_name") or default_span_name)
|
|
394
|
+
workflow_name = (
|
|
395
|
+
respan_params.get("workflow_name")
|
|
396
|
+
or respan_params.get("span_workflow_name")
|
|
397
|
+
or current_workflow_name
|
|
398
|
+
)
|
|
399
|
+
if workflow_name:
|
|
400
|
+
attributes.setdefault(SpanAttributes.TRACELOOP_WORKFLOW_NAME, str(workflow_name))
|
|
401
|
+
if respan_params.get("workflow_name") and "trace_group_identifier" not in respan_params:
|
|
402
|
+
attributes.setdefault(RESPAN_TRACE_GROUP_ID, str(respan_params["workflow_name"]))
|
|
403
|
+
|
|
404
|
+
for key, value in respan_params.items():
|
|
405
|
+
if key in {
|
|
406
|
+
"parent_span_id",
|
|
407
|
+
"span_id",
|
|
408
|
+
"span_name",
|
|
409
|
+
"trace_id",
|
|
410
|
+
"trace_name",
|
|
411
|
+
"workflow_name",
|
|
412
|
+
"span_workflow_name",
|
|
413
|
+
}:
|
|
414
|
+
continue
|
|
415
|
+
attr_key = RESPAN_SPAN_ATTRIBUTES_MAP.get(str(key))
|
|
416
|
+
if attr_key is None:
|
|
417
|
+
continue
|
|
418
|
+
if attr_key == RESPAN_METADATA and isinstance(value, Mapping):
|
|
419
|
+
for metadata_key, metadata_value in value.items():
|
|
420
|
+
_set_metadata(
|
|
421
|
+
attributes=attributes,
|
|
422
|
+
key=str(metadata_key),
|
|
423
|
+
value=metadata_value,
|
|
424
|
+
)
|
|
425
|
+
else:
|
|
426
|
+
attributes[attr_key] = value
|
|
427
|
+
return span_name
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def build_dify_span_data(
|
|
431
|
+
*,
|
|
432
|
+
method: str,
|
|
433
|
+
endpoint: str,
|
|
434
|
+
request_json: Any = None,
|
|
435
|
+
request_params: Any = None,
|
|
436
|
+
request_data: Any = None,
|
|
437
|
+
files: Any = None,
|
|
438
|
+
response: Any = None,
|
|
439
|
+
stream_events: Sequence[Any] | None = None,
|
|
440
|
+
error: Exception | None = None,
|
|
441
|
+
include_content: bool = True,
|
|
442
|
+
respan_params: Mapping[str, Any] | None = None,
|
|
443
|
+
propagated_attributes: Mapping[str, Any] | None = None,
|
|
444
|
+
current_workflow_name: str | None = None,
|
|
445
|
+
) -> tuple[str, dict[str, Any]]:
|
|
446
|
+
"""Build canonical span name and attributes from a Dify client call."""
|
|
447
|
+
endpoint = endpoint or ""
|
|
448
|
+
method = (method or "").upper()
|
|
449
|
+
request_json_mapping = _to_mapping(request_json) or {}
|
|
450
|
+
request_params_mapping = _to_mapping(request_params) or {}
|
|
451
|
+
response_data = _response_json(response)
|
|
452
|
+
stream_events = list(stream_events or [])
|
|
453
|
+
output = str(error) if error is not None else _response_output(response_data, stream_events)
|
|
454
|
+
default_span_name = _default_span_name(endpoint=endpoint)
|
|
455
|
+
|
|
456
|
+
attributes: dict[str, Any] = {
|
|
457
|
+
RESPAN_LOG_METHOD: LogMethodChoices.TRACING_INTEGRATION.value,
|
|
458
|
+
RESPAN_LOG_TYPE: _log_type(endpoint=endpoint),
|
|
459
|
+
SpanAttributes.TRACELOOP_ENTITY_NAME: default_span_name,
|
|
460
|
+
SpanAttributes.TRACELOOP_ENTITY_PATH: default_span_name,
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
_set_metadata(attributes=attributes, key=f"dify.{METHOD_KEY}", value=method)
|
|
464
|
+
_set_metadata(attributes=attributes, key=f"dify.{ENDPOINT_KEY}", value=endpoint)
|
|
465
|
+
if files:
|
|
466
|
+
_set_metadata(attributes=attributes, key="dify.file_upload", value=True)
|
|
467
|
+
|
|
468
|
+
_apply_request_context(
|
|
469
|
+
attributes=attributes,
|
|
470
|
+
request_json=request_json_mapping,
|
|
471
|
+
request_params=request_params_mapping,
|
|
472
|
+
)
|
|
473
|
+
_apply_response_metadata(attributes=attributes, response_data=response_data)
|
|
474
|
+
|
|
475
|
+
usage = (
|
|
476
|
+
_stream_usage(stream_events=stream_events)
|
|
477
|
+
if stream_events
|
|
478
|
+
else _metadata_usage(response_data=response_data)
|
|
479
|
+
)
|
|
480
|
+
_apply_usage(attributes=attributes, usage=usage)
|
|
481
|
+
|
|
482
|
+
if include_content:
|
|
483
|
+
request_input = _request_input(
|
|
484
|
+
request_json=request_json,
|
|
485
|
+
request_params=request_params,
|
|
486
|
+
request_data=request_data,
|
|
487
|
+
)
|
|
488
|
+
attributes[SpanAttributes.TRACELOOP_ENTITY_INPUT] = safe_json(
|
|
489
|
+
value=request_input
|
|
490
|
+
)
|
|
491
|
+
attributes[SpanAttributes.TRACELOOP_ENTITY_OUTPUT] = _string_content(output)
|
|
492
|
+
_apply_llm_content(
|
|
493
|
+
attributes=attributes,
|
|
494
|
+
endpoint=endpoint,
|
|
495
|
+
request_json=request_json_mapping,
|
|
496
|
+
output=output,
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
span_name = _apply_respan_params(
|
|
500
|
+
attributes=attributes,
|
|
501
|
+
respan_params=respan_params or {},
|
|
502
|
+
current_workflow_name=current_workflow_name,
|
|
503
|
+
default_span_name=default_span_name,
|
|
504
|
+
)
|
|
505
|
+
attributes[SpanAttributes.TRACELOOP_ENTITY_NAME] = span_name
|
|
506
|
+
attributes[SpanAttributes.TRACELOOP_ENTITY_PATH] = span_name
|
|
507
|
+
|
|
508
|
+
for key, value in (propagated_attributes or {}).items():
|
|
509
|
+
attributes[key] = value
|
|
510
|
+
|
|
511
|
+
for alias in OFF_CONTRACT_ALIASES:
|
|
512
|
+
attributes.pop(alias, None)
|
|
513
|
+
|
|
514
|
+
return span_name, attributes
|