Skip to content

Commit ff0a94b

Browse files
OpenAI integration (#2791)
* OpenAI integration * Fix linting errors * Fix CI * Fix lint * Fix more CI issues * Run tests on version pinned OpenAI too * Fix pydantic issue in test * Import type in TYPE_CHECKING gate * PR feedback fixes * Fix tiktoken test variant * PII gate the request and response * Rename set_data tags * Move doc location * Add "exclude prompts" flag as optional * Change prompts to be excluded by default * Set flag in tests * Fix tiktoken tox.ini extra dash * Change strip PII semantics * More test coverage for PII * notiktoken --------- Co-authored-by: Anton Pirker <[email protected]>
1 parent 46a632d commit ff0a94b

File tree

10 files changed

+546
-1
lines changed

10 files changed

+546
-1
lines changed

.github/workflows/test-integrations-data-processing.yml

+13-1
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ jobs:
2525
strategy:
2626
fail-fast: false
2727
matrix:
28-
python-version: ["3.5","3.7","3.8","3.11","3.12"]
28+
python-version: ["3.5","3.7","3.8","3.9","3.11","3.12"]
2929
# python3.6 reached EOL and is no longer being supported on
3030
# new versions of hosted runners on Github Actions
3131
# ubuntu-20.04 is the last version that supported python3.6
@@ -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 openai latest
62+
run: |
63+
set -x # print commands that are executed
64+
./scripts/runtox.sh "py${{ matrix.python-version }}-openai-latest" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch
6165
- name: Test rq latest
6266
run: |
6367
set -x # print commands that are executed
@@ -110,6 +114,10 @@ jobs:
110114
run: |
111115
set -x # print commands that are executed
112116
./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-huey" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch
117+
- name: Test openai pinned
118+
run: |
119+
set -x # print commands that are executed
120+
./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-openai" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch
113121
- name: Test rq pinned
114122
run: |
115123
set -x # print commands that are executed
@@ -151,6 +159,10 @@ jobs:
151159
run: |
152160
set -x # print commands that are executed
153161
./scripts/runtox.sh --exclude-latest "py2.7-huey" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch
162+
- name: Test openai py27
163+
run: |
164+
set -x # print commands that are executed
165+
./scripts/runtox.sh --exclude-latest "py2.7-openai" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch
154166
- name: Test rq py27
155167
run: |
156168
set -x # print commands that are executed

mypy.ini

+2
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ ignore_missing_imports = True
6767
ignore_missing_imports = True
6868
[mypy-huey.*]
6969
ignore_missing_imports = True
70+
[mypy-openai.*]
71+
ignore_missing_imports = True
7072
[mypy-arq.*]
7173
ignore_missing_imports = True
7274
[mypy-grpc.*]

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+
"openai",
7374
"rq",
7475
],
7576
"Databases": [

sentry_sdk/consts.py

+2
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,8 @@ class OP:
219219
MIDDLEWARE_STARLITE = "middleware.starlite"
220220
MIDDLEWARE_STARLITE_RECEIVE = "middleware.starlite.receive"
221221
MIDDLEWARE_STARLITE_SEND = "middleware.starlite.send"
222+
OPENAI_CHAT_COMPLETIONS_CREATE = "ai.chat_completions.create.openai"
223+
OPENAI_EMBEDDINGS_CREATE = "ai.embeddings.create.openai"
222224
QUEUE_SUBMIT_ARQ = "queue.submit.arq"
223225
QUEUE_TASK_ARQ = "queue.task.arq"
224226
QUEUE_SUBMIT_CELERY = "queue.submit.celery"

sentry_sdk/integrations/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ def iter_default_integrations(with_auto_enabling_integrations):
7878
"sentry_sdk.integrations.fastapi.FastApiIntegration",
7979
"sentry_sdk.integrations.flask.FlaskIntegration",
8080
"sentry_sdk.integrations.httpx.HttpxIntegration",
81+
"sentry_sdk.integrations.openai.OpenAIIntegration",
8182
"sentry_sdk.integrations.pyramid.PyramidIntegration",
8283
"sentry_sdk.integrations.redis.RedisIntegration",
8384
"sentry_sdk.integrations.rq.RqIntegration",

sentry_sdk/integrations/openai.py

+279
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
from sentry_sdk import consts
2+
from sentry_sdk._types import TYPE_CHECKING
3+
4+
if TYPE_CHECKING:
5+
from typing import Any, Iterable, List, Optional, Callable, Iterator
6+
from sentry_sdk.tracing import Span
7+
8+
import sentry_sdk
9+
from sentry_sdk._functools import wraps
10+
from sentry_sdk.hub import Hub, _should_send_default_pii
11+
from sentry_sdk.integrations import DidNotEnable, Integration
12+
from sentry_sdk.utils import logger, capture_internal_exceptions, event_from_exception
13+
14+
try:
15+
from openai.resources.chat.completions import Completions
16+
from openai.resources import Embeddings
17+
18+
if TYPE_CHECKING:
19+
from openai.types.chat import ChatCompletionMessageParam, ChatCompletionChunk
20+
except ImportError:
21+
raise DidNotEnable("OpenAI not installed")
22+
23+
try:
24+
import tiktoken # type: ignore
25+
26+
enc = tiktoken.get_encoding("cl100k_base")
27+
28+
def count_tokens(s):
29+
# type: (str) -> int
30+
return len(enc.encode_ordinary(s))
31+
32+
logger.debug("[OpenAI] using tiktoken to count tokens")
33+
except ImportError:
34+
logger.info(
35+
"The Sentry Python SDK requires 'tiktoken' in order to measure token usage from some OpenAI APIs"
36+
"Please install 'tiktoken' if you aren't receiving token usage in Sentry."
37+
"See https://docs.sentry.io/platforms/python/integrations/openai/ for more information."
38+
)
39+
40+
def count_tokens(s):
41+
# type: (str) -> int
42+
return 0
43+
44+
45+
COMPLETION_TOKENS_USED = "ai.completion_tоkens.used"
46+
PROMPT_TOKENS_USED = "ai.prompt_tоkens.used"
47+
TOTAL_TOKENS_USED = "ai.total_tоkens.used"
48+
49+
50+
class OpenAIIntegration(Integration):
51+
identifier = "openai"
52+
53+
def __init__(self, include_prompts=True):
54+
# type: (OpenAIIntegration, bool) -> None
55+
self.include_prompts = include_prompts
56+
57+
@staticmethod
58+
def setup_once():
59+
# type: () -> None
60+
Completions.create = _wrap_chat_completion_create(Completions.create)
61+
Embeddings.create = _wrap_embeddings_create(Embeddings.create)
62+
63+
64+
def _capture_exception(hub, exc):
65+
# type: (Hub, Any) -> None
66+
67+
if hub.client is not None:
68+
event, hint = event_from_exception(
69+
exc,
70+
client_options=hub.client.options,
71+
mechanism={"type": "openai", "handled": False},
72+
)
73+
hub.capture_event(event, hint=hint)
74+
75+
76+
def _calculate_chat_completion_usage(
77+
messages, response, span, streaming_message_responses=None
78+
):
79+
# type: (Iterable[ChatCompletionMessageParam], Any, Span, Optional[List[str]]) -> None
80+
completion_tokens = 0
81+
prompt_tokens = 0
82+
total_tokens = 0
83+
if hasattr(response, "usage"):
84+
if hasattr(response.usage, "completion_tokens") and isinstance(
85+
response.usage.completion_tokens, int
86+
):
87+
completion_tokens = response.usage.completion_tokens
88+
if hasattr(response.usage, "prompt_tokens") and isinstance(
89+
response.usage.prompt_tokens, int
90+
):
91+
prompt_tokens = response.usage.prompt_tokens
92+
if hasattr(response.usage, "total_tokens") and isinstance(
93+
response.usage.total_tokens, int
94+
):
95+
total_tokens = response.usage.total_tokens
96+
97+
if prompt_tokens == 0:
98+
for message in messages:
99+
if "content" in message:
100+
prompt_tokens += count_tokens(message["content"])
101+
102+
if completion_tokens == 0:
103+
if streaming_message_responses is not None:
104+
for message in streaming_message_responses:
105+
completion_tokens += count_tokens(message)
106+
elif hasattr(response, "choices"):
107+
for choice in response.choices:
108+
if hasattr(choice, "message"):
109+
completion_tokens += count_tokens(choice.message)
110+
111+
if total_tokens == 0:
112+
total_tokens = prompt_tokens + completion_tokens
113+
114+
if completion_tokens != 0:
115+
span.set_data(COMPLETION_TOKENS_USED, completion_tokens)
116+
if prompt_tokens != 0:
117+
span.set_data(PROMPT_TOKENS_USED, prompt_tokens)
118+
if total_tokens != 0:
119+
span.set_data(TOTAL_TOKENS_USED, total_tokens)
120+
121+
122+
def _wrap_chat_completion_create(f):
123+
# type: (Callable[..., Any]) -> Callable[..., Any]
124+
@wraps(f)
125+
def new_chat_completion(*args, **kwargs):
126+
# type: (*Any, **Any) -> Any
127+
hub = Hub.current
128+
if not hub:
129+
return f(*args, **kwargs)
130+
131+
integration = hub.get_integration(OpenAIIntegration) # type: OpenAIIntegration
132+
if not integration:
133+
return f(*args, **kwargs)
134+
135+
if "messages" not in kwargs:
136+
# invalid call (in all versions of openai), let it return error
137+
return f(*args, **kwargs)
138+
139+
try:
140+
iter(kwargs["messages"])
141+
except TypeError:
142+
# invalid call (in all versions), messages must be iterable
143+
return f(*args, **kwargs)
144+
145+
kwargs["messages"] = list(kwargs["messages"])
146+
messages = kwargs["messages"]
147+
model = kwargs.get("model")
148+
streaming = kwargs.get("stream")
149+
150+
span = sentry_sdk.start_span(
151+
op=consts.OP.OPENAI_CHAT_COMPLETIONS_CREATE, description="Chat Completion"
152+
)
153+
span.__enter__()
154+
try:
155+
res = f(*args, **kwargs)
156+
except Exception as e:
157+
_capture_exception(Hub.current, e)
158+
span.__exit__(None, None, None)
159+
raise e from None
160+
161+
with capture_internal_exceptions():
162+
if _should_send_default_pii() and integration.include_prompts:
163+
span.set_data("ai.input_messages", messages)
164+
span.set_data("ai.model_id", model)
165+
span.set_data("ai.streaming", streaming)
166+
167+
if hasattr(res, "choices"):
168+
if _should_send_default_pii() and integration.include_prompts:
169+
span.set_data(
170+
"ai.responses", list(map(lambda x: x.message, res.choices))
171+
)
172+
_calculate_chat_completion_usage(messages, res, span)
173+
span.__exit__(None, None, None)
174+
elif hasattr(res, "_iterator"):
175+
data_buf: list[list[str]] = [] # one for each choice
176+
177+
old_iterator = res._iterator # type: Iterator[ChatCompletionChunk]
178+
179+
def new_iterator():
180+
# type: () -> Iterator[ChatCompletionChunk]
181+
with capture_internal_exceptions():
182+
for x in old_iterator:
183+
if hasattr(x, "choices"):
184+
choice_index = 0
185+
for choice in x.choices:
186+
if hasattr(choice, "delta") and hasattr(
187+
choice.delta, "content"
188+
):
189+
content = choice.delta.content
190+
if len(data_buf) <= choice_index:
191+
data_buf.append([])
192+
data_buf[choice_index].append(content or "")
193+
choice_index += 1
194+
yield x
195+
if len(data_buf) > 0:
196+
all_responses = list(
197+
map(lambda chunk: "".join(chunk), data_buf)
198+
)
199+
if (
200+
_should_send_default_pii()
201+
and integration.include_prompts
202+
):
203+
span.set_data("ai.responses", all_responses)
204+
_calculate_chat_completion_usage(
205+
messages, res, span, all_responses
206+
)
207+
span.__exit__(None, None, None)
208+
209+
res._iterator = new_iterator()
210+
else:
211+
span.set_data("unknown_response", True)
212+
span.__exit__(None, None, None)
213+
return res
214+
215+
return new_chat_completion
216+
217+
218+
def _wrap_embeddings_create(f):
219+
# type: (Callable[..., Any]) -> Callable[..., Any]
220+
221+
@wraps(f)
222+
def new_embeddings_create(*args, **kwargs):
223+
# type: (*Any, **Any) -> Any
224+
225+
hub = Hub.current
226+
if not hub:
227+
return f(*args, **kwargs)
228+
229+
integration = hub.get_integration(OpenAIIntegration) # type: OpenAIIntegration
230+
if not integration:
231+
return f(*args, **kwargs)
232+
233+
with sentry_sdk.start_span(
234+
op=consts.OP.OPENAI_EMBEDDINGS_CREATE,
235+
description="OpenAI Embedding Creation",
236+
) as span:
237+
if "input" in kwargs and (
238+
_should_send_default_pii() and integration.include_prompts
239+
):
240+
if isinstance(kwargs["input"], str):
241+
span.set_data("ai.input_messages", [kwargs["input"]])
242+
elif (
243+
isinstance(kwargs["input"], list)
244+
and len(kwargs["input"]) > 0
245+
and isinstance(kwargs["input"][0], str)
246+
):
247+
span.set_data("ai.input_messages", kwargs["input"])
248+
if "model" in kwargs:
249+
span.set_data("ai.model_id", kwargs["model"])
250+
try:
251+
response = f(*args, **kwargs)
252+
except Exception as e:
253+
_capture_exception(Hub.current, e)
254+
raise e from None
255+
256+
prompt_tokens = 0
257+
total_tokens = 0
258+
if hasattr(response, "usage"):
259+
if hasattr(response.usage, "prompt_tokens") and isinstance(
260+
response.usage.prompt_tokens, int
261+
):
262+
prompt_tokens = response.usage.prompt_tokens
263+
if hasattr(response.usage, "total_tokens") and isinstance(
264+
response.usage.total_tokens, int
265+
):
266+
total_tokens = response.usage.total_tokens
267+
268+
if prompt_tokens == 0:
269+
prompt_tokens = count_tokens(kwargs["input"] or "")
270+
271+
if total_tokens == 0:
272+
total_tokens = prompt_tokens
273+
274+
span.set_data(PROMPT_TOKENS_USED, prompt_tokens)
275+
span.set_data(TOTAL_TOKENS_USED, total_tokens)
276+
277+
return response
278+
279+
return new_embeddings_create

setup.py

+1
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ def get_file_text(file_name):
6060
"httpx": ["httpx>=0.16.0"],
6161
"huey": ["huey>=2"],
6262
"loguru": ["loguru>=0.5"],
63+
"openai": ["openai>=1.0.0", "tiktoken>=0.3.0"],
6364
"opentelemetry": ["opentelemetry-distro>=0.35b0"],
6465
"opentelemetry-experimental": [
6566
"opentelemetry-distro~=0.40b0",

tests/integrations/openai/__init__.py

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

0 commit comments

Comments
 (0)