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.
@@ -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,7 @@
1
+ """Respan instrumentation for the Dify Python client."""
2
+
3
+ from respan_instrumentation_dify._instrumentation import DifyInstrumentor
4
+
5
+ DifyAIInstrumentor = DifyInstrumentor
6
+
7
+ __all__ = ["DifyInstrumentor", "DifyAIInstrumentor"]
@@ -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