langfuse-ruby 0.1.7 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +21 -10
- data/.github/workflows/release.yml +2 -2
- data/.mise.toml +8 -0
- data/.rubocop.yml +2 -2
- data/CHANGELOG.md +78 -10
- data/CLAUDE.md +54 -16
- data/Gemfile +1 -0
- data/Gemfile.lock +15 -5
- data/Makefile +6 -5
- data/README.md +338 -89
- data/Rakefile +0 -6
- data/docs/FINAL_SUMMARY.md +12 -185
- data/docs/PUBLISH_GUIDE.md +36 -272
- data/docs/README.md +12 -22
- data/docs/RELEASE_CHECKLIST.md +20 -145
- data/docs/V4.md +159 -0
- data/examples/basic_tracing.rb +11 -10
- data/examples/simplified_usage.rb +7 -6
- data/examples/v4_otel_tracing.rb +70 -0
- data/langfuse-ruby.gemspec +2 -0
- data/lib/langfuse/client.rb +757 -276
- data/lib/langfuse/event.rb +1 -17
- data/lib/langfuse/generation.rb +71 -124
- data/lib/langfuse/null_objects.rb +4 -0
- data/lib/langfuse/otel_exporter.rb +145 -63
- data/lib/langfuse/partial_updates.rb +30 -0
- data/lib/langfuse/prompt.rb +9 -83
- data/lib/langfuse/prompt_cache.rb +65 -0
- data/lib/langfuse/span.rb +29 -153
- data/lib/langfuse/span_wrappers.rb +32 -0
- data/lib/langfuse/template_compiler.rb +56 -0
- data/lib/langfuse/trace.rb +38 -164
- data/lib/langfuse/utils.rb +54 -20
- data/lib/langfuse/version.rb +1 -1
- data/lib/langfuse.rb +61 -56
- data/scripts/release.sh +12 -12
- metadata +37 -2
data/lib/langfuse/client.rb
CHANGED
|
@@ -6,36 +6,105 @@ require 'faraday/multipart'
|
|
|
6
6
|
require 'json'
|
|
7
7
|
require 'base64'
|
|
8
8
|
require 'concurrent'
|
|
9
|
+
require 'logger'
|
|
10
|
+
require 'digest'
|
|
11
|
+
require 'time'
|
|
9
12
|
|
|
10
13
|
module Langfuse
|
|
11
14
|
class Client
|
|
15
|
+
# The ingestion API limits batch payloads to 3.5 MB in total
|
|
16
|
+
MAX_BATCH_SIZE_BYTES = 3_500_000
|
|
17
|
+
|
|
18
|
+
# Allowed format for the tracing environment field
|
|
19
|
+
ENVIRONMENT_PATTERN = /\A(?!langfuse)[a-z0-9\-_]{1,40}\z/
|
|
20
|
+
|
|
21
|
+
# How long shutdown waits for the flush thread to finish its current send
|
|
22
|
+
FLUSH_THREAD_JOIN_TIMEOUT = 5
|
|
23
|
+
|
|
24
|
+
# Dropped-event warnings are emitted on the first drop and then every N drops
|
|
25
|
+
DROPPED_EVENTS_WARN_INTERVAL = 100
|
|
26
|
+
|
|
27
|
+
# Supported ingestion transports: the legacy ingestion API and OTLP (Langfuse v4)
|
|
28
|
+
INGESTION_MODES = %i[legacy otel].freeze
|
|
29
|
+
|
|
30
|
+
# Backoff for transient request failures. The delay is jittered so that many
|
|
31
|
+
# clients hitting the same rate limit do not retry in lockstep, and capped so
|
|
32
|
+
# a server-sent Retry-After cannot stall a flush for minutes.
|
|
33
|
+
RETRY_BASE_DELAY_SECONDS = 0.5
|
|
34
|
+
MAX_RETRY_DELAY_SECONDS = 10
|
|
35
|
+
|
|
36
|
+
# Log device that resolves $stdout at write time so output redirection
|
|
37
|
+
# (e.g. in tests) keeps working after the logger was created.
|
|
38
|
+
class StdoutLogDevice
|
|
39
|
+
def write(message)
|
|
40
|
+
$stdout.write(message)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def close; end
|
|
44
|
+
|
|
45
|
+
def flush
|
|
46
|
+
$stdout.flush
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
12
50
|
attr_reader :public_key, :secret_key, :host, :debug, :timeout, :retries, :flush_interval, :auto_flush,
|
|
13
|
-
:ingestion_mode
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
@
|
|
22
|
-
@
|
|
23
|
-
@
|
|
24
|
-
@
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
51
|
+
:ingestion_mode, :environment, :sample_rate, :flush_at, :max_queue_size, :mask, :logger
|
|
52
|
+
|
|
53
|
+
# timeout/retries default to nil so that Langfuse.configure values are not
|
|
54
|
+
# shadowed by the method defaults; the fallbacks live in config_value.
|
|
55
|
+
def initialize(public_key: nil, secret_key: nil, host: nil, debug: false, timeout: nil, retries: nil,
|
|
56
|
+
flush_interval: nil, auto_flush: nil, ingestion_mode: nil, environment: nil,
|
|
57
|
+
sample_rate: nil, mask: nil, flush_at: nil, max_queue_size: nil, logger: nil,
|
|
58
|
+
shutdown_on_exit: nil, http_adapter: nil)
|
|
59
|
+
@public_key = config_value(public_key, 'LANGFUSE_PUBLIC_KEY', :public_key)
|
|
60
|
+
@secret_key = config_value(secret_key, 'LANGFUSE_SECRET_KEY', :secret_key)
|
|
61
|
+
@host = host || ENV['LANGFUSE_HOST'] || ENV['LANGFUSE_BASE_URL'] || Langfuse.configuration.host
|
|
62
|
+
@debug = debug || ENV['LANGFUSE_DEBUG'] == 'true' || Langfuse.configuration.debug
|
|
63
|
+
@timeout = config_value(timeout, nil, :timeout) { 30 }
|
|
64
|
+
@retries = config_value(retries, nil, :retries) { 3 }
|
|
65
|
+
@flush_interval = config_value(flush_interval, 'LANGFUSE_FLUSH_INTERVAL', :flush_interval) { 5 }
|
|
66
|
+
@flush_at = config_value(flush_at, 'LANGFUSE_FLUSH_AT', :flush_at) { 15 }
|
|
67
|
+
@max_queue_size = config_value(max_queue_size, 'LANGFUSE_MAX_QUEUE_SIZE', :max_queue_size) { 10_000 }
|
|
68
|
+
@auto_flush = resolve_auto_flush(auto_flush)
|
|
69
|
+
@logger = logger || Langfuse.configuration.logger || build_default_logger
|
|
29
70
|
@ingestion_mode = resolve_ingestion_mode(ingestion_mode)
|
|
71
|
+
@environment = resolve_environment(environment)
|
|
72
|
+
@sample_rate = resolve_sample_rate(sample_rate)
|
|
73
|
+
@mask = resolve_mask(mask)
|
|
74
|
+
@shutdown_on_exit = shutdown_on_exit.nil? ? Langfuse.configuration.shutdown_on_exit : shutdown_on_exit
|
|
75
|
+
@http_adapter = http_adapter || Langfuse.configuration.http_adapter
|
|
76
|
+
@shutdown = false
|
|
30
77
|
|
|
31
78
|
raise AuthenticationError, 'Public key is required' unless @public_key
|
|
32
79
|
raise AuthenticationError, 'Secret key is required' unless @secret_key
|
|
33
80
|
|
|
34
|
-
|
|
35
|
-
@otel_connection = build_otel_connection if @ingestion_mode == :otel
|
|
36
|
-
@otel_exporter = OtelExporter.new(connection: @otel_connection, debug: @debug) if @ingestion_mode == :otel
|
|
81
|
+
setup_transport
|
|
37
82
|
@event_queue = Concurrent::Array.new
|
|
38
|
-
@
|
|
83
|
+
@queue_mutex = Mutex.new
|
|
84
|
+
@flush_mutex = Mutex.new
|
|
85
|
+
@flush_condition = ConditionVariable.new
|
|
86
|
+
@dropped_events = 0
|
|
87
|
+
@prompt_cache = PromptCache.new
|
|
88
|
+
start_flush_thread if @auto_flush
|
|
89
|
+
register_shutdown_hook if @shutdown_on_exit
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Keep the secret key out of logs, console output and exception messages.
|
|
93
|
+
def inspect
|
|
94
|
+
"#<#{self.class.name} host=#{@host.inspect} public_key=#{@public_key.inspect} " \
|
|
95
|
+
"ingestion_mode=#{@ingestion_mode.inspect}>"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Generate a trace ID matching the active ingestion mode
|
|
99
|
+
# (W3C 32-char hex for :otel, UUID for :legacy)
|
|
100
|
+
def generate_trace_id
|
|
101
|
+
@ingestion_mode == :otel ? Utils.generate_hex_trace_id : Utils.generate_id
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Generate an observation ID matching the active ingestion mode
|
|
105
|
+
# (W3C 16-char hex for :otel, UUID for :legacy)
|
|
106
|
+
def generate_observation_id
|
|
107
|
+
@ingestion_mode == :otel ? Utils.generate_hex_span_id : Utils.generate_id
|
|
39
108
|
end
|
|
40
109
|
|
|
41
110
|
# Trace operations
|
|
@@ -43,7 +112,7 @@ module Langfuse
|
|
|
43
112
|
input: nil, output: nil, metadata: nil, tags: nil, timestamp: nil, **kwargs)
|
|
44
113
|
Trace.new(
|
|
45
114
|
client: self,
|
|
46
|
-
id: id ||
|
|
115
|
+
id: id || generate_trace_id,
|
|
47
116
|
name: name,
|
|
48
117
|
user_id: user_id,
|
|
49
118
|
session_id: session_id,
|
|
@@ -59,12 +128,13 @@ module Langfuse
|
|
|
59
128
|
end
|
|
60
129
|
|
|
61
130
|
# Span operations
|
|
62
|
-
def span(trace_id:, name: nil, start_time: nil, end_time: nil, input: nil, output: nil,
|
|
131
|
+
def span(trace_id:, id: nil, name: nil, start_time: nil, end_time: nil, input: nil, output: nil,
|
|
63
132
|
metadata: nil, level: nil, status_message: nil, parent_observation_id: nil,
|
|
64
133
|
version: nil, as_type: nil, **kwargs)
|
|
65
134
|
Span.new(
|
|
66
135
|
client: self,
|
|
67
136
|
trace_id: trace_id,
|
|
137
|
+
id: id || generate_observation_id,
|
|
68
138
|
name: name,
|
|
69
139
|
start_time: start_time || Utils.current_timestamp,
|
|
70
140
|
end_time: end_time,
|
|
@@ -80,91 +150,15 @@ module Langfuse
|
|
|
80
150
|
)
|
|
81
151
|
end
|
|
82
152
|
|
|
83
|
-
# Convenience methods for enhanced observation types
|
|
153
|
+
# Convenience methods for enhanced observation types: each is a span with a
|
|
154
|
+
# fixed as_type. (embedding keeps its own definition because it folds
|
|
155
|
+
# model/usage into metadata first.)
|
|
156
|
+
extend SpanWrappers
|
|
157
|
+
define_span_wrappers(evaluator_name: :evaluator_obs)
|
|
84
158
|
|
|
85
|
-
#
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
version: nil, **kwargs)
|
|
89
|
-
span(
|
|
90
|
-
trace_id: trace_id,
|
|
91
|
-
name: name,
|
|
92
|
-
start_time: start_time,
|
|
93
|
-
end_time: end_time,
|
|
94
|
-
input: input,
|
|
95
|
-
output: output,
|
|
96
|
-
metadata: metadata,
|
|
97
|
-
level: level,
|
|
98
|
-
status_message: status_message,
|
|
99
|
-
parent_observation_id: parent_observation_id,
|
|
100
|
-
version: version,
|
|
101
|
-
as_type: ObservationType::AGENT,
|
|
102
|
-
**kwargs
|
|
103
|
-
)
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
# Create a tool observation (wrapper around span with as_type: 'tool')
|
|
107
|
-
def tool(trace_id:, name: nil, start_time: nil, end_time: nil, input: nil, output: nil,
|
|
108
|
-
metadata: nil, level: nil, status_message: nil, parent_observation_id: nil,
|
|
109
|
-
version: nil, **kwargs)
|
|
110
|
-
span(
|
|
111
|
-
trace_id: trace_id,
|
|
112
|
-
name: name,
|
|
113
|
-
start_time: start_time,
|
|
114
|
-
end_time: end_time,
|
|
115
|
-
input: input,
|
|
116
|
-
output: output,
|
|
117
|
-
metadata: metadata,
|
|
118
|
-
level: level,
|
|
119
|
-
status_message: status_message,
|
|
120
|
-
parent_observation_id: parent_observation_id,
|
|
121
|
-
version: version,
|
|
122
|
-
as_type: ObservationType::TOOL,
|
|
123
|
-
**kwargs
|
|
124
|
-
)
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
# Create a chain observation (wrapper around span with as_type: 'chain')
|
|
128
|
-
def chain(trace_id:, name: nil, start_time: nil, end_time: nil, input: nil, output: nil,
|
|
129
|
-
metadata: nil, level: nil, status_message: nil, parent_observation_id: nil,
|
|
130
|
-
version: nil, **kwargs)
|
|
131
|
-
span(
|
|
132
|
-
trace_id: trace_id,
|
|
133
|
-
name: name,
|
|
134
|
-
start_time: start_time,
|
|
135
|
-
end_time: end_time,
|
|
136
|
-
input: input,
|
|
137
|
-
output: output,
|
|
138
|
-
metadata: metadata,
|
|
139
|
-
level: level,
|
|
140
|
-
status_message: status_message,
|
|
141
|
-
parent_observation_id: parent_observation_id,
|
|
142
|
-
version: version,
|
|
143
|
-
as_type: ObservationType::CHAIN,
|
|
144
|
-
**kwargs
|
|
145
|
-
)
|
|
146
|
-
end
|
|
147
|
-
|
|
148
|
-
# Create a retriever observation (wrapper around span with as_type: 'retriever')
|
|
149
|
-
def retriever(trace_id:, name: nil, start_time: nil, end_time: nil, input: nil, output: nil,
|
|
150
|
-
metadata: nil, level: nil, status_message: nil, parent_observation_id: nil,
|
|
151
|
-
version: nil, **kwargs)
|
|
152
|
-
span(
|
|
153
|
-
trace_id: trace_id,
|
|
154
|
-
name: name,
|
|
155
|
-
start_time: start_time,
|
|
156
|
-
end_time: end_time,
|
|
157
|
-
input: input,
|
|
158
|
-
output: output,
|
|
159
|
-
metadata: metadata,
|
|
160
|
-
level: level,
|
|
161
|
-
status_message: status_message,
|
|
162
|
-
parent_observation_id: parent_observation_id,
|
|
163
|
-
version: version,
|
|
164
|
-
as_type: ObservationType::RETRIEVER,
|
|
165
|
-
**kwargs
|
|
166
|
-
)
|
|
167
|
-
end
|
|
159
|
+
# `evaluator` matches Trace/Span/Generation; `evaluator_obs` is kept for
|
|
160
|
+
# callers that adopted the older name.
|
|
161
|
+
alias evaluator evaluator_obs
|
|
168
162
|
|
|
169
163
|
# Create an embedding observation (wrapper around span with as_type: 'embedding')
|
|
170
164
|
def embedding(trace_id:, name: nil, start_time: nil, end_time: nil, input: nil, output: nil,
|
|
@@ -190,56 +184,16 @@ module Langfuse
|
|
|
190
184
|
)
|
|
191
185
|
end
|
|
192
186
|
|
|
193
|
-
# Create an evaluator observation (wrapper around span with as_type: 'evaluator')
|
|
194
|
-
def evaluator_obs(trace_id:, name: nil, start_time: nil, end_time: nil, input: nil, output: nil,
|
|
195
|
-
metadata: nil, level: nil, status_message: nil, parent_observation_id: nil,
|
|
196
|
-
version: nil, **kwargs)
|
|
197
|
-
span(
|
|
198
|
-
trace_id: trace_id,
|
|
199
|
-
name: name,
|
|
200
|
-
start_time: start_time,
|
|
201
|
-
end_time: end_time,
|
|
202
|
-
input: input,
|
|
203
|
-
output: output,
|
|
204
|
-
metadata: metadata,
|
|
205
|
-
level: level,
|
|
206
|
-
status_message: status_message,
|
|
207
|
-
parent_observation_id: parent_observation_id,
|
|
208
|
-
version: version,
|
|
209
|
-
as_type: ObservationType::EVALUATOR,
|
|
210
|
-
**kwargs
|
|
211
|
-
)
|
|
212
|
-
end
|
|
213
|
-
|
|
214
|
-
# Create a guardrail observation (wrapper around span with as_type: 'guardrail')
|
|
215
|
-
def guardrail(trace_id:, name: nil, start_time: nil, end_time: nil, input: nil, output: nil,
|
|
216
|
-
metadata: nil, level: nil, status_message: nil, parent_observation_id: nil,
|
|
217
|
-
version: nil, **kwargs)
|
|
218
|
-
span(
|
|
219
|
-
trace_id: trace_id,
|
|
220
|
-
name: name,
|
|
221
|
-
start_time: start_time,
|
|
222
|
-
end_time: end_time,
|
|
223
|
-
input: input,
|
|
224
|
-
output: output,
|
|
225
|
-
metadata: metadata,
|
|
226
|
-
level: level,
|
|
227
|
-
status_message: status_message,
|
|
228
|
-
parent_observation_id: parent_observation_id,
|
|
229
|
-
version: version,
|
|
230
|
-
as_type: ObservationType::GUARDRAIL,
|
|
231
|
-
**kwargs
|
|
232
|
-
)
|
|
233
|
-
end
|
|
234
|
-
|
|
235
187
|
# Generation operations
|
|
236
|
-
def generation(trace_id:, name: nil, start_time: nil, end_time: nil, completion_start_time: nil,
|
|
188
|
+
def generation(trace_id:, id: nil, name: nil, start_time: nil, end_time: nil, completion_start_time: nil,
|
|
237
189
|
model: nil, model_parameters: nil, input: nil, output: nil, usage: nil,
|
|
190
|
+
usage_details: nil, cost_details: nil, prompt: nil,
|
|
238
191
|
metadata: nil, level: nil, status_message: nil, parent_observation_id: nil,
|
|
239
192
|
version: nil, **kwargs)
|
|
240
193
|
Generation.new(
|
|
241
194
|
client: self,
|
|
242
195
|
trace_id: trace_id,
|
|
196
|
+
id: id || generate_observation_id,
|
|
243
197
|
name: name,
|
|
244
198
|
start_time: start_time || Utils.current_timestamp,
|
|
245
199
|
end_time: end_time,
|
|
@@ -249,6 +203,9 @@ module Langfuse
|
|
|
249
203
|
input: input,
|
|
250
204
|
output: output,
|
|
251
205
|
usage: usage,
|
|
206
|
+
usage_details: usage_details,
|
|
207
|
+
cost_details: cost_details,
|
|
208
|
+
prompt: prompt,
|
|
252
209
|
metadata: metadata,
|
|
253
210
|
level: level,
|
|
254
211
|
status_message: status_message,
|
|
@@ -259,11 +216,12 @@ module Langfuse
|
|
|
259
216
|
end
|
|
260
217
|
|
|
261
218
|
# Event operations
|
|
262
|
-
def event(trace_id:, name:, start_time: nil, input: nil, output: nil, metadata: nil,
|
|
219
|
+
def event(trace_id:, name:, id: nil, start_time: nil, input: nil, output: nil, metadata: nil,
|
|
263
220
|
level: nil, status_message: nil, parent_observation_id: nil, version: nil, **kwargs)
|
|
264
221
|
Event.new(
|
|
265
222
|
client: self,
|
|
266
223
|
trace_id: trace_id,
|
|
224
|
+
id: id || generate_observation_id,
|
|
267
225
|
name: name,
|
|
268
226
|
start_time: start_time,
|
|
269
227
|
input: input,
|
|
@@ -278,42 +236,24 @@ module Langfuse
|
|
|
278
236
|
end
|
|
279
237
|
|
|
280
238
|
# Prompt operations
|
|
281
|
-
def get_prompt(name, version: nil, label: nil, cache_ttl_seconds: 60)
|
|
239
|
+
def get_prompt(name, version: nil, label: nil, cache_ttl_seconds: 60, retries: nil)
|
|
282
240
|
cache_key = "prompt:#{name}:#{version}:#{label}"
|
|
241
|
+
cached = @prompt_cache.read(cache_key, cache_ttl_seconds)
|
|
242
|
+
return cached if cached
|
|
283
243
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
params[:version] = version if version
|
|
292
|
-
params[:label] = label if label
|
|
293
|
-
|
|
294
|
-
puts "Making request to: #{@host}#{path} with params: #{params}" if @debug
|
|
295
|
-
|
|
296
|
-
response = get(path, params)
|
|
297
|
-
|
|
298
|
-
puts "Response status: #{response.status}" if @debug
|
|
299
|
-
puts "Response headers: #{response.headers}" if @debug
|
|
300
|
-
puts "Response body type: #{response.body.class}" if @debug
|
|
244
|
+
begin
|
|
245
|
+
prompt = request_prompt(name, version: version, label: label, retries: retries)
|
|
246
|
+
rescue StandardError => e
|
|
247
|
+
# An expired entry beats no prompt at all: serving it keeps the
|
|
248
|
+
# application running through a Langfuse outage.
|
|
249
|
+
stale = @prompt_cache.read_stale(cache_key)
|
|
250
|
+
raise unless stale
|
|
301
251
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
puts 'Received HTML response instead of JSON:' if @debug
|
|
305
|
-
puts response.body[0..200] if @debug
|
|
306
|
-
raise APIError,
|
|
307
|
-
'Received HTML response instead of JSON. This usually indicates a 404 error or incorrect API endpoint.'
|
|
252
|
+
@logger.warn("Langfuse prompt fetch failed (#{name}), serving the cached copy: #{e.message}")
|
|
253
|
+
return stale
|
|
308
254
|
end
|
|
309
255
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
# Cache the prompt
|
|
313
|
-
@prompt_cache ||= {}
|
|
314
|
-
@prompt_cache[cache_key] = { prompt: prompt, cached_at: Time.now }
|
|
315
|
-
|
|
316
|
-
prompt
|
|
256
|
+
@prompt_cache.write(cache_key, prompt)
|
|
317
257
|
end
|
|
318
258
|
|
|
319
259
|
def create_prompt(name:, prompt:, labels: [], config: {}, **kwargs)
|
|
@@ -330,23 +270,38 @@ module Langfuse
|
|
|
330
270
|
end
|
|
331
271
|
|
|
332
272
|
# Score/Evaluation operations
|
|
333
|
-
|
|
273
|
+
# Scores can target a trace, an observation (trace_id + observation_id),
|
|
274
|
+
# a session (session_id) or a dataset run (dataset_run_id).
|
|
275
|
+
def score(name:, value:, trace_id: nil, observation_id: nil, session_id: nil, dataset_run_id: nil,
|
|
276
|
+
id: nil, data_type: nil, comment: nil, metadata: nil, config_id: nil, queue_id: nil,
|
|
277
|
+
environment: nil, **kwargs)
|
|
334
278
|
data = {
|
|
279
|
+
id: id,
|
|
280
|
+
trace_id: trace_id,
|
|
281
|
+
observation_id: observation_id,
|
|
282
|
+
session_id: session_id,
|
|
283
|
+
dataset_run_id: dataset_run_id,
|
|
335
284
|
name: name,
|
|
336
285
|
value: value,
|
|
337
286
|
data_type: data_type,
|
|
338
287
|
comment: comment,
|
|
288
|
+
metadata: metadata,
|
|
289
|
+
config_id: config_id,
|
|
290
|
+
queue_id: queue_id,
|
|
291
|
+
environment: environment,
|
|
339
292
|
**kwargs
|
|
340
|
-
}
|
|
293
|
+
}.compact
|
|
341
294
|
|
|
342
|
-
|
|
343
|
-
|
|
295
|
+
if trace_id.nil? && observation_id.nil? && session_id.nil? && dataset_run_id.nil?
|
|
296
|
+
@logger.warn('Langfuse score should reference a trace_id, observation_id, session_id or dataset_run_id')
|
|
297
|
+
end
|
|
344
298
|
|
|
345
299
|
enqueue_event('score-create', data)
|
|
346
300
|
end
|
|
301
|
+
alias create_score score
|
|
347
302
|
|
|
348
303
|
# Event queue management
|
|
349
|
-
def enqueue_event(type, body)
|
|
304
|
+
def enqueue_event(type, body, trace_ref: nil)
|
|
350
305
|
# 验证事件类型是否有效
|
|
351
306
|
valid_types = %w[
|
|
352
307
|
trace-create trace-update
|
|
@@ -357,81 +312,366 @@ module Langfuse
|
|
|
357
312
|
]
|
|
358
313
|
|
|
359
314
|
unless valid_types.include?(type)
|
|
360
|
-
|
|
315
|
+
@logger.debug { "Warning: Invalid event type '#{type}'. Skipping event." }
|
|
361
316
|
return
|
|
362
317
|
end
|
|
363
318
|
|
|
319
|
+
# Runs before the event is queued so events inherited from a parent process
|
|
320
|
+
# are discarded without dropping the event we are about to enqueue.
|
|
321
|
+
ensure_flush_thread
|
|
322
|
+
|
|
323
|
+
prepared_body = prepare_queued_body(body)
|
|
324
|
+
|
|
325
|
+
return unless sampled_event?(type, prepared_body)
|
|
326
|
+
|
|
364
327
|
event = {
|
|
365
328
|
id: Utils.generate_id,
|
|
366
329
|
type: type,
|
|
367
330
|
timestamp: Utils.current_timestamp,
|
|
368
|
-
body:
|
|
331
|
+
body: prepared_body
|
|
369
332
|
}
|
|
333
|
+
event[:trace_ref] = trace_ref if trace_ref
|
|
370
334
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
if
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
end
|
|
379
|
-
|
|
380
|
-
if existing_event_index
|
|
381
|
-
# 更新现有的 trace-create 事件
|
|
382
|
-
@event_queue[existing_event_index][:body].merge!(event[:body])
|
|
383
|
-
@event_queue[existing_event_index][:timestamp] = event[:timestamp]
|
|
384
|
-
puts "Updated existing trace-create event for trace_id: #{trace_id}" if @debug
|
|
385
|
-
else
|
|
386
|
-
# 如果没找到对应的 trace-create 事件,将 trace-update 转换为 trace-create
|
|
387
|
-
event[:type] = 'trace-create'
|
|
388
|
-
@event_queue << event
|
|
389
|
-
puts "Converted trace-update to trace-create for trace_id: #{trace_id}" if @debug
|
|
390
|
-
end
|
|
391
|
-
elsif @debug
|
|
392
|
-
puts 'Warning: trace-update event missing trace_id, skipping'
|
|
335
|
+
# The queue is drained under the same lock, so a concurrent flush can no
|
|
336
|
+
# longer take an event out between finding it and merging into it.
|
|
337
|
+
queued = @queue_mutex.synchronize do
|
|
338
|
+
if type == 'trace-update'
|
|
339
|
+
merge_or_queue_trace_update?(event)
|
|
340
|
+
else
|
|
341
|
+
push_event?(event)
|
|
393
342
|
end
|
|
394
|
-
else
|
|
395
|
-
@event_queue << event
|
|
396
343
|
end
|
|
397
|
-
|
|
344
|
+
return unless queued
|
|
345
|
+
|
|
346
|
+
@logger.debug { "Enqueued event: #{type}" }
|
|
347
|
+
|
|
348
|
+
request_flush if @auto_flush && @event_queue.length >= @flush_at
|
|
398
349
|
end
|
|
399
350
|
|
|
400
351
|
def flush
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
352
|
+
events = @queue_mutex.synchronize do
|
|
353
|
+
@event_queue.empty? ? [] : @event_queue.shift(@event_queue.length)
|
|
354
|
+
end
|
|
404
355
|
return if events.empty?
|
|
405
356
|
|
|
406
357
|
send_batch(events)
|
|
407
358
|
end
|
|
408
359
|
|
|
409
360
|
def shutdown
|
|
410
|
-
|
|
361
|
+
return if @shutdown
|
|
362
|
+
|
|
363
|
+
@shutdown = true
|
|
364
|
+
stop_flush_thread
|
|
411
365
|
flush unless @event_queue.empty?
|
|
412
366
|
end
|
|
413
367
|
|
|
414
368
|
private
|
|
415
369
|
|
|
370
|
+
def setup_transport
|
|
371
|
+
@connection = build_connection
|
|
372
|
+
return unless @ingestion_mode == :otel
|
|
373
|
+
|
|
374
|
+
@otel_connection = build_otel_connection
|
|
375
|
+
@otel_exporter = OtelExporter.new(connection: @otel_connection, debug: @debug, logger: @logger)
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def request_prompt(name, version:, label:, retries: nil)
|
|
379
|
+
path = "/api/public/v2/prompts/#{Utils.url_encode(name)}"
|
|
380
|
+
params = {}
|
|
381
|
+
params[:version] = version if version
|
|
382
|
+
params[:label] = label if label
|
|
383
|
+
|
|
384
|
+
@logger.debug { "Making request to: #{@host}#{path} with params: #{params}" }
|
|
385
|
+
|
|
386
|
+
response = request(:get, path, params: params, retries: retries)
|
|
387
|
+
|
|
388
|
+
@logger.debug { "Response status: #{response.status}, body type: #{response.body.class}" }
|
|
389
|
+
|
|
390
|
+
# Check if response body is a string (HTML) instead of parsed JSON
|
|
391
|
+
if response.body.is_a?(String) && response.body.include?('<!DOCTYPE html>')
|
|
392
|
+
@logger.debug { "Received HTML response instead of JSON: #{response.body[0..200]}" }
|
|
393
|
+
raise APIError,
|
|
394
|
+
'Received HTML response instead of JSON. This usually indicates a 404 error or incorrect API endpoint.'
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
Prompt.new(response.body)
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def build_default_logger
|
|
401
|
+
logger = Logger.new(StdoutLogDevice.new)
|
|
402
|
+
logger.level = @debug ? Logger::DEBUG : Logger::WARN
|
|
403
|
+
logger.progname = 'langfuse'
|
|
404
|
+
logger.formatter = proc do |severity, _time, progname, msg|
|
|
405
|
+
"#{severity} -- #{progname}: #{msg}\n"
|
|
406
|
+
end
|
|
407
|
+
logger
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
# Resolve a config value with precedence: explicit arg > env var > config attr > block default
|
|
411
|
+
def config_value(explicit, env_key, config_attr)
|
|
412
|
+
return explicit if explicit
|
|
413
|
+
|
|
414
|
+
if env_key
|
|
415
|
+
env_val = ENV.fetch(env_key, nil)
|
|
416
|
+
if env_val && %i[flush_interval flush_at timeout retries max_queue_size].include?(config_attr)
|
|
417
|
+
return env_val.to_i
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
return env_val if env_val
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
Langfuse.configuration.send(config_attr) || (yield if block_given?)
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def resolve_auto_flush(auto_flush)
|
|
427
|
+
if auto_flush.nil?
|
|
428
|
+
ENV['LANGFUSE_AUTO_FLUSH'] == 'false' ? false : Langfuse.configuration.auto_flush
|
|
429
|
+
else
|
|
430
|
+
auto_flush
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def resolve_environment(explicit_environment)
|
|
435
|
+
environment = explicit_environment || ENV['LANGFUSE_TRACING_ENVIRONMENT'] || Langfuse.configuration.environment
|
|
436
|
+
return nil if environment.nil? || environment.to_s.empty?
|
|
437
|
+
|
|
438
|
+
environment = environment.to_s
|
|
439
|
+
unless environment.match?(ENVIRONMENT_PATTERN)
|
|
440
|
+
@logger.warn("Invalid Langfuse environment '#{environment}'. It must match #{ENVIRONMENT_PATTERN.inspect}. " \
|
|
441
|
+
'Events may be rejected by the server.')
|
|
442
|
+
end
|
|
443
|
+
environment
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def resolve_sample_rate(explicit_sample_rate)
|
|
447
|
+
rate = explicit_sample_rate || ENV['LANGFUSE_SAMPLE_RATE']&.to_f || Langfuse.configuration.sample_rate
|
|
448
|
+
return nil if rate.nil?
|
|
449
|
+
|
|
450
|
+
rate = rate.to_f
|
|
451
|
+
unless rate.between?(0.0, 1.0)
|
|
452
|
+
@logger.warn("Invalid Langfuse sample_rate #{rate}, expected 0.0..1.0. Disabling sampling.")
|
|
453
|
+
return nil
|
|
454
|
+
end
|
|
455
|
+
rate
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
def resolve_mask(explicit_mask)
|
|
459
|
+
mask = explicit_mask || Langfuse.configuration.mask
|
|
460
|
+
return nil if mask.nil?
|
|
461
|
+
|
|
462
|
+
unless mask.respond_to?(:call)
|
|
463
|
+
@logger.warn('Langfuse mask must respond to #call. Ignoring mask.')
|
|
464
|
+
return nil
|
|
465
|
+
end
|
|
466
|
+
mask
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
def register_shutdown_hook
|
|
470
|
+
at_exit do
|
|
471
|
+
shutdown
|
|
472
|
+
rescue StandardError => e
|
|
473
|
+
@logger.debug("Langfuse shutdown on exit failed: #{e.message}")
|
|
474
|
+
end
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
# Camelize top-level keys, inject the default environment, then apply the
|
|
478
|
+
# mask. Every body that enters the queue — including a trace_ref rebuilt
|
|
479
|
+
# after the matching create has already flushed — must go through this.
|
|
480
|
+
def prepare_queued_body(body)
|
|
481
|
+
prepared = Utils.prepare_event_body(body)
|
|
482
|
+
inject_default_environment(prepared)
|
|
483
|
+
apply_mask(prepared)
|
|
484
|
+
prepared
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
def inject_default_environment(body)
|
|
488
|
+
return unless @environment
|
|
489
|
+
return if body.key?('environment')
|
|
490
|
+
|
|
491
|
+
body['environment'] = @environment
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
def apply_mask(body)
|
|
495
|
+
return unless @mask
|
|
496
|
+
|
|
497
|
+
%w[input output metadata].each do |field|
|
|
498
|
+
next unless body.key?(field) && !body[field].nil?
|
|
499
|
+
|
|
500
|
+
body[field] = begin
|
|
501
|
+
@mask.call(body[field])
|
|
502
|
+
rescue StandardError => e
|
|
503
|
+
@logger.error("Langfuse mask function failed: #{e.message}")
|
|
504
|
+
'<masked due to failed mask function>'
|
|
505
|
+
end
|
|
506
|
+
end
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
# Deterministic trace-based sampling: all events of a trace share the same decision.
|
|
510
|
+
def sampled_event?(type, body)
|
|
511
|
+
return true unless @sample_rate
|
|
512
|
+
|
|
513
|
+
trace_id = %w[trace-create trace-update].include?(type) ? body['id'] : body['traceId']
|
|
514
|
+
return true if trace_id.nil?
|
|
515
|
+
|
|
516
|
+
return true if trace_sampled?(trace_id)
|
|
517
|
+
|
|
518
|
+
@logger.debug("Dropping event for trace #{trace_id} due to sampling (rate: #{@sample_rate})")
|
|
519
|
+
false
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
def trace_sampled?(trace_id)
|
|
523
|
+
return true if @sample_rate >= 1.0
|
|
524
|
+
return false if @sample_rate <= 0.0
|
|
525
|
+
|
|
526
|
+
normalized = Digest::SHA256.hexdigest(trace_id.to_s)[0, 8].to_i(16).to_f / 0xffffffff
|
|
527
|
+
normalized < @sample_rate
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
def request_flush
|
|
531
|
+
@flush_mutex.synchronize { @flush_condition.signal }
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
# Merge a trace-update into the queued trace-create for the same trace.
|
|
535
|
+
# Returns whether the queue changed. Callers must hold @queue_mutex.
|
|
536
|
+
def merge_or_queue_trace_update?(event)
|
|
537
|
+
trace_id = event[:body]['id']
|
|
538
|
+
|
|
539
|
+
unless trace_id
|
|
540
|
+
@logger.debug { 'Warning: trace-update event missing trace_id, skipping' }
|
|
541
|
+
return false
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
position = @event_queue.find_index do |queued_event|
|
|
545
|
+
queued_event[:type] == 'trace-create' && queued_event[:body]['id'] == trace_id
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
unless position
|
|
549
|
+
# Nothing left to merge into (already flushed): send it as a create so
|
|
550
|
+
# the server can upsert. Use the trace's full state (via :trace_ref) to
|
|
551
|
+
# avoid sending a partial body that would produce a broken observation.
|
|
552
|
+
event[:type] = 'trace-create'
|
|
553
|
+
if event[:trace_ref]
|
|
554
|
+
# to_dict is the live instance state (symbol keys, unmasked). Re-run
|
|
555
|
+
# the same prepare/env/mask path as enqueue_event so the API still
|
|
556
|
+
# sees camelCase keys, the default environment, and redacted PII.
|
|
557
|
+
event[:body] = prepare_queued_body(event[:trace_ref].to_dict)
|
|
558
|
+
event.delete(:trace_ref)
|
|
559
|
+
end
|
|
560
|
+
@logger.debug { "Converted trace-update to trace-create for trace_id: #{trace_id}" }
|
|
561
|
+
return push_event?(event)
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
@event_queue[position][:body].merge!(event[:body])
|
|
565
|
+
@event_queue[position][:timestamp] = event[:timestamp]
|
|
566
|
+
@logger.debug { "Updated existing trace-create event for trace_id: #{trace_id}" }
|
|
567
|
+
true
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
# Append an event, evicting the oldest ones when the queue is full. Without a
|
|
571
|
+
# bound, an unreachable Langfuse would grow the queue until the process dies.
|
|
572
|
+
# Callers must hold @queue_mutex. Returns whether the event was queued.
|
|
573
|
+
def push_event?(event)
|
|
574
|
+
dropped = 0
|
|
575
|
+
while @event_queue.length >= @max_queue_size
|
|
576
|
+
@event_queue.shift
|
|
577
|
+
dropped += 1
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
if dropped.positive?
|
|
581
|
+
@dropped_events += dropped
|
|
582
|
+
warn_dropped_events
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
@event_queue << event
|
|
586
|
+
true
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
def warn_dropped_events
|
|
590
|
+
return unless @dropped_events == 1 || (@dropped_events % DROPPED_EVENTS_WARN_INTERVAL).zero?
|
|
591
|
+
|
|
592
|
+
@logger.warn("Langfuse event queue is full (max_queue_size=#{@max_queue_size}); " \
|
|
593
|
+
"dropped #{@dropped_events} oldest events so far")
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
# Put events back for the next flush. Permanent failures are dropped instead:
|
|
597
|
+
# they would fail again on every flush and block the queue indefinitely.
|
|
598
|
+
# Events are prepended so they are retried before any newly enqueued events,
|
|
599
|
+
# preserving the original chronological order.
|
|
600
|
+
def requeue_events(events, error)
|
|
601
|
+
return if events.empty?
|
|
602
|
+
|
|
603
|
+
if permanent_failure?(error)
|
|
604
|
+
@logger.warn("Langfuse dropped #{events.length} events after a permanent failure " \
|
|
605
|
+
"(#{error.class}): #{error.message}")
|
|
606
|
+
return
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
@queue_mutex.synchronize { events.reverse_each { |event| prepend_event(event) } }
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
# Insert an event at the head of the queue, evicting the oldest (tail) if full.
|
|
613
|
+
# Callers must hold @queue_mutex.
|
|
614
|
+
def prepend_event(event)
|
|
615
|
+
if @event_queue.length >= @max_queue_size
|
|
616
|
+
@event_queue.pop
|
|
617
|
+
@dropped_events += 1
|
|
618
|
+
warn_dropped_events
|
|
619
|
+
end
|
|
620
|
+
@event_queue.unshift(event)
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
def permanent_failure?(error)
|
|
624
|
+
error.is_a?(ValidationError) || error.is_a?(AuthenticationError)
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
# Threads do not survive fork. Recreate the flush thread in the child and drop
|
|
628
|
+
# the events it inherited, which the parent process still flushes itself.
|
|
629
|
+
def ensure_flush_thread
|
|
630
|
+
return unless @auto_flush
|
|
631
|
+
return if @flush_thread_pid == Process.pid && @flush_thread&.alive?
|
|
632
|
+
|
|
633
|
+
if @flush_thread_pid && @flush_thread_pid != Process.pid
|
|
634
|
+
inherited = @queue_mutex.synchronize { @event_queue.shift(@event_queue.length) }
|
|
635
|
+
@logger.debug { "Dropped #{inherited.length} events inherited from pid #{@flush_thread_pid}" }
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
start_flush_thread
|
|
639
|
+
end
|
|
640
|
+
|
|
641
|
+
# Let the flush thread finish the send it is in the middle of; killing it
|
|
642
|
+
# would lose the events it already drained from the queue.
|
|
643
|
+
def stop_flush_thread
|
|
644
|
+
thread = @flush_thread
|
|
645
|
+
return unless thread
|
|
646
|
+
|
|
647
|
+
@stop_flushing = true
|
|
648
|
+
@flush_thread = nil
|
|
649
|
+
request_flush
|
|
650
|
+
return if thread.join(FLUSH_THREAD_JOIN_TIMEOUT)
|
|
651
|
+
|
|
652
|
+
@logger.warn("Langfuse flush thread did not stop within #{FLUSH_THREAD_JOIN_TIMEOUT}s; terminating it")
|
|
653
|
+
thread.kill
|
|
654
|
+
end
|
|
655
|
+
|
|
416
656
|
def debug_event_data(events)
|
|
417
657
|
return unless @debug
|
|
418
658
|
|
|
419
|
-
|
|
659
|
+
@logger.debug('=== Event Data Debug Information ===')
|
|
420
660
|
events.each_with_index do |event, index|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
661
|
+
@logger.debug("Event #{index + 1}:")
|
|
662
|
+
@logger.debug(" ID: #{event[:id]}")
|
|
663
|
+
@logger.debug(" Type: #{event[:type]}")
|
|
664
|
+
@logger.debug(" Timestamp: #{event[:timestamp]}")
|
|
665
|
+
@logger.debug(" Body keys: #{event[:body]&.keys || 'nil'}")
|
|
426
666
|
|
|
427
667
|
# 检查常见的问题
|
|
428
|
-
|
|
668
|
+
@logger.debug(' ⚠️ WARNING: Empty or nil type!') if event[:type].nil? || event[:type].to_s.empty?
|
|
429
669
|
|
|
430
|
-
|
|
670
|
+
@logger.debug(' ⚠️ WARNING: Empty body!') if event[:body].nil?
|
|
431
671
|
|
|
432
|
-
|
|
672
|
+
@logger.debug(' ---')
|
|
433
673
|
end
|
|
434
|
-
|
|
674
|
+
@logger.debug('=== End Debug Information ===')
|
|
435
675
|
end
|
|
436
676
|
|
|
437
677
|
def send_batch(events)
|
|
@@ -441,10 +681,10 @@ module Langfuse
|
|
|
441
681
|
# 验证事件数据
|
|
442
682
|
valid_events = events.select do |event|
|
|
443
683
|
if event[:type].nil? || event[:type].to_s.empty?
|
|
444
|
-
|
|
684
|
+
@logger.debug("Warning: Event with empty type detected, skipping: #{event[:id]}")
|
|
445
685
|
false
|
|
446
686
|
elsif event[:body].nil?
|
|
447
|
-
|
|
687
|
+
@logger.debug("Warning: Event with empty body detected, skipping: #{event[:id]}")
|
|
448
688
|
false
|
|
449
689
|
else
|
|
450
690
|
true
|
|
@@ -452,7 +692,7 @@ module Langfuse
|
|
|
452
692
|
end
|
|
453
693
|
|
|
454
694
|
if valid_events.empty?
|
|
455
|
-
|
|
695
|
+
@logger.debug('No valid events to send')
|
|
456
696
|
return
|
|
457
697
|
end
|
|
458
698
|
|
|
@@ -464,32 +704,196 @@ module Langfuse
|
|
|
464
704
|
end
|
|
465
705
|
|
|
466
706
|
def send_batch_legacy(valid_events)
|
|
467
|
-
|
|
468
|
-
|
|
707
|
+
payload = encode_batch(valid_events)
|
|
708
|
+
|
|
709
|
+
# Common case: the whole batch fits, so this single JSON pass covers both
|
|
710
|
+
# the size check and the request body. Only an oversized payload pays for
|
|
711
|
+
# the per-event accounting in chunk_events.
|
|
712
|
+
if payload && payload.bytesize <= MAX_BATCH_SIZE_BYTES
|
|
713
|
+
begin
|
|
714
|
+
return post_ingestion(payload, valid_events.length)
|
|
715
|
+
rescue StandardError => e
|
|
716
|
+
@logger.debug { "Failed to flush events: #{e.message}" }
|
|
717
|
+
requeue_events(valid_events, e)
|
|
718
|
+
raise
|
|
719
|
+
end
|
|
720
|
+
end
|
|
469
721
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
722
|
+
send_batch_legacy_chunked(valid_events)
|
|
723
|
+
end
|
|
724
|
+
|
|
725
|
+
def send_batch_legacy_chunked(valid_events)
|
|
726
|
+
chunks = chunk_events(valid_events)
|
|
727
|
+
response = nil
|
|
728
|
+
|
|
729
|
+
chunks.each_with_index do |chunk, index|
|
|
730
|
+
response = post_ingestion(encode_batch(chunk) || build_batch_data(chunk), chunk.length)
|
|
474
731
|
rescue StandardError => e
|
|
475
|
-
|
|
476
|
-
|
|
732
|
+
@logger.debug { "Failed to flush events: #{e.message}" }
|
|
733
|
+
requeue_events(chunks[index..].flatten(1), e)
|
|
477
734
|
raise
|
|
478
735
|
end
|
|
736
|
+
|
|
737
|
+
response
|
|
738
|
+
end
|
|
739
|
+
|
|
740
|
+
# Faraday forwards a String body untouched, so a pre-serialized batch is not
|
|
741
|
+
# encoded a second time by the JSON middleware.
|
|
742
|
+
def encode_batch(events)
|
|
743
|
+
JSON.generate(build_batch_data(events))
|
|
744
|
+
rescue StandardError => e
|
|
745
|
+
@logger.debug { "Could not pre-serialize the batch, letting Faraday encode it: #{e.message}" }
|
|
746
|
+
nil
|
|
747
|
+
end
|
|
748
|
+
|
|
749
|
+
def post_ingestion(payload, event_count)
|
|
750
|
+
# Block form: interpolating a multi-megabyte batch would cost the same
|
|
751
|
+
# whether or not debug logging is enabled.
|
|
752
|
+
@logger.debug { "Sending batch data: #{payload}" }
|
|
753
|
+
|
|
754
|
+
response = post('/api/public/ingestion', payload)
|
|
755
|
+
log_ingestion_errors(response)
|
|
756
|
+
@logger.debug { "Flushed #{event_count} events (legacy)" }
|
|
757
|
+
response
|
|
479
758
|
end
|
|
480
759
|
|
|
481
760
|
def send_batch_otel(valid_events)
|
|
482
|
-
|
|
761
|
+
score_events, otel_events = valid_events.partition { |event| event[:type] == 'score-create' }
|
|
762
|
+
|
|
763
|
+
response = nil
|
|
764
|
+
|
|
765
|
+
unless otel_events.empty?
|
|
766
|
+
@logger.debug { "Sending #{otel_events.length} events via OTEL" }
|
|
767
|
+
chunks = chunk_events(otel_events)
|
|
768
|
+
|
|
769
|
+
chunks.each_with_index do |chunk, index|
|
|
770
|
+
response = export_otel_chunk(chunk)
|
|
771
|
+
rescue StandardError => e
|
|
772
|
+
@logger.debug { "Failed to flush OTEL events: #{e.message}" }
|
|
773
|
+
# Re-queue the not-yet-sent OTel chunks. Permanent failures (4xx)
|
|
774
|
+
# are dropped by requeue_events; transient ones are re-queued.
|
|
775
|
+
requeue_events(chunks[index..].flatten(1), e)
|
|
776
|
+
# Score events were never attempted and must always be re-queued,
|
|
777
|
+
# regardless of why the OTel chunk failed.
|
|
778
|
+
unless score_events.empty?
|
|
779
|
+
@queue_mutex.synchronize { score_events.each { |ev| prepend_event(ev) } }
|
|
780
|
+
end
|
|
781
|
+
raise
|
|
782
|
+
end
|
|
783
|
+
end
|
|
784
|
+
|
|
785
|
+
# Scores are not part of the OTLP trace mapping; they always go through
|
|
786
|
+
# the ingestion API. IDs are normalized to match the OTel-derived IDs.
|
|
787
|
+
unless score_events.empty?
|
|
788
|
+
score_events.each { |event| normalize_otel_score_event(event) }
|
|
789
|
+
response = send_batch_legacy(score_events)
|
|
790
|
+
end
|
|
791
|
+
|
|
792
|
+
response
|
|
793
|
+
end
|
|
794
|
+
|
|
795
|
+
# Export one chunk of events to the OTLP endpoint with retries for transient errors.
|
|
796
|
+
def export_otel_chunk(chunk, retries: nil)
|
|
797
|
+
allowed_retries = retries || @retries
|
|
798
|
+
attempt = 0
|
|
799
|
+
response = nil
|
|
483
800
|
|
|
484
801
|
begin
|
|
485
|
-
response =
|
|
802
|
+
response = execute_otel_export(chunk)
|
|
486
803
|
handle_response(response)
|
|
487
|
-
|
|
804
|
+
log_otel_partial_success(response)
|
|
805
|
+
@logger.debug { "Flushed #{chunk.length} events (otel)" }
|
|
488
806
|
response
|
|
489
|
-
rescue
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
807
|
+
rescue Langfuse::Error => e
|
|
808
|
+
raise unless attempt < allowed_retries && retryable_error?(e, response)
|
|
809
|
+
|
|
810
|
+
attempt += 1
|
|
811
|
+
delay = retry_delay(attempt, response)
|
|
812
|
+
@logger.debug { "Retrying OTEL export in #{delay.round(2)}s (attempt #{attempt}/#{allowed_retries}): #{e.message}" }
|
|
813
|
+
response = nil
|
|
814
|
+
sleep(delay)
|
|
815
|
+
retry
|
|
816
|
+
end
|
|
817
|
+
end
|
|
818
|
+
|
|
819
|
+
def execute_otel_export(chunk)
|
|
820
|
+
@otel_exporter.export(chunk)
|
|
821
|
+
rescue Faraday::TimeoutError => e
|
|
822
|
+
raise TimeoutError, "Request timed out: #{e.message}"
|
|
823
|
+
rescue Faraday::ConnectionFailed => e
|
|
824
|
+
raise NetworkError, "Connection failed: #{e.message}"
|
|
825
|
+
rescue Faraday::Error => e
|
|
826
|
+
raise APIError, "OTEL export failed: #{e.message}"
|
|
827
|
+
end
|
|
828
|
+
|
|
829
|
+
# The OTLP endpoint answers 200 even when it rejected part of the payload.
|
|
830
|
+
def log_otel_partial_success(response)
|
|
831
|
+
body = response.respond_to?(:body) ? response.body : nil
|
|
832
|
+
return unless body.is_a?(Hash)
|
|
833
|
+
|
|
834
|
+
partial = body['partialSuccess'] || body[:partialSuccess]
|
|
835
|
+
return unless partial.is_a?(Hash)
|
|
836
|
+
|
|
837
|
+
rejected = (partial['rejectedSpans'] || partial[:rejectedSpans]).to_i
|
|
838
|
+
message = (partial['errorMessage'] || partial[:errorMessage]).to_s
|
|
839
|
+
return if rejected.zero? && message.empty?
|
|
840
|
+
|
|
841
|
+
details = " - #{message}" unless message.empty?
|
|
842
|
+
@logger.warn("Langfuse OTEL partial success: #{rejected} spans rejected#{details}")
|
|
843
|
+
end
|
|
844
|
+
|
|
845
|
+
# Align score references with the OTel-derived trace/span IDs so scores
|
|
846
|
+
# attach to the correct entities when ingesting via the OTel endpoint.
|
|
847
|
+
def normalize_otel_score_event(event)
|
|
848
|
+
body = event[:body]
|
|
849
|
+
return unless body.is_a?(Hash)
|
|
850
|
+
|
|
851
|
+
body['traceId'] = OtelExporter.to_otel_trace_id(body['traceId']) if body['traceId']
|
|
852
|
+
body['observationId'] = OtelExporter.to_otel_span_id(body['observationId']) if body['observationId']
|
|
853
|
+
end
|
|
854
|
+
|
|
855
|
+
# Split events into chunks that respect the ingestion API batch size limit.
|
|
856
|
+
def chunk_events(events)
|
|
857
|
+
chunks = [[]]
|
|
858
|
+
current_size = 0
|
|
859
|
+
|
|
860
|
+
events.each do |event|
|
|
861
|
+
event_size = estimated_event_size(event)
|
|
862
|
+
|
|
863
|
+
if event_size > MAX_BATCH_SIZE_BYTES
|
|
864
|
+
@logger.warn("Langfuse event #{event[:id]} exceeds the maximum batch size of #{MAX_BATCH_SIZE_BYTES} bytes and was dropped")
|
|
865
|
+
next
|
|
866
|
+
end
|
|
867
|
+
|
|
868
|
+
if current_size + event_size > MAX_BATCH_SIZE_BYTES && !chunks.last.empty?
|
|
869
|
+
chunks << []
|
|
870
|
+
current_size = 0
|
|
871
|
+
end
|
|
872
|
+
|
|
873
|
+
chunks.last << event
|
|
874
|
+
current_size += event_size
|
|
875
|
+
end
|
|
876
|
+
|
|
877
|
+
chunks.reject(&:empty?)
|
|
878
|
+
end
|
|
879
|
+
|
|
880
|
+
def estimated_event_size(event)
|
|
881
|
+
JSON.generate(event).bytesize
|
|
882
|
+
rescue StandardError
|
|
883
|
+
1024
|
|
884
|
+
end
|
|
885
|
+
|
|
886
|
+
# The ingestion API responds with 207 and per-event successes/errors.
|
|
887
|
+
def log_ingestion_errors(response)
|
|
888
|
+
body = response.respond_to?(:body) ? response.body : nil
|
|
889
|
+
return unless body.is_a?(Hash)
|
|
890
|
+
|
|
891
|
+
errors = body['errors']
|
|
892
|
+
return unless errors.is_a?(Array) && errors.any?
|
|
893
|
+
|
|
894
|
+
errors.each do |error|
|
|
895
|
+
@logger.warn("Langfuse ingestion partial failure (status #{error['status']}): " \
|
|
896
|
+
"event #{error['id']} - #{error['message']}")
|
|
493
897
|
end
|
|
494
898
|
end
|
|
495
899
|
|
|
@@ -507,25 +911,38 @@ module Langfuse
|
|
|
507
911
|
def start_flush_thread
|
|
508
912
|
return unless @auto_flush
|
|
509
913
|
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
914
|
+
@stop_flushing = false
|
|
915
|
+
@flush_thread_pid = Process.pid
|
|
916
|
+
@flush_thread = Thread.new do
|
|
917
|
+
until @stop_flushing
|
|
918
|
+
# Wait for the flush interval or an early wake-up (flush_at threshold)
|
|
919
|
+
@flush_mutex.synchronize { @flush_condition.wait(@flush_mutex, @flush_interval) }
|
|
920
|
+
break if @stop_flushing
|
|
921
|
+
|
|
513
922
|
begin
|
|
514
923
|
flush unless @event_queue.empty?
|
|
515
924
|
rescue StandardError => e
|
|
516
|
-
|
|
925
|
+
@logger.debug { "Error in flush thread: #{e.message}" }
|
|
517
926
|
end
|
|
518
927
|
end
|
|
519
928
|
end
|
|
520
929
|
end
|
|
521
930
|
|
|
522
931
|
def resolve_ingestion_mode(explicit_mode)
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
return
|
|
527
|
-
|
|
528
|
-
|
|
932
|
+
env_mode = ENV.fetch('LANGFUSE_INGESTION_MODE', nil)
|
|
933
|
+
env_mode = nil if env_mode&.empty?
|
|
934
|
+
mode = explicit_mode || env_mode || Langfuse.configuration.ingestion_mode
|
|
935
|
+
return :legacy if mode.nil?
|
|
936
|
+
|
|
937
|
+
# Unrecognized values used to silently behave like :legacy, so a typo in
|
|
938
|
+
# LANGFUSE_INGESTION_MODE looked like a working v4 setup.
|
|
939
|
+
mode = mode.to_s.downcase.to_sym
|
|
940
|
+
return mode if INGESTION_MODES.include?(mode)
|
|
941
|
+
|
|
942
|
+
@logger.warn do
|
|
943
|
+
"Unknown Langfuse ingestion_mode #{mode.inspect}, expected one of #{INGESTION_MODES.join(', ')}. Using :legacy."
|
|
944
|
+
end
|
|
945
|
+
:legacy
|
|
529
946
|
end
|
|
530
947
|
|
|
531
948
|
def build_connection
|
|
@@ -546,11 +963,24 @@ module Langfuse
|
|
|
546
963
|
# 添加调试日志
|
|
547
964
|
conn.response :logger if @debug
|
|
548
965
|
|
|
549
|
-
|
|
550
|
-
conn.adapter Faraday.default_adapter
|
|
966
|
+
apply_http_adapter(conn)
|
|
551
967
|
end
|
|
552
968
|
end
|
|
553
969
|
|
|
970
|
+
# The default net_http adapter opens and closes a connection per request.
|
|
971
|
+
# `http_adapter` lets an application swap in a keep-alive adapter (for
|
|
972
|
+
# example `:net_http_persistent`) without this gem depending on it; an
|
|
973
|
+
# adapter that is not installed falls back instead of breaking tracing.
|
|
974
|
+
def apply_http_adapter(conn)
|
|
975
|
+
return conn.adapter(Faraday.default_adapter) if @http_adapter.nil?
|
|
976
|
+
|
|
977
|
+
conn.adapter(*Array(@http_adapter))
|
|
978
|
+
rescue StandardError => e
|
|
979
|
+
@logger.warn("Langfuse could not use the #{@http_adapter.inspect} Faraday adapter " \
|
|
980
|
+
"(#{e.message}); falling back to #{Faraday.default_adapter.inspect}")
|
|
981
|
+
conn.adapter(Faraday.default_adapter)
|
|
982
|
+
end
|
|
983
|
+
|
|
554
984
|
# Build a separate Faraday connection for OTEL with the v4 ingestion header.
|
|
555
985
|
def build_otel_connection
|
|
556
986
|
Faraday.new(url: @host) do |conn|
|
|
@@ -563,7 +993,7 @@ module Langfuse
|
|
|
563
993
|
|
|
564
994
|
conn.options.timeout = @timeout
|
|
565
995
|
conn.response :logger if @debug
|
|
566
|
-
conn
|
|
996
|
+
apply_http_adapter(conn)
|
|
567
997
|
end
|
|
568
998
|
end
|
|
569
999
|
|
|
@@ -588,33 +1018,84 @@ module Langfuse
|
|
|
588
1018
|
request(:patch, path, json: data)
|
|
589
1019
|
end
|
|
590
1020
|
|
|
591
|
-
def request(method, path, params: {}, json: nil)
|
|
592
|
-
|
|
1021
|
+
def request(method, path, params: {}, json: nil, retries: nil)
|
|
1022
|
+
allowed_retries = retries || @retries
|
|
1023
|
+
attempt = 0
|
|
1024
|
+
response = nil
|
|
593
1025
|
|
|
594
1026
|
begin
|
|
595
|
-
response =
|
|
596
|
-
req.url path
|
|
597
|
-
req.params = params if params.any?
|
|
598
|
-
req.body = json if json
|
|
599
|
-
end
|
|
600
|
-
|
|
1027
|
+
response = execute_request(method, path, params, json)
|
|
601
1028
|
handle_response(response)
|
|
602
|
-
rescue
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
1029
|
+
rescue Langfuse::Error => e
|
|
1030
|
+
# Typed errors raised by handle_response (401/404/429/4xx/5xx) keep their
|
|
1031
|
+
# class so callers can rescue AuthenticationError/RateLimitError etc.
|
|
1032
|
+
raise unless attempt < allowed_retries && retryable_error?(e, response)
|
|
1033
|
+
|
|
1034
|
+
attempt += 1
|
|
1035
|
+
delay = retry_delay(attempt, response)
|
|
1036
|
+
@logger.debug { "Retrying #{method.upcase} #{path} in #{delay.round(2)}s (attempt #{attempt}/#{allowed_retries}): #{e.message}" }
|
|
1037
|
+
response = nil
|
|
1038
|
+
sleep(delay)
|
|
1039
|
+
retry
|
|
611
1040
|
rescue StandardError => e
|
|
612
1041
|
raise APIError, "Request failed: #{e.message}"
|
|
613
1042
|
end
|
|
614
1043
|
end
|
|
615
1044
|
|
|
1045
|
+
def execute_request(method, path, params, json)
|
|
1046
|
+
@connection.send(method) do |req|
|
|
1047
|
+
req.url path
|
|
1048
|
+
req.params = params if params.any?
|
|
1049
|
+
req.body = json if json
|
|
1050
|
+
end
|
|
1051
|
+
rescue Faraday::TimeoutError => e
|
|
1052
|
+
raise TimeoutError, "Request timed out: #{e.message}"
|
|
1053
|
+
rescue Faraday::ConnectionFailed => e
|
|
1054
|
+
raise NetworkError, "Connection failed: #{e.message}"
|
|
1055
|
+
end
|
|
1056
|
+
|
|
1057
|
+
# Transient failures worth another attempt. Authentication and validation
|
|
1058
|
+
# errors would fail identically on a retry, so they are raised immediately.
|
|
1059
|
+
def retryable_error?(error, response)
|
|
1060
|
+
case error
|
|
1061
|
+
when TimeoutError, NetworkError, RateLimitError
|
|
1062
|
+
true
|
|
1063
|
+
when APIError
|
|
1064
|
+
response.respond_to?(:status) && response.status >= 500
|
|
1065
|
+
else
|
|
1066
|
+
false
|
|
1067
|
+
end
|
|
1068
|
+
end
|
|
1069
|
+
|
|
1070
|
+
# Honor the server's Retry-After when present, otherwise back off
|
|
1071
|
+
# exponentially with jitter.
|
|
1072
|
+
def retry_delay(attempt, response)
|
|
1073
|
+
server_delay = retry_after_seconds(response)
|
|
1074
|
+
return server_delay if server_delay
|
|
1075
|
+
|
|
1076
|
+
backoff = [RETRY_BASE_DELAY_SECONDS * (2**(attempt - 1)), MAX_RETRY_DELAY_SECONDS].min
|
|
1077
|
+
backoff * (0.5 + (rand * 0.5))
|
|
1078
|
+
end
|
|
1079
|
+
|
|
1080
|
+
def retry_after_seconds(response)
|
|
1081
|
+
raw = response.respond_to?(:headers) ? response.headers&.[]('retry-after') : nil
|
|
1082
|
+
return nil if raw.nil? || raw.to_s.strip.empty?
|
|
1083
|
+
|
|
1084
|
+
seconds = Float(raw, exception: false) || http_date_delay(raw)
|
|
1085
|
+
return nil unless seconds
|
|
1086
|
+
|
|
1087
|
+
seconds.clamp(0, MAX_RETRY_DELAY_SECONDS)
|
|
1088
|
+
end
|
|
1089
|
+
|
|
1090
|
+
# Retry-After may also be an HTTP date instead of a number of seconds.
|
|
1091
|
+
def http_date_delay(raw)
|
|
1092
|
+
Time.httpdate(raw.to_s) - Time.now
|
|
1093
|
+
rescue ArgumentError
|
|
1094
|
+
nil
|
|
1095
|
+
end
|
|
1096
|
+
|
|
616
1097
|
def handle_response(response)
|
|
617
|
-
|
|
1098
|
+
@logger.debug("Handling response with status: #{response.status}")
|
|
618
1099
|
|
|
619
1100
|
case response.status
|
|
620
1101
|
when 200..299
|