Skip to content

Commit 9cf6377

Browse files
feat(ai): Langchain integration (#2911)
Integration for Langchain. --------- Co-authored-by: Anton Pirker <[email protected]>
1 parent fb1b746 commit 9cf6377

File tree

15 files changed

+938
-72
lines changed

15 files changed

+938
-72
lines changed

Diff for: .github/workflows/test-integrations-data-processing.yml

+8
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ jobs:
5858
run: |
5959
set -x # print commands that are executed
6060
./scripts/runtox.sh "py${{ matrix.python-version }}-huey-latest" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch
61+
- name: Test langchain latest
62+
run: |
63+
set -x # print commands that are executed
64+
./scripts/runtox.sh "py${{ matrix.python-version }}-langchain-latest" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch
6165
- name: Test openai latest
6266
run: |
6367
set -x # print commands that are executed
@@ -114,6 +118,10 @@ jobs:
114118
run: |
115119
set -x # print commands that are executed
116120
./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-huey" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch
121+
- name: Test langchain pinned
122+
run: |
123+
set -x # print commands that are executed
124+
./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-langchain" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch
117125
- name: Test openai pinned
118126
run: |
119127
set -x # print commands that are executed

Diff for: mypy.ini

+2
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ ignore_missing_imports = True
4848
ignore_missing_imports = True
4949
[mypy-asgiref.*]
5050
ignore_missing_imports = True
51+
[mypy-langchain_core.*]
52+
ignore_missing_imports = True
5153
[mypy-executing.*]
5254
ignore_missing_imports = True
5355
[mypy-asttokens.*]

Diff for: scripts/split-tox-gh-actions/split-tox-gh-actions.py

+1
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
"beam",
7171
"celery",
7272
"huey",
73+
"langchain",
7374
"openai",
7475
"rq",
7576
],

Diff for: sentry_sdk/ai/__init__.py

Whitespace-only changes.

Diff for: sentry_sdk/ai/monitoring.py

+77
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
from functools import wraps
2+
3+
import sentry_sdk.utils
4+
from sentry_sdk import start_span
5+
from sentry_sdk.tracing import Span
6+
from sentry_sdk.utils import ContextVar
7+
from sentry_sdk._types import TYPE_CHECKING
8+
9+
if TYPE_CHECKING:
10+
from typing import Optional, Callable, Any
11+
12+
_ai_pipeline_name = ContextVar("ai_pipeline_name", default=None)
13+
14+
15+
def set_ai_pipeline_name(name):
16+
# type: (Optional[str]) -> None
17+
_ai_pipeline_name.set(name)
18+
19+
20+
def get_ai_pipeline_name():
21+
# type: () -> Optional[str]
22+
return _ai_pipeline_name.get()
23+
24+
25+
def ai_track(description, **span_kwargs):
26+
# type: (str, Any) -> Callable[..., Any]
27+
def decorator(f):
28+
# type: (Callable[..., Any]) -> Callable[..., Any]
29+
@wraps(f)
30+
def wrapped(*args, **kwargs):
31+
# type: (Any, Any) -> Any
32+
curr_pipeline = _ai_pipeline_name.get()
33+
op = span_kwargs.get("op", "ai.run" if curr_pipeline else "ai.pipeline")
34+
with start_span(description=description, op=op, **span_kwargs) as span:
35+
if curr_pipeline:
36+
span.set_data("ai.pipeline.name", curr_pipeline)
37+
return f(*args, **kwargs)
38+
else:
39+
_ai_pipeline_name.set(description)
40+
try:
41+
res = f(*args, **kwargs)
42+
except Exception as e:
43+
event, hint = sentry_sdk.utils.event_from_exception(
44+
e,
45+
client_options=sentry_sdk.get_client().options,
46+
mechanism={"type": "ai_monitoring", "handled": False},
47+
)
48+
sentry_sdk.capture_event(event, hint=hint)
49+
raise e from None
50+
finally:
51+
_ai_pipeline_name.set(None)
52+
return res
53+
54+
return wrapped
55+
56+
return decorator
57+
58+
59+
def record_token_usage(
60+
span, prompt_tokens=None, completion_tokens=None, total_tokens=None
61+
):
62+
# type: (Span, Optional[int], Optional[int], Optional[int]) -> None
63+
ai_pipeline_name = get_ai_pipeline_name()
64+
if ai_pipeline_name:
65+
span.set_data("ai.pipeline.name", ai_pipeline_name)
66+
if prompt_tokens is not None:
67+
span.set_measurement("ai_prompt_tokens_used", value=prompt_tokens)
68+
if completion_tokens is not None:
69+
span.set_measurement("ai_completion_tokens_used", value=completion_tokens)
70+
if (
71+
total_tokens is None
72+
and prompt_tokens is not None
73+
and completion_tokens is not None
74+
):
75+
total_tokens = prompt_tokens + completion_tokens
76+
if total_tokens is not None:
77+
span.set_measurement("ai_total_tokens_used", total_tokens)

Diff for: sentry_sdk/ai/utils.py

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from sentry_sdk._types import TYPE_CHECKING
2+
3+
if TYPE_CHECKING:
4+
from typing import Any
5+
6+
from sentry_sdk.tracing import Span
7+
from sentry_sdk.utils import logger
8+
9+
10+
def _normalize_data(data):
11+
# type: (Any) -> Any
12+
13+
# convert pydantic data (e.g. OpenAI v1+) to json compatible format
14+
if hasattr(data, "model_dump"):
15+
try:
16+
return data.model_dump()
17+
except Exception as e:
18+
logger.warning("Could not convert pydantic data to JSON: %s", e)
19+
return data
20+
if isinstance(data, list):
21+
if len(data) == 1:
22+
return _normalize_data(data[0]) # remove empty dimensions
23+
return list(_normalize_data(x) for x in data)
24+
if isinstance(data, dict):
25+
return {k: _normalize_data(v) for (k, v) in data.items()}
26+
return data
27+
28+
29+
def set_data_normalized(span, key, value):
30+
# type: (Span, str, Any) -> None
31+
normalized = _normalize_data(value)
32+
span.set_data(key, normalized)

Diff for: sentry_sdk/consts.py

+84
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,85 @@ class SPANDATA:
9191
See: https://develop.sentry.dev/sdk/performance/span-data-conventions/
9292
"""
9393

94+
AI_INPUT_MESSAGES = "ai.input_messages"
95+
"""
96+
The input messages to an LLM call.
97+
Example: [{"role": "user", "message": "hello"}]
98+
"""
99+
100+
AI_MODEL_ID = "ai.model_id"
101+
"""
102+
The unique descriptor of the model being execugted
103+
Example: gpt-4
104+
"""
105+
106+
AI_METADATA = "ai.metadata"
107+
"""
108+
Extra metadata passed to an AI pipeline step.
109+
Example: {"executed_function": "add_integers"}
110+
"""
111+
112+
AI_TAGS = "ai.tags"
113+
"""
114+
Tags that describe an AI pipeline step.
115+
Example: {"executed_function": "add_integers"}
116+
"""
117+
118+
AI_STREAMING = "ai.streaming"
119+
"""
120+
Whether or not the AI model call's repsonse was streamed back asynchronously
121+
Example: true
122+
"""
123+
124+
AI_TEMPERATURE = "ai.temperature"
125+
"""
126+
For an AI model call, the temperature parameter. Temperature essentially means how random the output will be.
127+
Example: 0.5
128+
"""
129+
130+
AI_TOP_P = "ai.top_p"
131+
"""
132+
For an AI model call, the top_p parameter. Top_p essentially controls how random the output will be.
133+
Example: 0.5
134+
"""
135+
136+
AI_TOP_K = "ai.top_k"
137+
"""
138+
For an AI model call, the top_k parameter. Top_k essentially controls how random the output will be.
139+
Example: 35
140+
"""
141+
142+
AI_FUNCTION_CALL = "ai.function_call"
143+
"""
144+
For an AI model call, the function that was called. This is deprecated for OpenAI, and replaced by tool_calls
145+
"""
146+
147+
AI_TOOL_CALLS = "ai.tool_calls"
148+
"""
149+
For an AI model call, the function that was called. This is deprecated for OpenAI, and replaced by tool_calls
150+
"""
151+
152+
AI_TOOLS = "ai.tools"
153+
"""
154+
For an AI model call, the functions that are available
155+
"""
156+
157+
AI_RESPONSE_FORMAT = "ai.response_format"
158+
"""
159+
For an AI model call, the format of the response
160+
"""
161+
162+
AI_LOGIT_BIAS = "ai.response_format"
163+
"""
164+
For an AI model call, the logit bias
165+
"""
166+
167+
AI_RESPONSES = "ai.responses"
168+
"""
169+
The responses to an AI model call. Always as a list.
170+
Example: ["hello", "world"]
171+
"""
172+
94173
DB_NAME = "db.name"
95174
"""
96175
The name of the database being accessed. For commands that switch the database, this should be set to the target database (even if the command fails).
@@ -245,6 +324,11 @@ class OP:
245324
MIDDLEWARE_STARLITE_SEND = "middleware.starlite.send"
246325
OPENAI_CHAT_COMPLETIONS_CREATE = "ai.chat_completions.create.openai"
247326
OPENAI_EMBEDDINGS_CREATE = "ai.embeddings.create.openai"
327+
LANGCHAIN_PIPELINE = "ai.pipeline.langchain"
328+
LANGCHAIN_RUN = "ai.run.langchain"
329+
LANGCHAIN_TOOL = "ai.tool.langchain"
330+
LANGCHAIN_AGENT = "ai.agent.langchain"
331+
LANGCHAIN_CHAT_COMPLETIONS_CREATE = "ai.chat_completions.create.langchain"
248332
QUEUE_SUBMIT_ARQ = "queue.submit.arq"
249333
QUEUE_TASK_ARQ = "queue.task.arq"
250334
QUEUE_SUBMIT_CELERY = "queue.submit.celery"

Diff for: sentry_sdk/integrations/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ def iter_default_integrations(with_auto_enabling_integrations):
8585
"sentry_sdk.integrations.graphene.GrapheneIntegration",
8686
"sentry_sdk.integrations.httpx.HttpxIntegration",
8787
"sentry_sdk.integrations.huey.HueyIntegration",
88+
"sentry_sdk.integrations.langchain.LangchainIntegration",
8889
"sentry_sdk.integrations.loguru.LoguruIntegration",
8990
"sentry_sdk.integrations.openai.OpenAIIntegration",
9091
"sentry_sdk.integrations.pymongo.PyMongoIntegration",

0 commit comments

Comments
 (0)