Skip to content

Commit 6e3bcc8

Browse files
openai: adds examples and flattens tests
This adds an examples directory which include two main features this instrumentation supports: chat and embeddings It also flattens test recordings and infrastructure around unit tests. Notably, instrumentation code doesn't change depending on if azure is used or not. Unit testing only openai (platform) simplifies tests and reduces infrastructure significantly. Signed-off-by: Adrian Cole <[email protected]>
1 parent 0eef232 commit 6e3bcc8

File tree

97 files changed

+1337
-15674
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

97 files changed

+1337
-15674
lines changed

instrumentation/elastic-opentelemetry-instrumentation-openai/README.md

Lines changed: 36 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -22,32 +22,31 @@ pip install elastic-opentelemetry-instrumentation-openai
2222

2323
This instrumentation supports *zero-code* / *autoinstrumentation*:
2424

25-
```
26-
opentelemetry-instrument python use_openai.py
25+
You can see telemetry from this package if you have an OpenTelemetry collector started, for example
26+
as documented in the root [examples](../../examples/) folder.
2727

28-
# You can record more information about prompts as log events by enabling content capture.
29-
OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true opentelemetry-instrument python use_openai.py
28+
Set up a virtual environment with this package, the dependencies it requires
29+
and `dotenv` (a portable way to load environment variables).
30+
```
31+
python3 -m venv .venv
32+
source .venv/bin/activate
33+
pip install -r test-requirements.txt
34+
pip install python-dotenv[cli]
3035
```
3136

32-
Or manual instrumentation:
33-
34-
```python
35-
import openai
36-
from opentelemetry.instrumentation.openai import OpenAIInstrumentor
37-
38-
OpenAIInstrumentor().instrument()
39-
40-
# assumes at least the OPENAI_API_KEY environment variable set
41-
client = openai.Client()
37+
Run the script with telemetry setup to use the instrumentation. [ollama.env](ollama.env)
38+
includes variables to point to Ollama instead of OpenAI, which allows you to
39+
run examples without a cloud account:
4240

43-
messages = [
44-
{
45-
"role": "user",
46-
"content": "Answer in up to 3 words: Which ocean contains the canarian islands?",
47-
}
48-
]
41+
```
42+
dotenv -f ollama.env run -- \
43+
opentelemetry-instrument python examples/chat.py
44+
```
4945

50-
chat_completion = client.chat.completions.create(model="gpt-4o-mini", messages=messages)
46+
You can record more information about prompts as log events by enabling content capture.
47+
```
48+
OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true dotenv -f ollama.env run -- \
49+
opentelemetry-instrument python examples/chat.py
5150
```
5251

5352
### Instrumentation specific environment variable configuration
@@ -110,20 +109,22 @@ response without querying the LLM.
110109

111110
### Azure OpenAI Environment Variables
112111

113-
Azure is different from OpenAI primarily in that a URL has an implicit model. This means it ignores
114-
the model parameter set by the OpenAI SDK. The implication is that one endpoint cannot serve both
115-
chat and embeddings at the same time. Hence, we need separate environment variables for chat and
116-
embeddings. In either case, the `DEPLOYMENT_URL` is the "Endpoint Target URI" and the `API_KEY` is
117-
the `Endpoint Key` for a corresponding deployment in https://oai.azure.com/resource/deployments
118-
119-
* `AZURE_CHAT_COMPLETIONS_DEPLOYMENT_URL`
120-
* It should look like https://endpoint.com/openai/deployments/my-deployment/chat/completions?api-version=2023-05-15
121-
* `AZURE_CHAT_COMPLETIONS_API_KEY`
122-
* It should be in hex like `abc01...` and possibly the same as `AZURE_EMBEDDINGS_API_KEY`
123-
* `AZURE_EMBEDDINGS_DEPLOYMENT_URL`
124-
* It should look like https://endpoint.com/openai/deployments/my-deployment/embeddings?api-version=2023-05-15
125-
* `AZURE_EMBEDDINGS_API_KEY`
126-
* It should be in hex like `abc01...` and possibly the same as `AZURE_CHAT_COMPLETIONS_API_KEY`
112+
The `AzureOpenAI` client extends `OpenAI` with parameters specific to the Azure OpenAI Service.
113+
114+
* `AZURE_OPENAI_ENDPOINT` - "Azure OpenAI Endpoint" in https://oai.azure.com/resource/overview
115+
* It should look like `https://<your-resource-name>.openai.azure.com/`
116+
* `AZURE_OPENAI_API_KEY` - "API key 1 (or 2)" in https://oai.azure.com/resource/overview
117+
* It should look be a hex string like `abc01...`
118+
* `OPENAI_API_VERSION` = "Inference version" from https://learn.microsoft.com/en-us/azure/ai-services/openai/api-version-deprecation
119+
* It should look like `2024-10-01-preview`
120+
* `TEST_CHAT_MODEL` = "Name" from https://oai.azure.com/resource/deployments that deployed a model
121+
that supports tool calling, such as "gpt-4o-mini".
122+
* `TEST_EMBEDDINGS_MODEL` = "Name" from https://oai.azure.com/resource/deployments that deployed a
123+
model that supports embeddings, such as "text-embedding-3-small".
124+
125+
Note: The model parameter of a chat completion or embeddings request is substituted for an identical
126+
deployment name. As deployment names are arbitrary they may have no correlation with a real model
127+
like `gpt-4o`
127128

128129
## License
129130

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import os
2+
3+
import openai
4+
5+
CHAT_MODEL = os.environ.get("TEST_CHAT_MODEL", "gpt-4o-mini")
6+
7+
8+
def main():
9+
client = openai.Client()
10+
11+
messages = [
12+
{
13+
"role": "user",
14+
"content": "Answer in up to 3 words: Which ocean contains Bouvet Island?",
15+
}
16+
]
17+
18+
chat_completion = client.chat.completions.create(model=CHAT_MODEL, messages=messages)
19+
print(chat_completion.choices[0].message.content)
20+
21+
22+
if __name__ == "__main__":
23+
main()
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import os
2+
3+
import numpy as np
4+
import openai
5+
6+
EMBEDDINGS_MODEL = os.environ.get("TEST_EMBEDDINGS_MODEL", "text-embedding-3-small")
7+
8+
9+
def main():
10+
client = openai.Client()
11+
12+
products = [
13+
"Search: Ingest your data, and explore Elastic's machine learning and retrieval augmented generation (RAG) capabilities."
14+
"Observability: Unify your logs, metrics, traces, and profiling at scale in a single platform.",
15+
"Security: Protect, investigate, and respond to cyber threats with AI-driven security analytics."
16+
"Elasticsearch: Distributed, RESTful search and analytics.",
17+
"Kibana: Visualize your data. Navigate the Stack.",
18+
"Beats: Collect, parse, and ship in a lightweight fashion.",
19+
"Connectors: Connect popular databases, file systems, collaboration tools, and more.",
20+
"Logstash: Ingest, transform, enrich, and output.",
21+
]
22+
23+
# Generate embeddings for each product. Keep them in an array instead of a vector DB.
24+
product_embeddings = []
25+
for product in products:
26+
product_embeddings.append(create_embedding(client, product))
27+
28+
query_embedding = create_embedding(client, "What can help me connect to a database?")
29+
30+
# Calculate cosine similarity between the query and document embeddings
31+
similarities = []
32+
for product_embedding in product_embeddings:
33+
similarity = np.dot(query_embedding, product_embedding) / (
34+
np.linalg.norm(query_embedding) * np.linalg.norm(product_embedding)
35+
)
36+
similarities.append(similarity)
37+
38+
# Get the index of the most similar document
39+
most_similar_index = np.argmax(similarities)
40+
41+
print(products[most_similar_index])
42+
43+
44+
def create_embedding(client, text):
45+
return client.embeddings.create(input=[text], model=EMBEDDINGS_MODEL, encoding_format="float").data[0].embedding
46+
47+
48+
if __name__ == "__main__":
49+
main()
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# Env to run the integration tests against a local Ollama.
2+
OPENAI_BASE_URL=http://127.0.0.1:11434/v1
3+
OPENAI_API_KEY=notused
4+
5+
# These models may be substituted in the future with inexpensive to run, newer
6+
# variants.
7+
TEST_CHAT_MODEL=qwen2.5:0.5b
8+
TEST_EMBEDDINGS_MODEL=all-minilm:33m
9+
10+
OTEL_SERVICE_NAME=elastic-opentelemetry-instrumentation-openai
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import os
2+
from dataclasses import dataclass
3+
4+
import openai
5+
from opentelemetry.instrumentation.openai import OpenAIInstrumentor
6+
from opentelemetry.metrics import Histogram
7+
from vcr.unittest import VCRMixin
8+
9+
# Use the same model for tools as for chat completion
10+
OPENAI_API_KEY = "test_openai_api_key"
11+
OPENAI_ORG_ID = "test_openai_org_key"
12+
OPENAI_PROJECT_ID = "test_openai_project_id"
13+
14+
LOCAL_MODEL = "qwen2.5:0.5b"
15+
16+
17+
@dataclass
18+
class OpenAIEnvironment:
19+
# TODO: add system
20+
operation_name: str = "chat"
21+
model: str = "gpt-4o-mini"
22+
response_model: str = "gpt-4o-mini-2024-07-18"
23+
server_address: str = "api.openai.com"
24+
server_port: int = 443
25+
26+
27+
class OpenaiMixin(VCRMixin):
28+
def _get_vcr_kwargs(self, **kwargs):
29+
"""
30+
This scrubs sensitive data and gunzips bodies when in recording mode.
31+
32+
Without this, you would leak cookies and auth tokens in the cassettes.
33+
Also, depending on the request, some responses would be binary encoded
34+
while others plain json. This ensures all bodies are human-readable.
35+
"""
36+
return {
37+
"decode_compressed_response": True,
38+
"filter_headers": [
39+
("authorization", "Bearer " + OPENAI_API_KEY),
40+
("openai-organization", OPENAI_ORG_ID),
41+
("openai-project", OPENAI_PROJECT_ID),
42+
("cookie", None),
43+
],
44+
"before_record_response": self.scrub_response_headers,
45+
}
46+
47+
@staticmethod
48+
def scrub_response_headers(response):
49+
"""
50+
This scrubs sensitive response headers. Note they are case-sensitive!
51+
"""
52+
response["headers"]["openai-organization"] = OPENAI_ORG_ID
53+
response["headers"]["Set-Cookie"] = "test_set_cookie"
54+
return response
55+
56+
@classmethod
57+
def setup_client(cls):
58+
# Control the arguments
59+
return openai.Client(
60+
api_key=os.getenv("OPENAI_API_KEY", OPENAI_API_KEY),
61+
organization=os.getenv("OPENAI_ORG_ID", OPENAI_ORG_ID),
62+
project=os.getenv("OPENAI_PROJECT_ID", OPENAI_PROJECT_ID),
63+
max_retries=1,
64+
)
65+
66+
@classmethod
67+
def setup_environment(cls):
68+
return OpenAIEnvironment()
69+
70+
@classmethod
71+
def setUpClass(cls):
72+
cls.client = cls.setup_client()
73+
cls.openai_env = cls.setup_environment()
74+
75+
def setUp(self):
76+
super().setUp()
77+
OpenAIInstrumentor().instrument()
78+
79+
def tearDown(self):
80+
super().tearDown()
81+
OpenAIInstrumentor().uninstrument()
82+
83+
def assertOperationDurationMetric(self, metric: Histogram):
84+
self.assertEqual(metric.name, "gen_ai.client.operation.duration")
85+
self.assert_metric_expected(
86+
metric,
87+
[
88+
self.create_histogram_data_point(
89+
count=1,
90+
sum_data_point=0.006543334107846022,
91+
max_data_point=0.006543334107846022,
92+
min_data_point=0.006543334107846022,
93+
attributes={
94+
"gen_ai.operation.name": self.openai_env.operation_name,
95+
"gen_ai.request.model": self.openai_env.model,
96+
"gen_ai.response.model": self.openai_env.response_model,
97+
"gen_ai.system": "openai",
98+
"server.address": self.openai_env.server_address,
99+
"server.port": self.openai_env.server_port,
100+
},
101+
),
102+
],
103+
est_value_delta=0.2,
104+
)
105+
106+
def assertErrorOperationDurationMetric(self, metric: Histogram, attributes: dict, data_point: float = None):
107+
self.assertEqual(metric.name, "gen_ai.client.operation.duration")
108+
default_attributes = {
109+
"gen_ai.operation.name": self.openai_env.operation_name,
110+
"gen_ai.request.model": self.openai_env.model,
111+
"gen_ai.system": "openai",
112+
"error.type": "APIConnectionError",
113+
"server.address": "localhost",
114+
"server.port": 9999,
115+
}
116+
if data_point is None:
117+
data_point = 0.8643839359283447
118+
self.assert_metric_expected(
119+
metric,
120+
[
121+
self.create_histogram_data_point(
122+
count=1,
123+
sum_data_point=data_point,
124+
max_data_point=data_point,
125+
min_data_point=data_point,
126+
attributes={**default_attributes, **attributes},
127+
),
128+
],
129+
est_value_delta=0.5,
130+
)
131+
132+
def assertTokenUsageInputMetric(self, metric: Histogram, input_data_point=4):
133+
self.assertEqual(metric.name, "gen_ai.client.token.usage")
134+
self.assert_metric_expected(
135+
metric,
136+
[
137+
self.create_histogram_data_point(
138+
count=1,
139+
sum_data_point=input_data_point,
140+
max_data_point=input_data_point,
141+
min_data_point=input_data_point,
142+
attributes={
143+
"gen_ai.operation.name": self.openai_env.operation_name,
144+
"gen_ai.request.model": self.openai_env.model,
145+
"gen_ai.response.model": self.openai_env.response_model,
146+
"gen_ai.system": "openai",
147+
"server.address": self.openai_env.server_address,
148+
"server.port": self.openai_env.server_port,
149+
"gen_ai.token.type": "input",
150+
},
151+
),
152+
],
153+
)
154+
155+
def assertTokenUsageMetric(self, metric: Histogram, input_data_point=24, output_data_point=4):
156+
self.assertEqual(metric.name, "gen_ai.client.token.usage")
157+
self.assert_metric_expected(
158+
metric,
159+
[
160+
self.create_histogram_data_point(
161+
count=1,
162+
sum_data_point=input_data_point,
163+
max_data_point=input_data_point,
164+
min_data_point=input_data_point,
165+
attributes={
166+
"gen_ai.operation.name": self.openai_env.operation_name,
167+
"gen_ai.request.model": self.openai_env.model,
168+
"gen_ai.response.model": self.openai_env.response_model,
169+
"gen_ai.system": "openai",
170+
"server.address": self.openai_env.server_address,
171+
"server.port": self.openai_env.server_port,
172+
"gen_ai.token.type": "input",
173+
},
174+
),
175+
self.create_histogram_data_point(
176+
count=1,
177+
sum_data_point=output_data_point,
178+
max_data_point=output_data_point,
179+
min_data_point=output_data_point,
180+
attributes={
181+
"gen_ai.operation.name": self.openai_env.operation_name,
182+
"gen_ai.request.model": self.openai_env.model,
183+
"gen_ai.response.model": self.openai_env.response_model,
184+
"gen_ai.system": "openai",
185+
"server.address": self.openai_env.server_address,
186+
"server.port": self.openai_env.server_port,
187+
"gen_ai.token.type": "output",
188+
},
189+
),
190+
],
191+
)

0 commit comments

Comments
 (0)