-
Notifications
You must be signed in to change notification settings - Fork 601
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
OpenAI: instrument embeddings and chat completions #2398
Changes from all commits
0c348d0
0d9e3fd
9fb700c
11bf4d4
3a9b12a
c372d90
543e92c
90e5d97
cc5b2cc
8ec46d9
9be9588
53864e5
6854a8b
adf18cc
2c352b8
76cb29d
42adee3
477053f
cadb30e
118dbda
da7692d
3146677
c179385
e1a70c9
4cbcc61
bf0eecf
00e0453
b6bd502
b84945c
b57e021
d0386d6
be65cc5
6377fcf
8ffcd47
004dcd3
36d9e83
9a75463
2be5e44
d083888
208b971
6136eab
b2b166d
d40461a
d627f15
e23c734
5ffbf9e
4c60bae
98c2b29
2451bdf
8a10359
cadfc35
5fed88f
8cb0072
fd1401c
a8687b7
3dac7e0
585327d
f15925d
8ac6b0f
c2c9959
b486d23
9985865
7b1484d
f79a2f6
55dd343
7273d1f
c83a892
36d6cab
4e26f74
85f7d51
22968ea
90b7ce9
0359ae1
d69d404
cdd42f6
a8092e0
d43b1a6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,37 @@ | ||
# This file is distributed under New Relic's license terms. | ||
# See https://github.com/newrelic/newrelic-ruby-agent/blob/main/LICENSE for complete details. | ||
# frozen_string_literal: true | ||
|
||
require_relative 'ruby_openai/instrumentation' | ||
require_relative 'ruby_openai/chain' | ||
require_relative 'ruby_openai/prepend' | ||
|
||
DependencyDetection.defer do | ||
named :'ruby_openai' | ||
|
||
OPENAI_VERSION = Gem::Version.new(OpenAI::VERSION) if defined?(OpenAI) | ||
|
||
depends_on do | ||
# add a config check for ai_monitoring.enabled | ||
# maybe add DT check here eventually? | ||
defined?(OpenAI) && defined?(OpenAI::Client) && | ||
OPENAI_VERSION >= Gem::Version.new('3.4.0') | ||
end | ||
|
||
executes do | ||
if use_prepend? | ||
if OPENAI_VERSION >= Gem::Version.new('5.0.0') | ||
prepend_instrument OpenAI::Client, | ||
NewRelic::Agent::Instrumentation::OpenAI::Prepend, | ||
NewRelic::Agent::Instrumentation::OpenAI::VENDOR | ||
else | ||
prepend_instrument OpenAI::Client.singleton_class, | ||
NewRelic::Agent::Instrumentation::OpenAI::Prepend, | ||
NewRelic::Agent::Instrumentation::OpenAI::VENDOR | ||
end | ||
else | ||
chain_instrument NewRelic::Agent::Instrumentation::OpenAI::Chain, | ||
NewRelic::Agent::Instrumentation::OpenAI::VENDOR | ||
end | ||
end | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,36 @@ | ||
# This file is distributed under New Relic's license terms. | ||
# See https://github.com/newrelic/newrelic-ruby-agent/blob/main/LICENSE for complete details. | ||
# frozen_string_literal: true | ||
|
||
module NewRelic::Agent::Instrumentation | ||
module OpenAI::Chain | ||
def self.instrument! | ||
::OpenAI::Client.class_eval do | ||
include NewRelic::Agent::Instrumentation::OpenAI | ||
|
||
alias_method(:json_post_without_new_relic, :json_post) | ||
|
||
# In versions 4.0.0+ json_post is an instance method | ||
# defined in the OpenAI::HTTP module, included by the | ||
# OpenAI::Client class | ||
def json_post(**kwargs) | ||
json_post_with_new_relic(**kwargs) do | ||
json_post_without_new_relic(**kwargs) | ||
end | ||
end | ||
|
||
# In versions below 4.0.0 json_post is a class method | ||
# on OpenAI::Client | ||
class << self | ||
alias_method(:json_post_without_new_relic, :json_post) | ||
|
||
def json_post(**kwargs) | ||
json_post_with_new_relic(**kwargs) do | ||
json_post_without_new_relic(**kwargs) | ||
end | ||
end | ||
end | ||
end | ||
end | ||
end | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,181 @@ | ||
# This file is distributed under New Relic's license terms. | ||
# See https://github.com/newrelic/newrelic-ruby-agent/blob/main/LICENSE for complete details. | ||
# frozen_string_literal: true | ||
|
||
module NewRelic::Agent::Instrumentation | ||
module OpenAI | ||
VENDOR = 'openAI' # AIM expects this capitalization style for the UI | ||
INSTRUMENTATION_NAME = NewRelic::Agent.base_name(name) | ||
EMBEDDINGS_PATH = '/embeddings' | ||
CHAT_COMPLETIONS_PATH = '/chat/completions' | ||
EMBEDDINGS_SEGMENT_NAME = 'Llm/embedding/OpenAI/embeddings' | ||
CHAT_COMPLETIONS_SEGMENT_NAME = 'Llm/completion/OpenAI/chat' | ||
|
||
def json_post_with_new_relic(path:, parameters:) | ||
return yield unless path == EMBEDDINGS_PATH || path == CHAT_COMPLETIONS_PATH | ||
|
||
NewRelic::Agent.record_instrumentation_invocation(INSTRUMENTATION_NAME) | ||
NewRelic::Agent::Llm::LlmEvent.set_llm_agent_attribute_on_transaction | ||
|
||
if path == EMBEDDINGS_PATH | ||
embeddings_instrumentation(parameters) { yield } | ||
elsif path == CHAT_COMPLETIONS_PATH | ||
chat_completions_instrumentation(parameters) { yield } | ||
end | ||
end | ||
|
||
private | ||
|
||
def embeddings_instrumentation(parameters) | ||
segment = NewRelic::Agent::Tracer.start_segment(name: EMBEDDINGS_SEGMENT_NAME) | ||
record_openai_metric | ||
event = create_embeddings_event(parameters) | ||
segment.llm_event = event | ||
begin | ||
response = NewRelic::Agent::Tracer.capture_segment_error(segment) { yield } | ||
# TODO: Remove !response.include?('error) when we drop support for versions below 4.0.0 | ||
add_embeddings_response_params(response, event) if response && !response.include?('error') | ||
|
||
response | ||
ensure | ||
finish(segment, event) | ||
end | ||
end | ||
|
||
def chat_completions_instrumentation(parameters) | ||
segment = NewRelic::Agent::Tracer.start_segment(name: CHAT_COMPLETIONS_SEGMENT_NAME) | ||
record_openai_metric | ||
event = create_chat_completion_summary(parameters) | ||
segment.llm_event = event | ||
messages = create_chat_completion_messages(parameters, event.id) | ||
|
||
begin | ||
response = NewRelic::Agent::Tracer.capture_segment_error(segment) { yield } | ||
# TODO: Remove !response.include?('error) when we drop support for versions below 4.0.0 | ||
if response && !response.include?('error') | ||
add_chat_completion_response_params(parameters, response, event) | ||
messages = update_chat_completion_messages(messages, response, event) | ||
end | ||
|
||
response | ||
ensure | ||
finish(segment, event) | ||
messages&.each { |m| m.record } | ||
end | ||
end | ||
|
||
def create_chat_completion_summary(parameters) | ||
NewRelic::Agent::Llm::ChatCompletionSummary.new( | ||
# TODO: POST-GA: Add metadata from add_custom_attributes if prefixed with 'llm.', except conversation_id | ||
vendor: VENDOR, | ||
conversation_id: conversation_id, | ||
api_key_last_four_digits: parse_api_key, | ||
request_max_tokens: parameters[:max_tokens] || parameters['max_tokens'], | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What leads the hash to have symbols or strings for keys, and are there ever both types of key present in the same hash? It might be handy to have a helper method like this: request_max_tokens: parameter_value(parameters, :max_tokens)
...
def parameter_value(parameters, value)
parameters[value] || parameters[value.to_s]
end and if we can rely on the hash keys being all symbols or all strings, we could further enhance the helper to memoize which key type is involved. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Both Strings and Symbols are accepted as keys, so it's all up to the user on which they use. And yes—both types can be mix and matched in the same request. Side note: We did performance testing of |
||
request_model: parameters[:model] || parameters['model'], | ||
temperature: parameters[:temperature] || parameters['temperature'] | ||
) | ||
end | ||
|
||
def create_embeddings_event(parameters) | ||
NewRelic::Agent::Llm::Embedding.new( | ||
# TODO: POST-GA: Add metadata from add_custom_attributes if prefixed with 'llm.', except conversation_id | ||
vendor: VENDOR, | ||
input: parameters[:input] || parameters['input'], | ||
api_key_last_four_digits: parse_api_key, | ||
request_model: parameters[:model] || parameters['model'] | ||
) | ||
end | ||
|
||
def add_chat_completion_response_params(parameters, response, event) | ||
event.response_number_of_messages = (parameters[:messages] || parameters['messages']).size + response['choices'].size | ||
# The response hash always returns keys as strings, so we don't need to run an || check here | ||
event.response_model = response['model'] | ||
event.response_usage_total_tokens = response['usage']['total_tokens'] | ||
event.response_usage_prompt_tokens = response['usage']['prompt_tokens'] | ||
event.response_usage_completion_tokens = response['usage']['completion_tokens'] | ||
event.response_choices_finish_reason = response['choices'][0]['finish_reason'] | ||
end | ||
|
||
def add_embeddings_response_params(response, event) | ||
event.response_model = response['model'] | ||
event.response_usage_total_tokens = response['usage']['total_tokens'] | ||
event.response_usage_prompt_tokens = response['usage']['prompt_tokens'] | ||
end | ||
|
||
def parse_api_key | ||
'sk-' + headers['Authorization'][-4..-1] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmm... it looks like the ability to use |
||
end | ||
|
||
# The customer must call add_custom_attributes with llm.conversation_id | ||
# before the transaction starts. Otherwise, the conversation_id will be nil. | ||
def conversation_id | ||
return @nr_conversation_id if @nr_conversation_id | ||
|
||
@nr_conversation_id ||= NewRelic::Agent::Tracer.current_transaction.attributes.custom_attributes[NewRelic::Agent::Llm::LlmEvent::CUSTOM_ATTRIBUTE_CONVERSATION_ID] | ||
end | ||
|
||
def create_chat_completion_messages(parameters, summary_id) | ||
(parameters[:messages] || parameters['messages']).map.with_index do |message, index| | ||
NewRelic::Agent::Llm::ChatCompletionMessage.new( | ||
content: message[:content] || message['content'], | ||
role: message[:role] || message['role'], | ||
sequence: index, | ||
completion_id: summary_id, | ||
vendor: VENDOR, | ||
is_response: true | ||
) | ||
end | ||
end | ||
|
||
def create_chat_completion_response_messages(response, sequence_origin, summary_id) | ||
response['choices'].map.with_index(sequence_origin) do |choice, index| | ||
NewRelic::Agent::Llm::ChatCompletionMessage.new( | ||
content: choice['message']['content'], | ||
role: choice['message']['role'], | ||
sequence: index, | ||
completion_id: summary_id, | ||
vendor: VENDOR, | ||
is_response: true | ||
) | ||
end | ||
end | ||
|
||
def update_chat_completion_messages(messages, response, summary) | ||
messages += create_chat_completion_response_messages(response, messages.size, summary.id) | ||
response_id = response['id'] || NewRelic::Agent::GuidGenerator.generate_guid | ||
|
||
messages.each do |message| | ||
# TODO: POST-GA: Add metadata from add_custom_attributes if prefixed with 'llm.', except conversation_id | ||
message.id = "#{response_id}-#{message.sequence}" | ||
message.conversation_id = conversation_id | ||
message.request_id = summary.request_id | ||
message.response_model = response['model'] | ||
end | ||
end | ||
|
||
def record_openai_metric | ||
NewRelic::Agent.record_metric(nr_supportability_metric, 0.0) | ||
end | ||
|
||
def segment_noticed_error?(segment) | ||
segment&.instance_variable_get(:@noticed_error) | ||
end | ||
|
||
def nr_supportability_metric | ||
@nr_supportability_metric ||= "Supportability/Ruby/ML/OpenAI/#{::OpenAI::VERSION}" | ||
end | ||
|
||
def finish(segment, event) | ||
segment&.finish | ||
|
||
return unless event | ||
|
||
if segment | ||
event.error = true if segment_noticed_error?(segment) | ||
event.duration = segment.duration | ||
end | ||
|
||
event.record | ||
end | ||
end | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,20 @@ | ||
# This file is distributed under New Relic's license terms. | ||
# See https://github.com/newrelic/newrelic-ruby-agent/blob/main/LICENSE for complete details. | ||
# frozen_string_literal: true | ||
|
||
module NewRelic::Agent::Instrumentation | ||
module OpenAI::Prepend | ||
include NewRelic::Agent::Instrumentation::OpenAI | ||
|
||
# In versions 4.0.0+ json_post is an instance method defined in the | ||
# OpenAI::HTTP module, included by the OpenAI::Client class. | ||
# | ||
# In versions below 4.0.0 json_post is a class method on OpenAI::Client. | ||
# | ||
# Dependency detection will apply the instrumentation to the correct scope, | ||
# so we don't need to change the code here. | ||
def json_post(**kwargs) | ||
json_post_with_new_relic(**kwargs) { super } | ||
end | ||
end | ||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cool to see the support for older versions.