This PR adds LLM instrumentation on langfuse for ai-editor feature ## Type of change New feature (non-breaking change which adds functionality) Needs langfuse account and env vars to be set ## How Has This Been Tested? I configured personal langfuse credentials and instrumented the app, traces can be seen in langfuse. each conversation is one session. <img width="1683" height="714" alt="image" src="https://github.com/user-attachments/assets/3fcba1c9-63cf-44b9-a355-fd6608691559" /> <img width="1446" height="172" alt="image" src="https://github.com/user-attachments/assets/dfa6e98f-4741-4e04-9a9e-078d1f01e97b" /> ## Checklist: - [x ] My code follows the style guidelines of this project - [ x] I have performed a self-review of my code - [ x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: aakashb95 <aakash@chatwoot.com> Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com> Co-authored-by: Pranav <pranav@chatwoot.com>
107 lines
4.0 KiB
Ruby
107 lines
4.0 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require 'opentelemetry_config'
|
|
|
|
module Integrations::LlmInstrumentation
|
|
# OpenTelemetry attribute names following GenAI semantic conventions
|
|
# https://opentelemetry.io/docs/specs/semconv/gen-ai/
|
|
ATTR_GEN_AI_PROVIDER = 'gen_ai.provider.name'
|
|
ATTR_GEN_AI_REQUEST_MODEL = 'gen_ai.request.model'
|
|
ATTR_GEN_AI_REQUEST_TEMPERATURE = 'gen_ai.request.temperature'
|
|
ATTR_GEN_AI_PROMPT_ROLE = 'gen_ai.prompt.%d.role'
|
|
ATTR_GEN_AI_PROMPT_CONTENT = 'gen_ai.prompt.%d.content'
|
|
ATTR_GEN_AI_COMPLETION_ROLE = 'gen_ai.completion.0.role'
|
|
ATTR_GEN_AI_COMPLETION_CONTENT = 'gen_ai.completion.0.content'
|
|
ATTR_GEN_AI_USAGE_INPUT_TOKENS = 'gen_ai.usage.input_tokens'
|
|
ATTR_GEN_AI_USAGE_OUTPUT_TOKENS = 'gen_ai.usage.output_tokens'
|
|
ATTR_GEN_AI_USAGE_TOTAL_TOKENS = 'gen_ai.usage.total_tokens'
|
|
ATTR_GEN_AI_RESPONSE_ERROR = 'gen_ai.response.error'
|
|
ATTR_GEN_AI_RESPONSE_ERROR_CODE = 'gen_ai.response.error_code'
|
|
|
|
# Langfuse-specific attributes
|
|
# https://langfuse.com/integrations/native/opentelemetry#property-mapping
|
|
ATTR_LANGFUSE_USER_ID = 'langfuse.user.id'
|
|
ATTR_LANGFUSE_SESSION_ID = 'langfuse.session.id'
|
|
ATTR_LANGFUSE_TAGS = 'langfuse.trace.tags'
|
|
|
|
def tracer
|
|
@tracer ||= OpentelemetryConfig.tracer
|
|
end
|
|
|
|
def instrument_llm_call(params)
|
|
return yield unless ChatwootApp.otel_enabled?
|
|
|
|
tracer.in_span(params[:span_name]) do |span|
|
|
setup_span_attributes(span, params)
|
|
result = yield
|
|
record_completion(span, result)
|
|
result
|
|
end
|
|
rescue StandardError => e
|
|
ChatwootExceptionTracker.new(e, account: params[:account]).capture_exception
|
|
yield
|
|
end
|
|
|
|
private
|
|
|
|
def setup_span_attributes(span, params)
|
|
set_request_attributes(span, params)
|
|
set_prompt_messages(span, params[:messages])
|
|
set_metadata_attributes(span, params)
|
|
end
|
|
|
|
def record_completion(span, result)
|
|
set_completion_attributes(span, result) if result.is_a?(Hash)
|
|
end
|
|
|
|
def set_request_attributes(span, params)
|
|
span.set_attribute(ATTR_GEN_AI_PROVIDER, 'openai')
|
|
span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, params[:model])
|
|
span.set_attribute(ATTR_GEN_AI_REQUEST_TEMPERATURE, params[:temperature]) if params[:temperature]
|
|
end
|
|
|
|
def set_prompt_messages(span, messages)
|
|
messages.each_with_index do |msg, idx|
|
|
span.set_attribute(format(ATTR_GEN_AI_PROMPT_ROLE, idx), msg['role'])
|
|
span.set_attribute(format(ATTR_GEN_AI_PROMPT_CONTENT, idx), msg['content'])
|
|
end
|
|
end
|
|
|
|
def set_metadata_attributes(span, params)
|
|
session_id = params[:conversation_id].present? ? "#{params[:account_id]}_#{params[:conversation_id]}" : nil
|
|
span.set_attribute(ATTR_LANGFUSE_USER_ID, params[:account_id].to_s) if params[:account_id]
|
|
span.set_attribute(ATTR_LANGFUSE_SESSION_ID, session_id) if session_id.present?
|
|
span.set_attribute(ATTR_LANGFUSE_TAGS, [params[:feature_name]].to_json)
|
|
end
|
|
|
|
def set_completion_attributes(span, result)
|
|
set_completion_message(span, result)
|
|
set_usage_metrics(span, result)
|
|
set_error_attributes(span, result)
|
|
end
|
|
|
|
def set_completion_message(span, result)
|
|
return if result[:message].blank?
|
|
|
|
span.set_attribute(ATTR_GEN_AI_COMPLETION_ROLE, 'assistant')
|
|
span.set_attribute(ATTR_GEN_AI_COMPLETION_CONTENT, result[:message])
|
|
end
|
|
|
|
def set_usage_metrics(span, result)
|
|
return if result[:usage].blank?
|
|
|
|
usage = result[:usage]
|
|
span.set_attribute(ATTR_GEN_AI_USAGE_INPUT_TOKENS, usage['prompt_tokens']) if usage['prompt_tokens']
|
|
span.set_attribute(ATTR_GEN_AI_USAGE_OUTPUT_TOKENS, usage['completion_tokens']) if usage['completion_tokens']
|
|
span.set_attribute(ATTR_GEN_AI_USAGE_TOTAL_TOKENS, usage['total_tokens']) if usage['total_tokens']
|
|
end
|
|
|
|
def set_error_attributes(span, result)
|
|
return if result[:error].blank?
|
|
|
|
span.set_attribute(ATTR_GEN_AI_RESPONSE_ERROR, result[:error].to_json)
|
|
span.set_attribute(ATTR_GEN_AI_RESPONSE_ERROR_CODE, result[:error_code]) if result[:error_code]
|
|
span.status = OpenTelemetry::Trace::Status.error("API Error: #{result[:error_code]}")
|
|
end
|
|
end
|