Skip to content

Commit e6e67c8

Browse files
authored
Merge pull request #29 from hieheihei/feat/add_dify_support
Feat: Add Dify Instrumentor
2 parents b6dae09 + 2f18ef4 commit e6e67c8

30 files changed

+4282
-0
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# OpenTelemerty Dify Instrumentation
2+
3+
Dify Python Agent provides observability for Dify applications. This document provides examples of usage and results in the Dify instrumentation. For details on usage and installation of LoongSuite and Jaeger, please refer to [LoongSuite Documentation](https://github.com/alibaba/loongsuite-python-agent/blob/main/README.md).
4+
5+
## Installation
6+
7+
```shell
8+
git clone https://github.com/alibaba/loongsuite-python-agent.git
9+
pip install ./instrumentation-genai/opentelemetry-instrumentation-dify
10+
```
2.38 MB
Loading
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
[build-system]
2+
requires = ["hatchling"]
3+
build-backend = "hatchling.build"
4+
5+
[project]
6+
name = "opentelemetry-instrumentation-dify"
7+
dynamic = ["version"]
8+
description = "OpenTelemetry Dify Instrumentation"
9+
readme = "README.md"
10+
license = "Apache-2.0"
11+
requires-python = ">=3.8, <3.13"
12+
authors = [
13+
{ name = "LoongSuite Python Agent Authors", email = "" },
14+
]
15+
classifiers = [
16+
"Development Status :: 5 - Production/Stable",
17+
"Intended Audience :: Developers",
18+
"License :: OSI Approved :: Apache Software License",
19+
"Programming Language :: Python",
20+
"Programming Language :: Python :: 3",
21+
"Programming Language :: Python :: 3.8",
22+
"Programming Language :: Python :: 3.9",
23+
"Programming Language :: Python :: 3.10",
24+
"Programming Language :: Python :: 3.11",
25+
"Programming Language :: Python :: 3.12",
26+
"Programming Language :: Python :: 3.13",
27+
]
28+
dependencies = [
29+
"wrapt",
30+
]
31+
32+
[project.optional-dependencies]
33+
instruments = [
34+
]
35+
test = [
36+
"pytest",
37+
"opentelemetry-sdk",
38+
]
39+
type-check = []
40+
41+
42+
[tool.hatch.version]
43+
path = "src/opentelemetry/instrumentation/dify/version.py"
44+
45+
[tool.hatch.build.targets.sdist]
46+
include = [
47+
"src",
48+
]
49+
50+
[tool.hatch.build.targets.wheel]
51+
packages = ["src/opentelemetry"]
52+
53+
[project.entry-points.opentelemetry_instrumentor]
54+
dify = "opentelemetry.instrumentation.dify:DifyInstrumentor"
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import logging
2+
from typing import Any, Collection
3+
4+
from opentelemetry.instrumentation.dify.package import _instruments
5+
from opentelemetry.instrumentation.dify.wrapper import set_wrappers
6+
from opentelemetry import trace as trace_api
7+
from opentelemetry.instrumentation.instrumentor import BaseInstrumentor # type: ignore
8+
9+
from opentelemetry.instrumentation.dify.config import is_version_supported, MIN_SUPPORTED_VERSION, MAX_SUPPORTED_VERSION
10+
11+
logger = logging.getLogger(__name__)
12+
logger.addHandler(logging.NullHandler())
13+
14+
15+
class DifyInstrumentor(BaseInstrumentor): # type: ignore
16+
"""
17+
An instrumentor for Dify
18+
"""
19+
20+
def instrumentation_dependencies(self) -> Collection[str]:
21+
return _instruments
22+
23+
def _instrument(self, **kwargs: Any) -> None:
24+
if not is_version_supported():
25+
logger.warning(
26+
f"Dify version is not supported. Current version must be between {MIN_SUPPORTED_VERSION} and {MAX_SUPPORTED_VERSION}."
27+
)
28+
return
29+
if not (tracer_provider := kwargs.get("tracer_provider")):
30+
tracer_provider = trace_api.get_tracer_provider()
31+
tracer = trace_api.get_tracer(__name__, None, tracer_provider=tracer_provider)
32+
33+
set_wrappers(tracer)
34+
35+
def _uninstrument(self, **kwargs: Any) -> None:
36+
pass
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
from typing import Any, Dict
2+
from abc import ABC
3+
from opentelemetry.metrics import get_meter
4+
5+
from opentelemetry import trace
6+
from opentelemetry.trace import Tracer
7+
8+
from opentelemetry.instrumentation.dify.semconv import GEN_AI_USER_ID, GEN_AI_SESSION_ID
9+
from opentelemetry.instrumentation.dify.utils import get_llm_common_attributes
10+
from opentelemetry.instrumentation.dify.version import __version__
11+
from opentelemetry.context import get_value
12+
from opentelemetry.instrumentation.dify.contants import _get_dify_app_name_key, DIFY_APP_ID_KEY
13+
14+
15+
_DIFY_APP_NAME_KEY = _get_dify_app_name_key()
16+
17+
18+
class BaseWrapper(ABC):
19+
def __init__(self, tracer: Tracer):
20+
self.tracer = tracer
21+
self._span_kind = "TASK"
22+
self._meter = get_meter(
23+
__name__,
24+
__version__,
25+
None,
26+
schema_url="https://opentelemetry.io/schemas/1.11.0",
27+
)
28+
self._app_list: Dict[str, str] = {}
29+
self._init_metrics()
30+
31+
def _init_metrics(self):
32+
meter = self._meter
33+
34+
def set_span_kind(self, span_kind: str):
35+
self._span_kind = span_kind
36+
37+
def span_kind(self):
38+
return self._span_kind
39+
40+
def get_common_attributes(self):
41+
attributes = get_llm_common_attributes()
42+
attributes["spanKind"] = self.span_kind()
43+
return attributes
44+
45+
def extract_attributes_from_context(self) -> Dict:
46+
attributes = {}
47+
app_name = get_value(_DIFY_APP_NAME_KEY)
48+
app_id = get_value(DIFY_APP_ID_KEY)
49+
user_id = get_value(GEN_AI_USER_ID)
50+
session_id = get_value(GEN_AI_SESSION_ID)
51+
if app_name:
52+
attributes[_DIFY_APP_NAME_KEY] = app_name
53+
if app_id:
54+
attributes[DIFY_APP_ID_KEY] = app_id
55+
if user_id:
56+
attributes[GEN_AI_USER_ID] = user_id
57+
if session_id:
58+
attributes[GEN_AI_SESSION_ID] = session_id
59+
return attributes
60+
61+
def before_process(self):
62+
pass
63+
64+
def after_process(self):
65+
pass
66+
67+
def record_call_count(self, attributes: Dict[str, Any] = None, span_kind: str = None):
68+
"""记录调用次数"""
69+
common_attrs = self.get_common_attributes()
70+
if span_kind:
71+
common_attrs["spanKind"] = span_kind
72+
if attributes:
73+
common_attrs.update(attributes)
74+
75+
def record_duration(self, duration: float, attributes: Dict[str, Any] = None, span_kind: str = None):
76+
"""记录调用持续时间"""
77+
common_attrs = self.get_common_attributes()
78+
if span_kind:
79+
common_attrs["spanKind"] = span_kind
80+
if attributes:
81+
common_attrs.update(attributes)
82+
83+
def record_call_error_count(self, attributes: Dict[str, Any] = None, span_kind: str = None):
84+
"""记录调用错误次数"""
85+
common_attrs = self.get_common_attributes()
86+
if span_kind:
87+
common_attrs["spanKind"] = span_kind
88+
if attributes:
89+
common_attrs.update(attributes)
90+
91+
class LLMBaseWrapper(BaseWrapper):
92+
def __init__(self, tracer: Tracer):
93+
super().__init__(tracer)
94+
95+
def get_trace_headers(self, current_span=None):
96+
# Get current context
97+
if current_span is None:
98+
current_span = trace.get_current_span()
99+
if not current_span:
100+
# logger.debug("No current span found")
101+
return {}
102+
current_context = current_span.get_span_context()
103+
# Only inject if we have a valid context
104+
if current_context and hasattr(current_context, "trace_id") and hasattr(current_context, "span_id"):
105+
# Create trace headers
106+
trace_headers = {}
107+
# Create traceparent header
108+
trace_id_hex = format(current_context.trace_id, "032x")
109+
span_id_hex = format(current_context.span_id, "016x")
110+
flags = format(int(current_context.trace_flags) if hasattr(current_context, "trace_flags") else 1,
111+
"02x")
112+
traceparent = f"00-{trace_id_hex}-{span_id_hex}-{flags}"
113+
trace_headers["traceparent"] = traceparent
114+
# Add tracestate if available
115+
if hasattr(current_context, "trace_state") and current_context.trace_state:
116+
trace_headers["tracestate"] = str(current_context.trace_state)
117+
118+
return trace_headers
119+
120+
121+
def record_call_count(self, model_name: str, attributes: Dict[str, Any] = None, span_kind: str = "LLM"):
122+
"""记录调用次数"""
123+
if attributes is None:
124+
attributes = {}
125+
attributes["modelName"] = model_name
126+
super().record_call_count(attributes, span_kind)
127+
128+
def record_duration(self, duration: float, model_name: str, attributes: Dict[str, Any] = None,
129+
span_kind: str = "LLM"):
130+
"""记录调用持续时间"""
131+
if attributes is None:
132+
attributes = {}
133+
attributes["modelName"] = model_name
134+
super().record_duration(duration, attributes, span_kind)
135+
136+
def record_call_error_count(self, model_name: str, attributes: Dict[str, Any] = None, span_kind: str = "LLM"):
137+
"""记录调用错误次数"""
138+
if attributes is None:
139+
attributes = {}
140+
attributes["modelName"] = model_name
141+
super().record_call_error_count(attributes, span_kind)
142+
143+
def record_llm_output_token_seconds(self, duration: float, attributes: Dict[str, Any] = None,
144+
span_kind: str = "LLM"):
145+
"""记录LLM输出token的持续时间"""
146+
common_attrs = self.get_common_attributes()
147+
if span_kind:
148+
common_attrs["spanKind"] = span_kind
149+
if attributes:
150+
common_attrs.update(attributes)
151+
152+
def record_first_token_seconds(self, duration: float, model_name: str, attributes: Dict[str, Any] = None,
153+
span_kind: str = "LLM"):
154+
"""记录首包耗时"""
155+
common_attrs = self.get_common_attributes()
156+
if span_kind:
157+
common_attrs["spanKind"] = span_kind
158+
if attributes:
159+
common_attrs.update(attributes)
160+
common_attrs["modelName"] = model_name
161+
162+
def _record_llm_tokens(self, tokens: int, usage_type: str, model_name: str, attributes: Dict[str, Any] = None,
163+
span_kind: str = "LLM"):
164+
"""记录LLM token数量的通用方法"""
165+
common_attrs = self.get_common_attributes()
166+
if span_kind:
167+
common_attrs["spanKind"] = span_kind
168+
if attributes:
169+
common_attrs.update(attributes)
170+
common_attrs["usageType"] = usage_type
171+
common_attrs["modelName"] = model_name
172+
173+
def record_llm_input_tokens(self, tokens: int, model_name: str, attributes: Dict[str, Any] = None,
174+
span_kind: str = "LLM"):
175+
"""记录LLM输入token的数量"""
176+
self._record_llm_tokens(tokens, "input", model_name, attributes, span_kind)
177+
178+
def record_llm_output_tokens(self, tokens: int, model_name: str, attributes: Dict[str, Any] = None,
179+
span_kind: str = "LLM"):
180+
"""记录LLM输出token的数量"""
181+
self._record_llm_tokens(tokens, "output", model_name, attributes, span_kind)
182+
183+
184+
class TOOLBaseWrapper(BaseWrapper):
185+
def __init__(self, tracer: Tracer):
186+
super().__init__(tracer)
187+
188+
def _init_metrics(self):
189+
super()._init_metrics()
190+
191+
def record_call_count(self, tool_name: str, attributes: Dict[str, Any] = None, span_kind: str = "TOOL"):
192+
"""记录调用次数"""
193+
if attributes is None:
194+
attributes = {}
195+
attributes["rpc"] = tool_name
196+
super().record_call_count(attributes, span_kind)
197+
198+
def record_duration(self, duration: float, tool_name: str, attributes: Dict[str, Any] = None,
199+
span_kind: str = "TOOL"):
200+
"""记录调用持续时间"""
201+
if attributes is None:
202+
attributes = {}
203+
attributes["rpc"] = tool_name
204+
super().record_duration(duration, attributes, span_kind)
205+
206+
def record_call_error_count(self, tool_name: str, attributes: Dict[str, Any] = None, span_kind: str = "TOOL"):
207+
"""记录调用错误次数"""
208+
if attributes is None:
209+
attributes = {}
210+
attributes["rpc"] = tool_name
211+
super().record_call_error_count(attributes, span_kind)
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
from opentelemetry import trace as trace_api
2+
from opentelemetry.instrumentation.dify.env_utils import is_capture_content_enabled
3+
from opentelemetry.instrumentation.dify.semconv import INPUT_VALUE, OUTPUT_VALUE
4+
5+
content_key = [
6+
INPUT_VALUE,
7+
OUTPUT_VALUE,
8+
'gen_ai.request.tool_calls',
9+
'gen_ai.request.stop_sequences',
10+
'tool.parameters',
11+
'vector_search.query',
12+
'full_text_search.query'
13+
]
14+
15+
content_prefixes_key = [
16+
"gen_ai.prompts",
17+
"gen_ai.completions",
18+
"retrieval.documents",
19+
"vector_search.document",
20+
"embedding.embeddings",
21+
"reranker.input_documents",
22+
"reranker.output_documents",
23+
"reranker.query",
24+
]
25+
26+
max_content_length = 4 * 1024
27+
28+
def set_dict_value(attr:dict, key:str, value:str) -> None:
29+
if is_capture_content_enabled():
30+
attr[key] = value
31+
elif not is_content_key(key):
32+
attr[key] = value
33+
else:
34+
attr[key] = to_size(value)
35+
36+
def set_span_value(span: trace_api.Span, key:str, value:str) -> None:
37+
if is_capture_content_enabled():
38+
span.set_attribute(key, value)
39+
elif not is_content_key(key):
40+
span.set_attribute(key, value)
41+
else:
42+
span.set_attribute(key, to_size(value))
43+
44+
def is_content_key(key:str) -> bool:
45+
return (key in content_key) or any(key.startswith(prefix) for prefix in content_prefixes_key)
46+
47+
def process_content(content: str | None) -> str:
48+
if is_capture_content_enabled():
49+
if content is not None and len(content) > max_content_length:
50+
content = content[:max_content_length] + "..."
51+
return content
52+
elif content is None:
53+
return "<0size>"
54+
else:
55+
return to_size(content)
56+
57+
def to_size(content:str) -> str:
58+
if content is None:
59+
return "<0size>"
60+
size = len(content)
61+
return f"<{size}size>"

0 commit comments

Comments
 (0)