langfuse-ruby 0.2.0 → 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/.rubocop.yml +1 -1
- data/CHANGELOG.md +44 -0
- data/CLAUDE.md +40 -14
- data/Gemfile.lock +1 -1
- data/Makefile +1 -4
- data/README.md +216 -99
- 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/lib/langfuse/client.rb +457 -247
- data/lib/langfuse/event.rb +1 -17
- data/lib/langfuse/generation.rb +35 -120
- data/lib/langfuse/null_objects.rb +4 -0
- data/lib/langfuse/otel_exporter.rb +101 -11
- 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 +28 -153
- data/lib/langfuse/span_wrappers.rb +32 -0
- data/lib/langfuse/template_compiler.rb +56 -0
- data/lib/langfuse/trace.rb +26 -162
- data/lib/langfuse/utils.rb +28 -26
- data/lib/langfuse/version.rb +1 -1
- data/lib/langfuse.rb +49 -52
- data/scripts/release.sh +12 -12
- metadata +8 -2
data/lib/langfuse/event.rb
CHANGED
|
@@ -57,23 +57,7 @@ module Langfuse
|
|
|
57
57
|
end
|
|
58
58
|
|
|
59
59
|
def create_event
|
|
60
|
-
|
|
61
|
-
id: @id,
|
|
62
|
-
trace_id: @trace_id,
|
|
63
|
-
name: @name,
|
|
64
|
-
start_time: @start_time,
|
|
65
|
-
input: @input,
|
|
66
|
-
output: @output,
|
|
67
|
-
metadata: @metadata,
|
|
68
|
-
level: @level,
|
|
69
|
-
status_message: @status_message,
|
|
70
|
-
parent_observation_id: @parent_observation_id,
|
|
71
|
-
version: @version
|
|
72
|
-
}
|
|
73
|
-
data[:type] = @as_type if @as_type
|
|
74
|
-
data = data.merge(@kwargs).compact
|
|
75
|
-
|
|
76
|
-
@client.enqueue_event('event-create', data)
|
|
60
|
+
@client.enqueue_event('event-create', to_dict)
|
|
77
61
|
end
|
|
78
62
|
end
|
|
79
63
|
end
|
data/lib/langfuse/generation.rb
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
module Langfuse
|
|
4
4
|
class Generation
|
|
5
|
+
include PartialUpdates
|
|
6
|
+
|
|
5
7
|
attr_reader :id, :trace_id, :name, :start_time, :end_time, :completion_start_time,
|
|
6
8
|
:model, :model_parameters, :input, :output, :usage, :usage_details, :cost_details,
|
|
7
9
|
:prompt_name, :prompt_version, :metadata, :level,
|
|
@@ -43,35 +45,48 @@ module Langfuse
|
|
|
43
45
|
model_parameters: nil, input: nil, output: nil, usage: nil,
|
|
44
46
|
usage_details: nil, cost_details: nil, prompt: nil, metadata: nil,
|
|
45
47
|
level: nil, status_message: nil, version: nil, **kwargs)
|
|
46
|
-
@name = name
|
|
47
|
-
@end_time = end_time
|
|
48
|
-
@completion_start_time = completion_start_time
|
|
49
|
-
@model = model
|
|
48
|
+
@name = name unless name.nil?
|
|
49
|
+
@end_time = end_time unless end_time.nil?
|
|
50
|
+
@completion_start_time = completion_start_time unless completion_start_time.nil?
|
|
51
|
+
@model = model unless model.nil?
|
|
50
52
|
@model_parameters.merge!(model_parameters) if model_parameters
|
|
51
|
-
@input = input
|
|
52
|
-
@output = output
|
|
53
|
+
@input = input unless input.nil?
|
|
54
|
+
@output = output unless output.nil?
|
|
53
55
|
@usage.merge!(usage) if usage
|
|
54
56
|
@usage_details.merge!(usage_details) if usage_details
|
|
55
57
|
@cost_details.merge!(cost_details) if cost_details
|
|
56
58
|
@prompt_name, @prompt_version = extract_prompt_info(prompt) if prompt
|
|
57
59
|
@metadata.merge!(metadata) if metadata
|
|
58
|
-
@level = level
|
|
59
|
-
@status_message = status_message
|
|
60
|
-
@version = version
|
|
60
|
+
@level = level unless level.nil?
|
|
61
|
+
@status_message = status_message unless status_message.nil?
|
|
62
|
+
@version = version unless version.nil?
|
|
61
63
|
@kwargs.merge!(kwargs)
|
|
62
64
|
|
|
65
|
+
changes = { name: name, end_time: end_time, completion_start_time: completion_start_time,
|
|
66
|
+
model: model, model_parameters: model_parameters, input: input, output: output,
|
|
67
|
+
usage: usage, usage_details: usage_details, cost_details: cost_details,
|
|
68
|
+
metadata: metadata, level: level, status_message: status_message,
|
|
69
|
+
version: version }
|
|
70
|
+
changes.merge!(prompt_name: @prompt_name, prompt_version: @prompt_version) if prompt
|
|
71
|
+
|
|
72
|
+
track_changes(changes, kwargs.keys)
|
|
63
73
|
update_generation
|
|
64
74
|
self
|
|
65
75
|
end
|
|
66
76
|
|
|
67
77
|
def end(output: nil, end_time: nil, usage: nil, usage_details: nil, cost_details: nil, **kwargs)
|
|
68
78
|
@end_time = end_time || Utils.current_timestamp
|
|
69
|
-
@output = output
|
|
79
|
+
@output = output unless output.nil?
|
|
70
80
|
@usage.merge!(usage) if usage
|
|
71
81
|
@usage_details.merge!(usage_details) if usage_details
|
|
72
82
|
@cost_details.merge!(cost_details) if cost_details
|
|
73
83
|
@kwargs.merge!(kwargs)
|
|
74
84
|
|
|
85
|
+
track_changes(
|
|
86
|
+
{ end_time: @end_time, output: output, usage: usage,
|
|
87
|
+
usage_details: usage_details, cost_details: cost_details },
|
|
88
|
+
kwargs.keys
|
|
89
|
+
)
|
|
75
90
|
update_generation
|
|
76
91
|
self
|
|
77
92
|
end
|
|
@@ -99,6 +114,7 @@ module Langfuse
|
|
|
99
114
|
# Create a child generation
|
|
100
115
|
def generation(name: nil, start_time: nil, end_time: nil, completion_start_time: nil,
|
|
101
116
|
model: nil, model_parameters: nil, input: nil, output: nil, usage: nil,
|
|
117
|
+
usage_details: nil, cost_details: nil, prompt: nil,
|
|
102
118
|
metadata: nil, level: nil, status_message: nil, version: nil, **kwargs)
|
|
103
119
|
@client.generation(
|
|
104
120
|
trace_id: @trace_id,
|
|
@@ -111,6 +127,9 @@ module Langfuse
|
|
|
111
127
|
input: input,
|
|
112
128
|
output: output,
|
|
113
129
|
usage: usage,
|
|
130
|
+
usage_details: usage_details,
|
|
131
|
+
cost_details: cost_details,
|
|
132
|
+
prompt: prompt,
|
|
114
133
|
metadata: metadata,
|
|
115
134
|
level: level,
|
|
116
135
|
status_message: status_message,
|
|
@@ -138,79 +157,11 @@ module Langfuse
|
|
|
138
157
|
)
|
|
139
158
|
end
|
|
140
159
|
|
|
141
|
-
# Convenience methods for enhanced observation types
|
|
142
|
-
|
|
143
|
-
#
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
span(
|
|
147
|
-
name: name,
|
|
148
|
-
start_time: start_time,
|
|
149
|
-
end_time: end_time,
|
|
150
|
-
input: input,
|
|
151
|
-
output: output,
|
|
152
|
-
metadata: metadata,
|
|
153
|
-
level: level,
|
|
154
|
-
status_message: status_message,
|
|
155
|
-
version: version,
|
|
156
|
-
as_type: ObservationType::AGENT,
|
|
157
|
-
**kwargs
|
|
158
|
-
)
|
|
159
|
-
end
|
|
160
|
-
|
|
161
|
-
# Create a child tool observation
|
|
162
|
-
def tool(name: nil, start_time: nil, end_time: nil, input: nil, output: nil,
|
|
163
|
-
metadata: nil, level: nil, status_message: nil, version: nil, **kwargs)
|
|
164
|
-
span(
|
|
165
|
-
name: name,
|
|
166
|
-
start_time: start_time,
|
|
167
|
-
end_time: end_time,
|
|
168
|
-
input: input,
|
|
169
|
-
output: output,
|
|
170
|
-
metadata: metadata,
|
|
171
|
-
level: level,
|
|
172
|
-
status_message: status_message,
|
|
173
|
-
version: version,
|
|
174
|
-
as_type: ObservationType::TOOL,
|
|
175
|
-
**kwargs
|
|
176
|
-
)
|
|
177
|
-
end
|
|
178
|
-
|
|
179
|
-
# Create a child chain observation
|
|
180
|
-
def chain(name: nil, start_time: nil, end_time: nil, input: nil, output: nil,
|
|
181
|
-
metadata: nil, level: nil, status_message: nil, version: nil, **kwargs)
|
|
182
|
-
span(
|
|
183
|
-
name: name,
|
|
184
|
-
start_time: start_time,
|
|
185
|
-
end_time: end_time,
|
|
186
|
-
input: input,
|
|
187
|
-
output: output,
|
|
188
|
-
metadata: metadata,
|
|
189
|
-
level: level,
|
|
190
|
-
status_message: status_message,
|
|
191
|
-
version: version,
|
|
192
|
-
as_type: ObservationType::CHAIN,
|
|
193
|
-
**kwargs
|
|
194
|
-
)
|
|
195
|
-
end
|
|
196
|
-
|
|
197
|
-
# Create a child retriever observation
|
|
198
|
-
def retriever(name: nil, start_time: nil, end_time: nil, input: nil, output: nil,
|
|
199
|
-
metadata: nil, level: nil, status_message: nil, version: nil, **kwargs)
|
|
200
|
-
span(
|
|
201
|
-
name: name,
|
|
202
|
-
start_time: start_time,
|
|
203
|
-
end_time: end_time,
|
|
204
|
-
input: input,
|
|
205
|
-
output: output,
|
|
206
|
-
metadata: metadata,
|
|
207
|
-
level: level,
|
|
208
|
-
status_message: status_message,
|
|
209
|
-
version: version,
|
|
210
|
-
as_type: ObservationType::RETRIEVER,
|
|
211
|
-
**kwargs
|
|
212
|
-
)
|
|
213
|
-
end
|
|
160
|
+
# Convenience methods for enhanced observation types: each is a child span
|
|
161
|
+
# with a fixed as_type. (embedding keeps its own definition because it folds
|
|
162
|
+
# model/usage into metadata first.)
|
|
163
|
+
extend SpanWrappers
|
|
164
|
+
define_span_wrappers
|
|
214
165
|
|
|
215
166
|
# Create a child embedding observation
|
|
216
167
|
def embedding(name: nil, start_time: nil, end_time: nil, input: nil, output: nil,
|
|
@@ -234,42 +185,6 @@ module Langfuse
|
|
|
234
185
|
)
|
|
235
186
|
end
|
|
236
187
|
|
|
237
|
-
# Create a child evaluator observation
|
|
238
|
-
def evaluator(name: nil, start_time: nil, end_time: nil, input: nil, output: nil,
|
|
239
|
-
metadata: nil, level: nil, status_message: nil, version: nil, **kwargs)
|
|
240
|
-
span(
|
|
241
|
-
name: name,
|
|
242
|
-
start_time: start_time,
|
|
243
|
-
end_time: end_time,
|
|
244
|
-
input: input,
|
|
245
|
-
output: output,
|
|
246
|
-
metadata: metadata,
|
|
247
|
-
level: level,
|
|
248
|
-
status_message: status_message,
|
|
249
|
-
version: version,
|
|
250
|
-
as_type: ObservationType::EVALUATOR,
|
|
251
|
-
**kwargs
|
|
252
|
-
)
|
|
253
|
-
end
|
|
254
|
-
|
|
255
|
-
# Create a child guardrail observation
|
|
256
|
-
def guardrail(name: nil, start_time: nil, end_time: nil, input: nil, output: nil,
|
|
257
|
-
metadata: nil, level: nil, status_message: nil, version: nil, **kwargs)
|
|
258
|
-
span(
|
|
259
|
-
name: name,
|
|
260
|
-
start_time: start_time,
|
|
261
|
-
end_time: end_time,
|
|
262
|
-
input: input,
|
|
263
|
-
output: output,
|
|
264
|
-
metadata: metadata,
|
|
265
|
-
level: level,
|
|
266
|
-
status_message: status_message,
|
|
267
|
-
version: version,
|
|
268
|
-
as_type: ObservationType::GUARDRAIL,
|
|
269
|
-
**kwargs
|
|
270
|
-
)
|
|
271
|
-
end
|
|
272
|
-
|
|
273
188
|
def score(name:, value:, data_type: nil, comment: nil, **kwargs)
|
|
274
189
|
@client.score(
|
|
275
190
|
trace_id: @trace_id,
|
|
@@ -345,7 +260,7 @@ module Langfuse
|
|
|
345
260
|
end
|
|
346
261
|
|
|
347
262
|
def update_generation
|
|
348
|
-
@client.enqueue_event('generation-update',
|
|
263
|
+
@client.enqueue_event('generation-update', update_body)
|
|
349
264
|
end
|
|
350
265
|
end
|
|
351
266
|
end
|
|
@@ -15,6 +15,7 @@ module Langfuse
|
|
|
15
15
|
def retriever(**) = NullSpan.new
|
|
16
16
|
def embedding(**) = NullSpan.new
|
|
17
17
|
def evaluator(**) = NullSpan.new
|
|
18
|
+
alias evaluator_obs evaluator
|
|
18
19
|
def guardrail(**) = NullSpan.new
|
|
19
20
|
def score(**) = nil
|
|
20
21
|
def get_url = nil
|
|
@@ -36,6 +37,7 @@ module Langfuse
|
|
|
36
37
|
def retriever(**) = NullSpan.new
|
|
37
38
|
def embedding(**) = NullSpan.new
|
|
38
39
|
def evaluator(**) = NullSpan.new
|
|
40
|
+
alias evaluator_obs evaluator
|
|
39
41
|
def guardrail(**) = NullSpan.new
|
|
40
42
|
def score(**) = nil
|
|
41
43
|
def get_url = nil
|
|
@@ -65,10 +67,12 @@ module Langfuse
|
|
|
65
67
|
def retriever(**) = NullSpan.new
|
|
66
68
|
def embedding(**) = NullSpan.new
|
|
67
69
|
def evaluator(**) = NullSpan.new
|
|
70
|
+
alias evaluator_obs evaluator
|
|
68
71
|
def guardrail(**) = NullSpan.new
|
|
69
72
|
def score(**) = nil
|
|
70
73
|
def get_url = nil
|
|
71
74
|
def to_dict = {}
|
|
72
75
|
def id = nil
|
|
76
|
+
def trace_id = nil
|
|
73
77
|
end
|
|
74
78
|
end
|
|
@@ -9,6 +9,15 @@ module Langfuse
|
|
|
9
9
|
class OtelExporter
|
|
10
10
|
OTEL_ENDPOINT = '/api/public/otel/v1/traces'
|
|
11
11
|
|
|
12
|
+
# Event types that carry the full state of an observation on every emit, so a
|
|
13
|
+
# later event supersedes the earlier one for the same observation id.
|
|
14
|
+
OBSERVATION_EVENT_TYPES = %w[span-create span-update generation-create generation-update].freeze
|
|
15
|
+
|
|
16
|
+
# Token keys accepted on the legacy `usage` object, in priority order.
|
|
17
|
+
USAGE_INPUT_KEYS = %w[promptTokens prompt_tokens inputTokens input_tokens input].freeze
|
|
18
|
+
USAGE_OUTPUT_KEYS = %w[completionTokens completion_tokens outputTokens output_tokens output].freeze
|
|
19
|
+
USAGE_TOTAL_KEYS = %w[totalTokens total_tokens total].freeze
|
|
20
|
+
|
|
12
21
|
class << self
|
|
13
22
|
# Convert an ID (UUID or hex string) to an OTEL 32-char hex trace ID.
|
|
14
23
|
# OTEL trace IDs are 16 bytes (32 hex chars). Native hex IDs pass through unchanged.
|
|
@@ -70,7 +79,7 @@ module Langfuse
|
|
|
70
79
|
# Build the top-level resourceSpans array from events.
|
|
71
80
|
# Groups events by trace_id, producing one scopeSpan per trace.
|
|
72
81
|
def build_resource_spans(events)
|
|
73
|
-
grouped = group_events_by_trace(events)
|
|
82
|
+
grouped = group_events_by_trace(collapse_observation_events(events))
|
|
74
83
|
|
|
75
84
|
scope_spans = grouped.map do |_trace_id, trace_events|
|
|
76
85
|
spans = trace_events.filter_map { |event| convert_event_to_span(event) }
|
|
@@ -93,6 +102,40 @@ module Langfuse
|
|
|
93
102
|
}]
|
|
94
103
|
end
|
|
95
104
|
|
|
105
|
+
# Collapse the create/update events of one observation into a single event.
|
|
106
|
+
# The v4 data model is append-only, so exporting both would produce two
|
|
107
|
+
# observations sharing a span id. Bodies are merged into new hashes, leaving
|
|
108
|
+
# the queued events untouched for re-queueing when the export fails.
|
|
109
|
+
def collapse_observation_events(events)
|
|
110
|
+
position_by_id = {}
|
|
111
|
+
|
|
112
|
+
events.each_with_object([]) do |event, collapsed|
|
|
113
|
+
id = observation_event_id(event)
|
|
114
|
+
|
|
115
|
+
if id.nil?
|
|
116
|
+
collapsed << event
|
|
117
|
+
elsif (position = position_by_id[id])
|
|
118
|
+
previous = collapsed[position]
|
|
119
|
+
collapsed[position] = previous.merge(
|
|
120
|
+
type: event[:type],
|
|
121
|
+
body: previous[:body].merge(event[:body])
|
|
122
|
+
)
|
|
123
|
+
else
|
|
124
|
+
position_by_id[id] = collapsed.length
|
|
125
|
+
collapsed << event
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def observation_event_id(event)
|
|
131
|
+
return nil unless OBSERVATION_EVENT_TYPES.include?(event[:type])
|
|
132
|
+
|
|
133
|
+
body = event[:body]
|
|
134
|
+
return nil unless body.is_a?(Hash)
|
|
135
|
+
|
|
136
|
+
body['id'] || body[:id]
|
|
137
|
+
end
|
|
138
|
+
|
|
96
139
|
# Group events by their trace ID for proper OTEL span hierarchy.
|
|
97
140
|
def group_events_by_trace(events)
|
|
98
141
|
groups = Hash.new { |h, k| h[k] = [] }
|
|
@@ -251,21 +294,65 @@ module Langfuse
|
|
|
251
294
|
end
|
|
252
295
|
end
|
|
253
296
|
|
|
254
|
-
|
|
255
|
-
if usage.is_a?(Hash)
|
|
256
|
-
add_attr(attributes, 'gen_ai.usage.prompt_tokens', usage['promptTokens'] || usage['prompt_tokens'])
|
|
257
|
-
add_attr(attributes, 'gen_ai.usage.completion_tokens', usage['completionTokens'] || usage['completion_tokens'])
|
|
258
|
-
total = usage['totalTokens'] || usage['total_tokens']
|
|
259
|
-
add_attr(attributes, 'gen_ai.usage.total_tokens', total) if total
|
|
260
|
-
end
|
|
261
|
-
|
|
262
|
-
add_json_attr(attributes, 'langfuse.observation.usage_details', body['usageDetails'])
|
|
297
|
+
add_usage_attributes(attributes, body)
|
|
263
298
|
add_json_attr(attributes, 'langfuse.observation.cost_details', body['costDetails'])
|
|
264
299
|
add_attr(attributes, 'langfuse.observation.prompt.name', body['promptName'])
|
|
265
300
|
add_attr(attributes, 'langfuse.observation.prompt.version', body['promptVersion'])
|
|
266
301
|
add_attr(attributes, 'langfuse.observation.completion_start_time', body['completionStartTime'])
|
|
267
302
|
end
|
|
268
303
|
|
|
304
|
+
# Emit token usage both as gen_ai.* semantic conventions and as the Langfuse
|
|
305
|
+
# v4 usage_details model. usage_details is what v4 uses for cost, so a legacy
|
|
306
|
+
# `usage` object is normalized into it when no explicit usage_details exists.
|
|
307
|
+
def add_usage_attributes(attributes, body)
|
|
308
|
+
usage = normalize_legacy_usage(body['usage'])
|
|
309
|
+
|
|
310
|
+
if usage
|
|
311
|
+
add_attr(attributes, 'gen_ai.usage.prompt_tokens', usage[:input])
|
|
312
|
+
add_attr(attributes, 'gen_ai.usage.completion_tokens', usage[:output])
|
|
313
|
+
add_attr(attributes, 'gen_ai.usage.total_tokens', usage[:total])
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
usage_details = body['usageDetails']
|
|
317
|
+
usage_details = usage if blank_value?(usage_details)
|
|
318
|
+
add_json_attr(attributes, 'langfuse.observation.usage_details', usage_details)
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# Accept every shape the legacy ingestion API allowed
|
|
322
|
+
# (promptTokens / inputTokens / input) and return {input:, output:, total:}.
|
|
323
|
+
# Non-token units are skipped: usage_details is token-based, so mapping them
|
|
324
|
+
# would produce wrong cost numbers.
|
|
325
|
+
def normalize_legacy_usage(usage)
|
|
326
|
+
return nil unless usage.is_a?(Hash) && !usage.empty?
|
|
327
|
+
|
|
328
|
+
unit = usage['unit'] || usage[:unit]
|
|
329
|
+
if unit && unit.to_s.upcase != 'TOKENS'
|
|
330
|
+
log_debug { "Skipping usage with unit #{unit}; use usage_details for non-token usage" }
|
|
331
|
+
return nil
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
normalized = {
|
|
335
|
+
input: fetch_usage_value(usage, USAGE_INPUT_KEYS),
|
|
336
|
+
output: fetch_usage_value(usage, USAGE_OUTPUT_KEYS),
|
|
337
|
+
total: fetch_usage_value(usage, USAGE_TOTAL_KEYS)
|
|
338
|
+
}.compact
|
|
339
|
+
|
|
340
|
+
normalized.empty? ? nil : normalized
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def fetch_usage_value(usage, keys)
|
|
344
|
+
keys.each do |key|
|
|
345
|
+
value = usage[key]
|
|
346
|
+
return value unless value.nil?
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
nil
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def blank_value?(value)
|
|
353
|
+
value.nil? || (value.respond_to?(:empty?) && value.empty?)
|
|
354
|
+
end
|
|
355
|
+
|
|
269
356
|
def to_otel_trace_id(id_str)
|
|
270
357
|
self.class.to_otel_trace_id(id_str)
|
|
271
358
|
end
|
|
@@ -279,14 +366,17 @@ module Langfuse
|
|
|
279
366
|
return '0' unless timestamp_str
|
|
280
367
|
|
|
281
368
|
time = Time.parse(timestamp_str.to_s)
|
|
282
|
-
(time.
|
|
369
|
+
((time.to_i * 1_000_000_000) + time.nsec).to_s
|
|
283
370
|
rescue ArgumentError
|
|
284
371
|
'0'
|
|
285
372
|
end
|
|
286
373
|
|
|
287
374
|
# Add a string/numeric attribute to the attributes array.
|
|
375
|
+
# Structured values are JSON-encoded instead of falling back to Ruby's
|
|
376
|
+
# inspect format (which is not machine-readable on the Langfuse side).
|
|
288
377
|
def add_attr(attributes, key, value)
|
|
289
378
|
return if value.nil?
|
|
379
|
+
return add_json_attr(attributes, key, value) if value.is_a?(Hash) || value.is_a?(Array)
|
|
290
380
|
|
|
291
381
|
otel_value = case value
|
|
292
382
|
when String
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Langfuse
|
|
4
|
+
# Update events carry only the fields that changed. The ingestion API merges an
|
|
5
|
+
# update into the existing trace/observation, so re-sending a generation's
|
|
6
|
+
# input on every `end` call would double the payload of every LLM call.
|
|
7
|
+
module PartialUpdates
|
|
8
|
+
# Always sent so the server can resolve (and if needed upsert) the entity.
|
|
9
|
+
# `to_dict#slice` ignores the keys a given class does not have.
|
|
10
|
+
UPDATE_IDENTITY_FIELDS = %i[id trace_id type].freeze
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
# `changes` maps a body field to the value passed to update/end; nil means
|
|
15
|
+
# "not provided" and is left out of the update body. `extra_keys` carries the
|
|
16
|
+
# caller's **kwargs, which are merged into the body by to_dict.
|
|
17
|
+
def track_changes(changes, extra_keys = nil)
|
|
18
|
+
keys = changes.compact.keys
|
|
19
|
+
keys.concat(extra_keys.to_a)
|
|
20
|
+
@changed_fields = keys
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def update_body
|
|
24
|
+
# Never tracked → this is a create path; use the full body.
|
|
25
|
+
return to_dict if @changed_fields.nil?
|
|
26
|
+
|
|
27
|
+
to_dict.slice(*UPDATE_IDENTITY_FIELDS, *@changed_fields)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
data/lib/langfuse/prompt.rb
CHANGED
|
@@ -64,7 +64,7 @@ module Langfuse
|
|
|
64
64
|
# Convert text prompt to LangChain PromptTemplate format
|
|
65
65
|
{
|
|
66
66
|
_type: 'prompt',
|
|
67
|
-
input_variables: extract_variables(@prompt),
|
|
67
|
+
input_variables: TemplateCompiler.extract_variables(@prompt),
|
|
68
68
|
template: @prompt
|
|
69
69
|
}
|
|
70
70
|
end
|
|
@@ -75,7 +75,7 @@ module Langfuse
|
|
|
75
75
|
{
|
|
76
76
|
_type: "#{message[:role]}_message",
|
|
77
77
|
content: message[:content],
|
|
78
|
-
input_variables: extract_variables(message[:content])
|
|
78
|
+
input_variables: TemplateCompiler.extract_variables(message[:content])
|
|
79
79
|
}
|
|
80
80
|
end
|
|
81
81
|
|
|
@@ -87,44 +87,11 @@ module Langfuse
|
|
|
87
87
|
end
|
|
88
88
|
|
|
89
89
|
def compile_text_prompt(variables)
|
|
90
|
-
|
|
91
|
-
variables.each do |key, value|
|
|
92
|
-
compiled.gsub!("{{#{key}}}", value.to_s)
|
|
93
|
-
compiled.gsub!("{#{key}}", value.to_s)
|
|
94
|
-
end
|
|
95
|
-
compiled
|
|
90
|
+
TemplateCompiler.compile(@prompt, variables)
|
|
96
91
|
end
|
|
97
92
|
|
|
98
93
|
def compile_chat_prompt(variables)
|
|
99
|
-
@prompt
|
|
100
|
-
compiled_content = message[:content].dup
|
|
101
|
-
variables.each do |key, value|
|
|
102
|
-
compiled_content.gsub!("{{#{key}}}", value.to_s)
|
|
103
|
-
compiled_content.gsub!("{#{key}}", value.to_s)
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
{
|
|
107
|
-
role: message[:role],
|
|
108
|
-
content: compiled_content
|
|
109
|
-
}
|
|
110
|
-
end
|
|
111
|
-
end
|
|
112
|
-
|
|
113
|
-
def extract_variables(text)
|
|
114
|
-
# Extract variables from template text (supports {{var}} and {var} formats)
|
|
115
|
-
variables = []
|
|
116
|
-
|
|
117
|
-
# Match {{variable}} format
|
|
118
|
-
text.scan(/\{\{(\w+)\}\}/) do |match|
|
|
119
|
-
variables << match[0]
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
# Match {variable} format
|
|
123
|
-
text.scan(/\{(\w+)\}/) do |match|
|
|
124
|
-
variables << match[0] unless variables.include?(match[0])
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
variables
|
|
94
|
+
TemplateCompiler.compile_messages(@prompt, variables)
|
|
128
95
|
end
|
|
129
96
|
end
|
|
130
97
|
|
|
@@ -137,33 +104,15 @@ module Langfuse
|
|
|
137
104
|
end
|
|
138
105
|
|
|
139
106
|
def format(variables = {})
|
|
140
|
-
|
|
141
|
-
variables.each do |key, value|
|
|
142
|
-
compiled.gsub!("{{#{key}}}", value.to_s)
|
|
143
|
-
compiled.gsub!("{#{key}}", value.to_s)
|
|
144
|
-
end
|
|
145
|
-
compiled
|
|
107
|
+
TemplateCompiler.compile(@template, variables)
|
|
146
108
|
end
|
|
147
109
|
|
|
148
110
|
def self.from_template(template)
|
|
149
|
-
|
|
150
|
-
new(template: template, input_variables: variables)
|
|
111
|
+
new(template: template, input_variables: extract_variables(template))
|
|
151
112
|
end
|
|
152
113
|
|
|
153
114
|
def self.extract_variables(text)
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
# Match {{variable}} format
|
|
157
|
-
text.scan(/\{\{(\w+)\}\}/) do |match|
|
|
158
|
-
variables << match[0]
|
|
159
|
-
end
|
|
160
|
-
|
|
161
|
-
# Match {variable} format
|
|
162
|
-
text.scan(/\{(\w+)\}/) do |match|
|
|
163
|
-
variables << match[0] unless variables.include?(match[0])
|
|
164
|
-
end
|
|
165
|
-
|
|
166
|
-
variables
|
|
115
|
+
TemplateCompiler.extract_variables(text)
|
|
167
116
|
end
|
|
168
117
|
end
|
|
169
118
|
|
|
@@ -176,34 +125,11 @@ module Langfuse
|
|
|
176
125
|
end
|
|
177
126
|
|
|
178
127
|
def format(variables = {})
|
|
179
|
-
@messages
|
|
180
|
-
compiled_content = message[:content].dup
|
|
181
|
-
variables.each do |key, value|
|
|
182
|
-
compiled_content.gsub!("{{#{key}}}", value.to_s)
|
|
183
|
-
compiled_content.gsub!("{#{key}}", value.to_s)
|
|
184
|
-
end
|
|
185
|
-
|
|
186
|
-
{
|
|
187
|
-
role: message[:role],
|
|
188
|
-
content: compiled_content
|
|
189
|
-
}
|
|
190
|
-
end
|
|
128
|
+
TemplateCompiler.compile_messages(@messages, variables)
|
|
191
129
|
end
|
|
192
130
|
|
|
193
131
|
def self.from_messages(messages)
|
|
194
|
-
input_variables
|
|
195
|
-
|
|
196
|
-
messages.each do |message|
|
|
197
|
-
message[:content].scan(/\{\{(\w+)\}\}/) do |match|
|
|
198
|
-
input_variables << match[0] unless input_variables.include?(match[0])
|
|
199
|
-
end
|
|
200
|
-
|
|
201
|
-
message[:content].scan(/\{(\w+)\}/) do |match|
|
|
202
|
-
input_variables << match[0] unless input_variables.include?(match[0])
|
|
203
|
-
end
|
|
204
|
-
end
|
|
205
|
-
|
|
206
|
-
new(messages: messages, input_variables: input_variables)
|
|
132
|
+
new(messages: messages, input_variables: TemplateCompiler.extract_message_variables(messages))
|
|
207
133
|
end
|
|
208
134
|
end
|
|
209
135
|
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'concurrent'
|
|
4
|
+
|
|
5
|
+
module Langfuse
|
|
6
|
+
# Bounded, thread-safe cache of Prompt objects, keyed by name/version/label.
|
|
7
|
+
#
|
|
8
|
+
# TTLs are measured on the monotonic clock so wall-clock jumps (NTP, DST) cannot
|
|
9
|
+
# extend or shorten an entry's lifetime. Entries keep insertion order and the
|
|
10
|
+
# oldest ones are evicted once the cache is full, so a long-running process with
|
|
11
|
+
# many prompt names cannot grow it without bound.
|
|
12
|
+
class PromptCache
|
|
13
|
+
DEFAULT_MAX_ENTRIES = 200
|
|
14
|
+
|
|
15
|
+
Entry = Struct.new(:prompt, :cached_at) do
|
|
16
|
+
def fresh?(ttl_seconds, now)
|
|
17
|
+
return false if ttl_seconds.nil?
|
|
18
|
+
|
|
19
|
+
now - cached_at < ttl_seconds
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(max_entries: DEFAULT_MAX_ENTRIES)
|
|
24
|
+
@max_entries = [max_entries.to_i, 0].max
|
|
25
|
+
@entries = Concurrent::Hash.new
|
|
26
|
+
@mutex = Mutex.new
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The cached prompt while it is still fresh, otherwise nil.
|
|
30
|
+
def read(key, ttl_seconds)
|
|
31
|
+
entry = @entries[key]
|
|
32
|
+
return nil unless entry&.fresh?(ttl_seconds, monotonic_time)
|
|
33
|
+
|
|
34
|
+
entry.prompt
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The cached prompt regardless of age. Used to keep serving prompts while the
|
|
38
|
+
# Langfuse API is unreachable.
|
|
39
|
+
def read_stale(key)
|
|
40
|
+
@entries[key]&.prompt
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Writes are serialized so concurrent fetches cannot corrupt the hash while it
|
|
44
|
+
# is being trimmed. Returns the prompt for convenient chaining.
|
|
45
|
+
def write(key, prompt)
|
|
46
|
+
@mutex.synchronize do
|
|
47
|
+
@entries.delete(key)
|
|
48
|
+
@entries[key] = Entry.new(prompt, monotonic_time)
|
|
49
|
+
@entries.shift while @entries.length > @max_entries && !@entries.empty?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
prompt
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def length
|
|
56
|
+
@entries.length
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def monotonic_time
|
|
62
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|