-
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 29 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,33 @@ | ||
# 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' | ||
|
||
depends_on do | ||
defined?(OpenAI) && defined?(OpenAI::Client) | ||
# maybe add DT check here eventually? | ||
# possibly also a config check for ai.enabled | ||
end | ||
|
||
executes do | ||
NewRelic::Agent.logger.info('Installing ruby-openai instrumentation') | ||
|
||
if use_prepend? | ||
# instead of metaprogramming on OpenAI::Client, we could also use OpenAI::HTTP, | ||
# it's a module that's required by OpenAI::Client and contains the | ||
# json_post method we're instrumenting | ||
prepend_instrument OpenAI::Client, | ||
NewRelic::Agent::Instrumentation::OpenAI::Prepend, | ||
NewRelic::Agent::Instrumentation::OpenAI::VENDOR | ||
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,21 @@ | ||
# 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) | ||
|
||
def json_post(**kwargs) | ||
json_post_with_new_relic(**kwargs) do | ||
json_post_without_new_relic(**kwargs) | ||
end | ||
end | ||
end | ||
end | ||
end | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,180 @@ | ||
# 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' # or SUPPORTBILITY_NAME? or both? | ||
# TODO: should everything below be called embeddings if we renamed to chat completions? | ||
EMBEDDINGS_PATH = '/embeddings' | ||
CHAT_COMPLETIONS_PATH = '/chat/completions' | ||
SEGMENT_NAME_FORMAT = 'Llm/%s/OpenAI/create' | ||
|
||
# This method is defined in the OpenAI::HTTP module that is included | ||
# only in the OpenAI::Client class | ||
def json_post_with_new_relic(path:, parameters:) | ||
if path == EMBEDDINGS_PATH | ||
NewRelic::Agent.record_instrumentation_invocation(VENDOR) | ||
embedding_instrumentation(parameters) { yield } | ||
elsif path == CHAT_COMPLETIONS_PATH | ||
NewRelic::Agent.record_instrumentation_invocation(VENDOR) | ||
chat_completions_instrumentation(parameters) { yield } | ||
else | ||
yield | ||
end | ||
end | ||
|
||
private | ||
|
||
def embedding_instrumentation(parameters) | ||
segment = NewRelic::Agent::Tracer.start_segment(SEGMENT_NAME_FORMAT % 'embedding') | ||
record_openai_metric | ||
event = create_embedding_event(parameters) | ||
segment.embedding = event | ||
begin | ||
response = NewRelic::Agent::Tracer.capture_segment_error(segment) { yield } | ||
|
||
response | ||
ensure | ||
add_embedding_response_params(response, event) if response | ||
segment&.finish | ||
event&.error = true if segment_noticed_error?(segment) # need to test throwing an error | ||
event&.duration = segment&.duration | ||
event&.record # always record the event | ||
end | ||
end | ||
|
||
def chat_completions_instrumentation(parameters) | ||
# TODO: Do we have to start the segment outside the ensure block? | ||
segment = NewRelic::Agent::Tracer.start_segment(name: SEGMENT_NAME_FORMAT % 'completion') | ||
record_openai_metric | ||
event = create_chat_completion_summary(parameters) | ||
segment.chat_completion_summary = event | ||
messages = create_chat_completion_messages(parameters, summary_event_id) | ||
response = NewRelic::Agent::Tracer.capture_segment_error(segment) { yield } | ||
add_response_params(parameters, response, event) if response | ||
messages = update_chat_completion_messages(messages, response, event) if response | ||
|
||
response # return the response to the original caller | ||
ensure | ||
segment&.finish | ||
event&.error = true if segment_noticed_error?(segment) | ||
event&.duration = segment&.duration | ||
event&.record # always record the event | ||
messages&.each { |m| m&.record } | ||
end | ||
|
||
def create_chat_completion_summary(parameters) | ||
event = NewRelic::Agent::Llm::ChatCompletionSummary.new( | ||
# metadata => TBD, create API | ||
vendor: VENDOR, | ||
conversation_id: conversation_id, | ||
api_key_last_four_digits: parse_api_key, | ||
# TODO: Determine how to access parameters with keys as strings | ||
request_max_tokens: parameters[:max_tokens], | ||
request_model: parameters[:model], | ||
temperature: parameters[:temperature] | ||
) | ||
end | ||
|
||
def create_embedding_event(parameters) | ||
# TODO: Determine how to access parameters with keys as strings | ||
event = NewRelic::Agent::Llm::Embedding.new( | ||
# metadata => TBD, create API | ||
vendor: VENDOR, | ||
input: parameters[:input], | ||
api_key_last_four_digits: parse_api_key, | ||
request_model: parameters[:model] | ||
) | ||
end | ||
|
||
def add_response_params(parameters, response, event) | ||
event.response_number_of_messages = parameters[:messages].size + response['choices'].size | ||
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_embedding_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] | ||
end | ||
|
||
# The customer must call add_custom_attributes with 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['conversation_id'] | ||
end | ||
|
||
def create_chat_completion_messages(parameters, summary_id) | ||
# TODO: Determine how to access parameters with keys as strings | ||
parameters[:messages].map.with_index do |message, i| | ||
NewRelic::Agent::Llm::ChatCompletionMessage.new( | ||
content: message[:content] || message['content'], | ||
role: message[:role] || message['role'], | ||
sequence: i, | ||
completion_id: summary_id, | ||
vendor: VENDOR, | ||
is_response: false | ||
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. According to the spec, this shouldn't be set to false, it should be not included if it would be false
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. Great callout! Thank you! 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. This has been updated! cdd42f6 |
||
) | ||
end | ||
end | ||
|
||
def create_chat_completion_response_messages(response, sequence_origin, summary_id) | ||
response['choices'].map.with_index(sequence_origin) do |choice, i| | ||
NewRelic::Agent::Llm::ChatCompletionMessage.new( | ||
content: choice['message']['content'], | ||
role: choice['message']['role'], | ||
sequence: i, | ||
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| | ||
# metadata => TBD, create API | ||
message.id = "#{response_id}-#{message.sequence}" | ||
message.conversation_id = conversation_id | ||
message.request_id = summary.request_id | ||
message.response_model = response['model'] | ||
end | ||
end | ||
|
||
# Name is defined in Ruby 3.0+ | ||
# copied from rails code | ||
# Parameter keys might be symbols and might be strings | ||
# response body keys have always been strings | ||
def hash_with_indifferent_access_whatever | ||
if Symbol.method_defined?(:name) | ||
key.kind_of?(Symbol) ? key.name : key | ||
else | ||
key.kind_of?(Symbol) ? key.to_s : key | ||
end | ||
end | ||
|
||
# the preceding :: are necessary to access the OpenAI module defined in the gem rather than the current module | ||
# TODO: discover whether this metric name should be prepended with 'Supportability' | ||
def record_openai_metric | ||
NewRelic::Agent.record_metric("Ruby/ML/OpenAI/#{::OpenAI::VERSION}", 0.0) | ||
end | ||
|
||
def segment_noticed_error?(segment) | ||
segment&.instance_variable_get(:@noticed_error) | ||
end | ||
end | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
# 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 | ||
|
||
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.
Hmm... it looks like the ability to use
[-4..]
instead of[-4..-1]
wasn't introduced until Ruby v2.6.