Skip to content

Commit eac253a

Browse files
czyberantonpirker
andauthored
feat(integrations): Add Anthropic Integration (#2831)
This PR adds an anthropic integration. It supports the creation of messages in streaming and non-streaming mode. --------- Co-authored-by: Anton Pirker <[email protected]>
1 parent f98f77f commit eac253a

File tree

9 files changed

+406
-0
lines changed

9 files changed

+406
-0
lines changed

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

+8
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ jobs:
4242
- name: Erase coverage
4343
run: |
4444
coverage erase
45+
- name: Test anthropic latest
46+
run: |
47+
set -x # print commands that are executed
48+
./scripts/runtox.sh "py${{ matrix.python-version }}-anthropic-latest" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch
4549
- name: Test arq latest
4650
run: |
4751
set -x # print commands that are executed
@@ -102,6 +106,10 @@ jobs:
102106
- name: Erase coverage
103107
run: |
104108
coverage erase
109+
- name: Test anthropic pinned
110+
run: |
111+
set -x # print commands that are executed
112+
./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-anthropic" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch
105113
- name: Test arq pinned
106114
run: |
107115
set -x # print commands that are executed

Diff for: mypy.ini

+2
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ ignore_missing_imports = True
3636
ignore_missing_imports = True
3737
[mypy-aiohttp.*]
3838
ignore_missing_imports = True
39+
[mypy-anthropic.*]
40+
ignore_missing_imports = True
3941
[mypy-sanic.*]
4042
ignore_missing_imports = True
4143
[mypy-tornado.*]

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

+1
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
"gcp",
6767
],
6868
"Data Processing": [
69+
"anthropic",
6970
"arq",
7071
"beam",
7172
"celery",

Diff for: sentry_sdk/consts.py

+1
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,7 @@ class SPANDATA:
296296

297297

298298
class OP:
299+
ANTHROPIC_MESSAGES_CREATE = "ai.messages.create.anthropic"
299300
CACHE_GET_ITEM = "cache.get_item"
300301
DB = "db"
301302
DB_REDIS = "db.redis"

Diff for: sentry_sdk/integrations/anthropic.py

+170
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
from functools import wraps
2+
3+
import sentry_sdk
4+
from sentry_sdk.ai.monitoring import record_token_usage
5+
from sentry_sdk.consts import OP, SPANDATA
6+
from sentry_sdk.integrations import DidNotEnable, Integration
7+
from sentry_sdk.scope import should_send_default_pii
8+
from sentry_sdk.utils import (
9+
capture_internal_exceptions,
10+
ensure_integration_enabled,
11+
event_from_exception,
12+
package_version,
13+
)
14+
15+
from anthropic.resources import Messages
16+
17+
from typing import TYPE_CHECKING
18+
19+
if TYPE_CHECKING:
20+
from typing import Any, Iterator
21+
from anthropic.types import MessageStreamEvent
22+
from sentry_sdk.tracing import Span
23+
24+
25+
class AnthropicIntegration(Integration):
26+
identifier = "anthropic"
27+
28+
def __init__(self, include_prompts=True):
29+
# type: (AnthropicIntegration, bool) -> None
30+
self.include_prompts = include_prompts
31+
32+
@staticmethod
33+
def setup_once():
34+
# type: () -> None
35+
version = package_version("anthropic")
36+
37+
if version is None:
38+
raise DidNotEnable("Unparsable anthropic version.")
39+
40+
if version < (0, 16):
41+
raise DidNotEnable("anthropic 0.16 or newer required.")
42+
43+
Messages.create = _wrap_message_create(Messages.create)
44+
45+
46+
def _capture_exception(exc):
47+
# type: (Any) -> None
48+
event, hint = event_from_exception(
49+
exc,
50+
client_options=sentry_sdk.get_client().options,
51+
mechanism={"type": "anthropic", "handled": False},
52+
)
53+
sentry_sdk.capture_event(event, hint=hint)
54+
55+
56+
def _calculate_token_usage(result, span):
57+
# type: (Messages, Span) -> None
58+
input_tokens = 0
59+
output_tokens = 0
60+
if hasattr(result, "usage"):
61+
usage = result.usage
62+
if hasattr(usage, "input_tokens") and isinstance(usage.input_tokens, int):
63+
input_tokens = usage.input_tokens
64+
if hasattr(usage, "output_tokens") and isinstance(usage.output_tokens, int):
65+
output_tokens = usage.output_tokens
66+
67+
total_tokens = input_tokens + output_tokens
68+
record_token_usage(span, input_tokens, output_tokens, total_tokens)
69+
70+
71+
def _wrap_message_create(f):
72+
# type: (Any) -> Any
73+
@wraps(f)
74+
@ensure_integration_enabled(AnthropicIntegration, f)
75+
def _sentry_patched_create(*args, **kwargs):
76+
# type: (*Any, **Any) -> Any
77+
if "messages" not in kwargs:
78+
return f(*args, **kwargs)
79+
80+
try:
81+
iter(kwargs["messages"])
82+
except TypeError:
83+
return f(*args, **kwargs)
84+
85+
messages = list(kwargs["messages"])
86+
model = kwargs.get("model")
87+
88+
span = sentry_sdk.start_span(
89+
op=OP.ANTHROPIC_MESSAGES_CREATE, description="Anthropic messages create"
90+
)
91+
span.__enter__()
92+
93+
try:
94+
result = f(*args, **kwargs)
95+
except Exception as exc:
96+
_capture_exception(exc)
97+
span.__exit__(None, None, None)
98+
raise exc from None
99+
100+
integration = sentry_sdk.get_client().get_integration(AnthropicIntegration)
101+
102+
with capture_internal_exceptions():
103+
span.set_data(SPANDATA.AI_MODEL_ID, model)
104+
span.set_data(SPANDATA.AI_STREAMING, False)
105+
if should_send_default_pii() and integration.include_prompts:
106+
span.set_data(SPANDATA.AI_INPUT_MESSAGES, messages)
107+
if hasattr(result, "content"):
108+
if should_send_default_pii() and integration.include_prompts:
109+
span.set_data(
110+
SPANDATA.AI_RESPONSES,
111+
list(
112+
map(
113+
lambda message: {
114+
"type": message.type,
115+
"text": message.text,
116+
},
117+
result.content,
118+
)
119+
),
120+
)
121+
_calculate_token_usage(result, span)
122+
span.__exit__(None, None, None)
123+
elif hasattr(result, "_iterator"):
124+
old_iterator = result._iterator
125+
126+
def new_iterator():
127+
# type: () -> Iterator[MessageStreamEvent]
128+
input_tokens = 0
129+
output_tokens = 0
130+
content_blocks = []
131+
with capture_internal_exceptions():
132+
for event in old_iterator:
133+
if hasattr(event, "type"):
134+
if event.type == "message_start":
135+
usage = event.message.usage
136+
input_tokens += usage.input_tokens
137+
output_tokens += usage.output_tokens
138+
elif event.type == "content_block_start":
139+
pass
140+
elif event.type == "content_block_delta":
141+
content_blocks.append(event.delta.text)
142+
elif event.type == "content_block_stop":
143+
pass
144+
elif event.type == "message_delta":
145+
output_tokens += event.usage.output_tokens
146+
elif event.type == "message_stop":
147+
continue
148+
yield event
149+
150+
if should_send_default_pii() and integration.include_prompts:
151+
complete_message = "".join(content_blocks)
152+
span.set_data(
153+
SPANDATA.AI_RESPONSES,
154+
[{"type": "text", "text": complete_message}],
155+
)
156+
total_tokens = input_tokens + output_tokens
157+
record_token_usage(
158+
span, input_tokens, output_tokens, total_tokens
159+
)
160+
span.set_data(SPANDATA.AI_STREAMING, True)
161+
span.__exit__(None, None, None)
162+
163+
result._iterator = new_iterator()
164+
else:
165+
span.set_data("unknown_response", True)
166+
span.__exit__(None, None, None)
167+
168+
return result
169+
170+
return _sentry_patched_create

Diff for: setup.py

+1
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ def get_file_text(file_name):
4444
],
4545
extras_require={
4646
"aiohttp": ["aiohttp>=3.5"],
47+
"anthropic": ["anthropic>=0.16"],
4748
"arq": ["arq>=0.23"],
4849
"asyncpg": ["asyncpg>=0.23"],
4950
"beam": ["apache-beam>=2.12"],

Diff for: tests/integrations/anthropic/__init__.py

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import pytest
2+
3+
pytest.importorskip("anthropic")

0 commit comments

Comments
 (0)