feat: Add support for Langfuse LLM Tracing via OTEL (#12905)

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>
This commit is contained in:
Aakash Bakhle
2025-11-22 06:01:45 +05:30
committed by GitHub
parent a8e9acfae9
commit e9c60aec04
13 changed files with 698 additions and 253 deletions

View File

@@ -42,4 +42,11 @@ module ChatwootApp
def self.advanced_search_allowed?
enterprise? && ENV.fetch('OPENSEARCH_URL', nil).present?
end
def self.otel_enabled?
otel_provider = InstallationConfig.find_by(name: 'OTEL_PROVIDER')&.value
secret_key = InstallationConfig.find_by(name: 'LANGFUSE_SECRET_KEY')&.value
otel_provider.present? && secret_key.present? && otel_provider == 'langfuse'
end
end

View File

@@ -0,0 +1,106 @@
# 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

View File

@@ -1,4 +1,6 @@
class Integrations::OpenaiBaseService
include Integrations::LlmInstrumentation
# gpt-4o-mini supports 128,000 tokens
# 1 token is approx 4 characters
# sticking with 120000 to be safe
@@ -87,21 +89,57 @@ class Integrations::OpenaiBaseService
end
def make_api_call(body)
headers = {
parsed_body = JSON.parse(body)
instrumentation_params = build_instrumentation_params(parsed_body)
instrument_llm_call(instrumentation_params) do
execute_api_request(body, parsed_body['messages'])
end
end
def build_instrumentation_params(parsed_body)
{
span_name: "llm.#{event_name}",
account_id: hook.account_id,
conversation_id: conversation&.display_id,
feature_name: event_name,
model: parsed_body['model'],
messages: parsed_body['messages'],
temperature: parsed_body['temperature']
}
end
def execute_api_request(body, messages)
Rails.logger.info("OpenAI API request: #{body}")
response = HTTParty.post(api_url, headers: api_headers, body: body)
Rails.logger.info("OpenAI API response: #{response.body}")
parse_api_response(response, messages)
end
def api_headers
{
'Content-Type' => 'application/json',
'Authorization' => "Bearer #{hook.settings['api_key']}"
}
end
Rails.logger.info("OpenAI API request: #{body}")
response = HTTParty.post(api_url, headers: headers, body: body)
Rails.logger.info("OpenAI API response: #{response.body}")
def parse_api_response(response, messages)
return build_error_response(response, messages) unless response.success?
return { error: response.parsed_response, error_code: response.code } unless response.success?
parsed_response = JSON.parse(response.body)
build_success_response(parsed_response, messages)
end
choices = JSON.parse(response.body)['choices']
def build_error_response(response, messages)
{ error: response.parsed_response, error_code: response.code, request_messages: messages }
end
return { message: choices.first['message']['content'] } if choices.present?
def build_success_response(parsed_response, messages)
choices = parsed_response['choices']
usage = parsed_response['usage']
message_content = choices.present? ? choices.first['message']['content'] : nil
{ message: nil }
{ message: message_content, usage: usage, request_messages: messages }
end
end

View File

@@ -0,0 +1,91 @@
require 'opentelemetry/sdk'
require 'opentelemetry/exporter/otlp'
require 'base64'
module OpentelemetryConfig
class << self
def tracer
initialize! unless initialized?
OpenTelemetry.tracer_provider.tracer('chatwoot')
end
def initialized?
@initialized ||= false
end
def initialize!
return if @initialized
return mark_initialized unless langfuse_provider?
return mark_initialized unless langfuse_credentials_present?
configure_opentelemetry
mark_initialized
rescue StandardError => e
Rails.logger.error "Failed to configure OpenTelemetry: #{e.message}"
mark_initialized
end
def reset!
@initialized = false
end
private
def mark_initialized
@initialized = true
end
def langfuse_provider?
otel_provider = InstallationConfig.find_by(name: 'OTEL_PROVIDER')&.value
otel_provider == 'langfuse'
end
def langfuse_credentials_present?
endpoint = InstallationConfig.find_by(name: 'LANGFUSE_BASE_URL')&.value
public_key = InstallationConfig.find_by(name: 'LANGFUSE_PUBLIC_KEY')&.value
secret_key = InstallationConfig.find_by(name: 'LANGFUSE_SECRET_KEY')&.value
if endpoint.blank? || public_key.blank? || secret_key.blank?
Rails.logger.error 'OpenTelemetry disabled (LANGFUSE_BASE_URL, LANGFUSE_PUBLIC_KEY or LANGFUSE_SECRET_KEY is missing)'
return false
end
true
end
def langfuse_credentials
{
endpoint: InstallationConfig.find_by(name: 'LANGFUSE_BASE_URL')&.value,
public_key: InstallationConfig.find_by(name: 'LANGFUSE_PUBLIC_KEY')&.value,
secret_key: InstallationConfig.find_by(name: 'LANGFUSE_SECRET_KEY')&.value
}
end
def traces_endpoint
credentials = langfuse_credentials
"#{credentials[:endpoint]}/api/public/otel/v1/traces"
end
def exporter_config
credentials = langfuse_credentials
auth_header = Base64.strict_encode64("#{credentials[:public_key]}:#{credentials[:secret_key]}")
config = {
endpoint: traces_endpoint,
headers: { 'Authorization' => "Basic #{auth_header}" }
}
config[:ssl_verify_mode] = OpenSSL::SSL::VERIFY_NONE if Rails.env.development?
config
end
def configure_opentelemetry
OpenTelemetry::SDK.configure do |c|
c.service_name = 'chatwoot'
exporter = OpenTelemetry::Exporter::OTLP::Exporter.new(**exporter_config)
c.add_span_processor(OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(exporter))
Rails.logger.info 'OpenTelemetry initialized and configured to export to Langfuse'
end
end
end
end