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.
@@ -57,23 +57,7 @@ module Langfuse
57
57
  end
58
58
 
59
59
  def create_event
60
- data = {
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
@@ -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 if name
47
- @end_time = end_time if end_time
48
- @completion_start_time = completion_start_time if completion_start_time
49
- @model = model if 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 if input
52
- @output = output if 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 if level
59
- @status_message = status_message if status_message
60
- @version = version if 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 if 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
- # Create a child agent observation
144
- def agent(name: nil, start_time: nil, end_time: nil, input: nil, output: nil,
145
- metadata: nil, level: nil, status_message: nil, version: nil, **kwargs)
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', to_dict)
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
- usage = body['usage']
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.to_f * 1_000_000_000).to_i.to_s
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
@@ -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
- compiled = @prompt.dup
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.map do |message|
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
- compiled = @template.dup
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
- variables = extract_variables(template)
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
- variables = []
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.map do |message|
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