langfuse-rb 0.1.0
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 +7 -0
- data/CHANGELOG.md +60 -0
- data/LICENSE +21 -0
- data/README.md +106 -0
- data/lib/langfuse/api_client.rb +330 -0
- data/lib/langfuse/cache_warmer.rb +219 -0
- data/lib/langfuse/chat_prompt_client.rb +98 -0
- data/lib/langfuse/client.rb +338 -0
- data/lib/langfuse/config.rb +135 -0
- data/lib/langfuse/observations.rb +615 -0
- data/lib/langfuse/otel_attributes.rb +275 -0
- data/lib/langfuse/otel_setup.rb +123 -0
- data/lib/langfuse/prompt_cache.rb +131 -0
- data/lib/langfuse/propagation.rb +471 -0
- data/lib/langfuse/rails_cache_adapter.rb +200 -0
- data/lib/langfuse/score_client.rb +321 -0
- data/lib/langfuse/span_processor.rb +61 -0
- data/lib/langfuse/text_prompt_client.rb +67 -0
- data/lib/langfuse/types.rb +353 -0
- data/lib/langfuse/version.rb +5 -0
- data/lib/langfuse.rb +457 -0
- metadata +177 -0
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "opentelemetry/context"
|
|
4
|
+
|
|
5
|
+
module Langfuse
|
|
6
|
+
# Attribute propagation utilities for Langfuse OpenTelemetry integration.
|
|
7
|
+
#
|
|
8
|
+
# This module provides the `propagate_attributes` method for setting trace-level
|
|
9
|
+
# attributes (user_id, session_id, metadata) that automatically propagate to all child spans
|
|
10
|
+
# within the context.
|
|
11
|
+
#
|
|
12
|
+
# @example Basic usage
|
|
13
|
+
# Langfuse.observe("operation") do |span|
|
|
14
|
+
# Langfuse.propagate_attributes(user_id: "user_123", session_id: "session_abc") do
|
|
15
|
+
# # Current span has user_id and session_id
|
|
16
|
+
# span.start_observation("child") do |child|
|
|
17
|
+
# # Child span inherits user_id and session_id
|
|
18
|
+
# end
|
|
19
|
+
# end
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# rubocop:disable Metrics/ModuleLength
|
|
23
|
+
module Propagation
|
|
24
|
+
# Map of propagated attribute keys to span attribute keys
|
|
25
|
+
SPAN_KEY_MAP = {
|
|
26
|
+
"user_id" => OtelAttributes::TRACE_USER_ID,
|
|
27
|
+
"session_id" => OtelAttributes::TRACE_SESSION_ID,
|
|
28
|
+
"version" => OtelAttributes::VERSION,
|
|
29
|
+
"tags" => OtelAttributes::TRACE_TAGS,
|
|
30
|
+
"metadata" => OtelAttributes::TRACE_METADATA
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
# OpenTelemetry context keys for propagated attributes
|
|
34
|
+
CONTEXT_KEYS = {
|
|
35
|
+
"user_id" => OpenTelemetry::Context.create_key("langfuse_user_id"),
|
|
36
|
+
"session_id" => OpenTelemetry::Context.create_key("langfuse_session_id"),
|
|
37
|
+
"metadata" => OpenTelemetry::Context.create_key("langfuse_metadata"),
|
|
38
|
+
"version" => OpenTelemetry::Context.create_key("langfuse_version"),
|
|
39
|
+
"tags" => OpenTelemetry::Context.create_key("langfuse_tags")
|
|
40
|
+
}.freeze
|
|
41
|
+
|
|
42
|
+
# List of propagated attribute keys (derived from CONTEXT_KEYS)
|
|
43
|
+
PROPAGATED_ATTRIBUTES = CONTEXT_KEYS.keys.freeze
|
|
44
|
+
|
|
45
|
+
# Baggage key prefix for cross-service propagation
|
|
46
|
+
BAGGAGE_PREFIX = "langfuse_"
|
|
47
|
+
|
|
48
|
+
# Propagate trace-level attributes to all spans created within this context.
|
|
49
|
+
#
|
|
50
|
+
# This method sets attributes on the currently active span AND automatically
|
|
51
|
+
# propagates them to all new child spans created within the block. This is the
|
|
52
|
+
# recommended way to set trace-level attributes like user_id, session_id, and metadata
|
|
53
|
+
# dimensions that should be consistently applied across all observations in a trace.
|
|
54
|
+
#
|
|
55
|
+
# @param user_id [String, nil] User identifier (≤200 characters)
|
|
56
|
+
# @param session_id [String, nil] Session identifier (≤200 characters)
|
|
57
|
+
# @param metadata [Hash<String, String>, nil] Additional metadata (all values ≤200 characters)
|
|
58
|
+
# @param version [String, nil] Version identifier (≤200 characters)
|
|
59
|
+
# @param tags [Array<String>, nil] List of tags (each ≤200 characters)
|
|
60
|
+
# @param as_baggage [Boolean] If true, propagates via OpenTelemetry baggage for cross-service propagation
|
|
61
|
+
# @yield Block within which attributes are propagated
|
|
62
|
+
# @return [Object] The result of the block
|
|
63
|
+
#
|
|
64
|
+
# @example Basic usage
|
|
65
|
+
# Langfuse.propagate_attributes(user_id: "user_123", session_id: "session_abc") do
|
|
66
|
+
# # All spans created here inherit attributes
|
|
67
|
+
# end
|
|
68
|
+
#
|
|
69
|
+
# @example With metadata and tags
|
|
70
|
+
# Langfuse.propagate_attributes(
|
|
71
|
+
# user_id: "user_123",
|
|
72
|
+
# metadata: { environment: "production", region: "us-east" },
|
|
73
|
+
# tags: ["api", "v2"]
|
|
74
|
+
# ) do
|
|
75
|
+
# # All spans inherit these attributes
|
|
76
|
+
# end
|
|
77
|
+
#
|
|
78
|
+
def self.propagate_attributes(user_id: nil, session_id: nil, metadata: nil, version: nil, tags: nil,
|
|
79
|
+
as_baggage: false, &block)
|
|
80
|
+
raise ArgumentError, "Block required" unless block
|
|
81
|
+
|
|
82
|
+
_propagate_attributes(
|
|
83
|
+
user_id: user_id,
|
|
84
|
+
session_id: session_id,
|
|
85
|
+
metadata: metadata,
|
|
86
|
+
version: version,
|
|
87
|
+
tags: tags,
|
|
88
|
+
as_baggage: as_baggage,
|
|
89
|
+
&block
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Internal implementation of propagate_attributes
|
|
94
|
+
#
|
|
95
|
+
# @api private
|
|
96
|
+
def self._propagate_attributes(user_id: nil, session_id: nil, metadata: nil, version: nil, tags: nil,
|
|
97
|
+
as_baggage: false, &)
|
|
98
|
+
current_context = OpenTelemetry::Context.current
|
|
99
|
+
current_span = OpenTelemetry::Trace.current_span
|
|
100
|
+
|
|
101
|
+
# Process each propagated attribute using PROPAGATED_ATTRIBUTES constant
|
|
102
|
+
PROPAGATED_ATTRIBUTES.each do |key|
|
|
103
|
+
value = binding.local_variable_get(key.to_sym)
|
|
104
|
+
next if value.nil?
|
|
105
|
+
next if key == "tags" && value.empty?
|
|
106
|
+
|
|
107
|
+
validated_value = _validate_attribute_value(key, value)
|
|
108
|
+
next unless validated_value
|
|
109
|
+
|
|
110
|
+
current_context = _set_propagated_attribute(
|
|
111
|
+
key: key,
|
|
112
|
+
value: validated_value,
|
|
113
|
+
context: current_context,
|
|
114
|
+
span: current_span,
|
|
115
|
+
as_baggage: as_baggage
|
|
116
|
+
)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Execute block in new context
|
|
120
|
+
OpenTelemetry::Context.with_current(current_context, &)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Validate an attribute value based on its type
|
|
124
|
+
#
|
|
125
|
+
# @param key [String] Attribute key
|
|
126
|
+
# @param value [Object] Attribute value
|
|
127
|
+
# @return [Object, nil] Validated value or nil if invalid
|
|
128
|
+
#
|
|
129
|
+
# @api private
|
|
130
|
+
# rubocop:disable Metrics/CyclomaticComplexity
|
|
131
|
+
def self._validate_attribute_value(key, value)
|
|
132
|
+
case key
|
|
133
|
+
when "tags"
|
|
134
|
+
validated_tags = value.filter_map { |tag| _validate_propagated_value(tag, "tag") }
|
|
135
|
+
validated_tags.any? ? validated_tags : nil
|
|
136
|
+
when "metadata"
|
|
137
|
+
validated_metadata = {}
|
|
138
|
+
value.each do |k, v|
|
|
139
|
+
validated_metadata[k.to_s] = v.to_s if _validate_string_value(v, "metadata.#{k}")
|
|
140
|
+
end
|
|
141
|
+
validated_metadata.any? ? validated_metadata : nil
|
|
142
|
+
else
|
|
143
|
+
_validate_propagated_value(value, key)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
# rubocop:enable Metrics/CyclomaticComplexity
|
|
147
|
+
|
|
148
|
+
# Get propagated attributes from context for span processor
|
|
149
|
+
#
|
|
150
|
+
# @param context [OpenTelemetry::Context] The context to read from
|
|
151
|
+
# @return [Hash<String, String, Array<String>>] Hash of span key => value
|
|
152
|
+
#
|
|
153
|
+
# @api private
|
|
154
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
155
|
+
def self.get_propagated_attributes_from_context(context)
|
|
156
|
+
propagated_attributes = _extract_baggage_attributes(context)
|
|
157
|
+
|
|
158
|
+
# Handle OTEL context values
|
|
159
|
+
PROPAGATED_ATTRIBUTES.each do |key|
|
|
160
|
+
context_key = _get_propagated_context_key(key)
|
|
161
|
+
value = context.value(context_key)
|
|
162
|
+
|
|
163
|
+
next if value.nil?
|
|
164
|
+
|
|
165
|
+
span_key = _get_propagated_span_key(key)
|
|
166
|
+
|
|
167
|
+
if key == "metadata" && value.is_a?(Hash)
|
|
168
|
+
# Handle metadata - flatten into individual attributes
|
|
169
|
+
value.each do |k, v|
|
|
170
|
+
metadata_key = "#{OtelAttributes::TRACE_METADATA}.#{k}"
|
|
171
|
+
propagated_attributes[metadata_key] = v.to_s
|
|
172
|
+
end
|
|
173
|
+
elsif key == "tags" && value.is_a?(Array)
|
|
174
|
+
# Handle tags - serialize as JSON array for span attributes
|
|
175
|
+
serialized_tags = OtelAttributes.serialize(value)
|
|
176
|
+
propagated_attributes[span_key] = serialized_tags if serialized_tags
|
|
177
|
+
else
|
|
178
|
+
propagated_attributes[span_key] = value.to_s
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
182
|
+
|
|
183
|
+
propagated_attributes
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Merge metadata with existing context value
|
|
187
|
+
#
|
|
188
|
+
# @param context [OpenTelemetry::Context] Current context
|
|
189
|
+
# @param context_key [OpenTelemetry::Context::Key] Context key for metadata
|
|
190
|
+
# @param new_metadata [Hash<String, String>] New metadata to merge
|
|
191
|
+
# @return [Hash<String, String>] Merged metadata
|
|
192
|
+
#
|
|
193
|
+
# @api private
|
|
194
|
+
def self._merge_metadata(context, context_key, new_metadata)
|
|
195
|
+
existing = context.value(context_key) || {}
|
|
196
|
+
existing = existing.to_h if existing.respond_to?(:to_h)
|
|
197
|
+
existing.merge(new_metadata)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Merge tags with existing context value
|
|
201
|
+
#
|
|
202
|
+
# @param context [OpenTelemetry::Context] Current context
|
|
203
|
+
# @param context_key [OpenTelemetry::Context::Key] Context key for tags
|
|
204
|
+
# @param new_tags [Array<String>] New tags to merge
|
|
205
|
+
# @return [Array<String>] Merged tags (deduplicated)
|
|
206
|
+
#
|
|
207
|
+
# @api private
|
|
208
|
+
def self._merge_tags(context, context_key, new_tags)
|
|
209
|
+
existing = context.value(context_key) || []
|
|
210
|
+
existing = existing.to_a if existing.respond_to?(:to_a)
|
|
211
|
+
(existing + new_tags).uniq
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Set a propagated attribute in context and on current span
|
|
215
|
+
#
|
|
216
|
+
# @param key [String] Attribute key (user_id, session_id, version, tags, metadata)
|
|
217
|
+
# @param value [String, Array<String>, Hash<String, String>] Attribute value
|
|
218
|
+
# @param context [OpenTelemetry::Context] Current context
|
|
219
|
+
# @param span [OpenTelemetry::Trace::Span, nil] Current span (may be nil)
|
|
220
|
+
# @param as_baggage [Boolean] Whether to set in baggage
|
|
221
|
+
# @return [OpenTelemetry::Context] New context with attribute set
|
|
222
|
+
#
|
|
223
|
+
# @api private
|
|
224
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
225
|
+
def self._set_propagated_attribute(key:, value:, context:, span:, as_baggage:)
|
|
226
|
+
context_key = _get_propagated_context_key(key)
|
|
227
|
+
span_key = _get_propagated_span_key(key)
|
|
228
|
+
baggage_key = _get_propagated_baggage_key(key)
|
|
229
|
+
|
|
230
|
+
# Merge metadata/tags with existing context values
|
|
231
|
+
value = if key == "metadata" && value.is_a?(Hash)
|
|
232
|
+
_merge_metadata(context, context_key, value)
|
|
233
|
+
elsif key == "tags" && value.is_a?(Array)
|
|
234
|
+
_merge_tags(context, context_key, value)
|
|
235
|
+
else
|
|
236
|
+
value
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Set in context
|
|
240
|
+
context = context.set_value(context_key, value)
|
|
241
|
+
|
|
242
|
+
# Set on current span (if recording)
|
|
243
|
+
if span&.recording?
|
|
244
|
+
if key == "metadata" && value.is_a?(Hash)
|
|
245
|
+
# Handle metadata - flatten into individual attributes
|
|
246
|
+
value.each do |k, v|
|
|
247
|
+
metadata_key = "#{OtelAttributes::TRACE_METADATA}.#{k}"
|
|
248
|
+
span.set_attribute(metadata_key, v.to_s)
|
|
249
|
+
end
|
|
250
|
+
elsif key == "tags" && value.is_a?(Array)
|
|
251
|
+
# Handle tags - serialize as JSON array
|
|
252
|
+
serialized_tags = OtelAttributes.serialize(value)
|
|
253
|
+
span.set_attribute(span_key, serialized_tags) if serialized_tags
|
|
254
|
+
else
|
|
255
|
+
span.set_attribute(span_key, value.to_s)
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Set in baggage (if requested and available)
|
|
260
|
+
# Note: Baggage support requires opentelemetry-baggage gem
|
|
261
|
+
if as_baggage
|
|
262
|
+
unless baggage_available?
|
|
263
|
+
Langfuse.configuration.logger.warn(
|
|
264
|
+
"Langfuse: Baggage propagation requested but opentelemetry-baggage gem not available. " \
|
|
265
|
+
"Install opentelemetry-baggage for cross-service propagation."
|
|
266
|
+
)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
context = _set_baggage_attribute(
|
|
270
|
+
context: context,
|
|
271
|
+
key: key,
|
|
272
|
+
value: value,
|
|
273
|
+
baggage_key: baggage_key
|
|
274
|
+
)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
context
|
|
278
|
+
end
|
|
279
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
280
|
+
|
|
281
|
+
# Validate a propagated value (string or array of strings)
|
|
282
|
+
#
|
|
283
|
+
# @param value [String, Array<String>] Value to validate
|
|
284
|
+
# @param key [String] Attribute key for error messages
|
|
285
|
+
# @return [String, Array<String>, nil] Validated value or nil if invalid
|
|
286
|
+
#
|
|
287
|
+
# @api private
|
|
288
|
+
def self._validate_propagated_value(value, key)
|
|
289
|
+
if value.is_a?(Array)
|
|
290
|
+
validated = value.filter_map { |v| _validate_string_value(v, key) ? v : nil }
|
|
291
|
+
return validated.any? ? validated : nil
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Validate string value (will log warning if not a string)
|
|
295
|
+
return nil unless _validate_string_value(value, key)
|
|
296
|
+
|
|
297
|
+
value
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Validate a string value
|
|
301
|
+
#
|
|
302
|
+
# @param value [String] Value to validate
|
|
303
|
+
# @param key [String] Attribute key for error messages
|
|
304
|
+
# @return [Boolean] True if valid, false otherwise
|
|
305
|
+
#
|
|
306
|
+
# @api private
|
|
307
|
+
# rubocop:disable Naming/PredicateMethod
|
|
308
|
+
def self._validate_string_value(value, key)
|
|
309
|
+
unless value.is_a?(String)
|
|
310
|
+
Langfuse.configuration.logger.warn(
|
|
311
|
+
"Langfuse: Propagated attribute '#{key}' value is not a string. Dropping value."
|
|
312
|
+
)
|
|
313
|
+
return false
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
if value.length > 200
|
|
317
|
+
Langfuse.configuration.logger.warn(
|
|
318
|
+
"Langfuse: Propagated attribute '#{key}' value is over 200 characters " \
|
|
319
|
+
"(#{value.length} chars). Dropping value."
|
|
320
|
+
)
|
|
321
|
+
return false
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
true
|
|
325
|
+
end
|
|
326
|
+
# rubocop:enable Naming/PredicateMethod
|
|
327
|
+
|
|
328
|
+
# Get context key for a propagated attribute
|
|
329
|
+
#
|
|
330
|
+
# @param key [String] Attribute key (user_id, session_id, etc.)
|
|
331
|
+
# @return [OpenTelemetry::Context::Key] Context key object
|
|
332
|
+
#
|
|
333
|
+
# @api private
|
|
334
|
+
def self._get_propagated_context_key(key)
|
|
335
|
+
CONTEXT_KEYS[key] || raise(ArgumentError, "Unknown propagated attribute key: #{key}")
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# Get span attribute key for a propagated attribute
|
|
339
|
+
#
|
|
340
|
+
# @param key [String] Attribute key (user_id, session_id, etc.)
|
|
341
|
+
# @return [String] Span attribute key
|
|
342
|
+
#
|
|
343
|
+
# @api private
|
|
344
|
+
def self._get_propagated_span_key(key)
|
|
345
|
+
SPAN_KEY_MAP[key] || "#{OtelAttributes::TRACE_METADATA}.#{key}"
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# Get baggage key for a propagated attribute
|
|
349
|
+
#
|
|
350
|
+
# @param key [String] Attribute key (user_id, session_id, etc.)
|
|
351
|
+
# @return [String] Baggage key (snake_case for cross-service compatibility)
|
|
352
|
+
#
|
|
353
|
+
# @api private
|
|
354
|
+
def self._get_propagated_baggage_key(key)
|
|
355
|
+
"#{BAGGAGE_PREFIX}#{key}"
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# Get span key from baggage key
|
|
359
|
+
#
|
|
360
|
+
# @param baggage_key [String] Baggage key
|
|
361
|
+
# @return [String, nil] Span key or nil if not a Langfuse baggage key
|
|
362
|
+
#
|
|
363
|
+
# @api private
|
|
364
|
+
def self._get_span_key_from_baggage_key(baggage_key)
|
|
365
|
+
return nil unless baggage_key.start_with?(BAGGAGE_PREFIX)
|
|
366
|
+
|
|
367
|
+
# Remove prefix
|
|
368
|
+
suffix = baggage_key[BAGGAGE_PREFIX.length..]
|
|
369
|
+
|
|
370
|
+
# Handle metadata keys (format: langfuse_metadata_{key_name})
|
|
371
|
+
if suffix.start_with?("metadata_")
|
|
372
|
+
metadata_key = suffix[("metadata_".length)..]
|
|
373
|
+
return "#{OtelAttributes::TRACE_METADATA}.#{metadata_key}"
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
# Map standard keys
|
|
377
|
+
case suffix
|
|
378
|
+
when "user_id"
|
|
379
|
+
_get_propagated_span_key("user_id")
|
|
380
|
+
when "session_id"
|
|
381
|
+
_get_propagated_span_key("session_id")
|
|
382
|
+
when "version"
|
|
383
|
+
_get_propagated_span_key("version")
|
|
384
|
+
when "tags"
|
|
385
|
+
_get_propagated_span_key("tags")
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# Check if baggage API is available
|
|
390
|
+
#
|
|
391
|
+
# @return [Boolean] True if OpenTelemetry::Baggage is defined
|
|
392
|
+
#
|
|
393
|
+
# @api private
|
|
394
|
+
def self.baggage_available?
|
|
395
|
+
defined?(OpenTelemetry::Baggage)
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# Extract propagated attributes from baggage
|
|
399
|
+
#
|
|
400
|
+
# @param context [OpenTelemetry::Context] The context to read baggage from
|
|
401
|
+
# @return [Hash<String, String, Array<String>>] Hash of span key => value
|
|
402
|
+
#
|
|
403
|
+
# @api private
|
|
404
|
+
def self._extract_baggage_attributes(context)
|
|
405
|
+
return {} unless baggage_available?
|
|
406
|
+
|
|
407
|
+
baggage = OpenTelemetry::Baggage.value(context: context)
|
|
408
|
+
return {} unless baggage.is_a?(Hash)
|
|
409
|
+
|
|
410
|
+
attributes = {}
|
|
411
|
+
baggage.each do |baggage_key, baggage_value|
|
|
412
|
+
next unless baggage_key.to_s.start_with?(BAGGAGE_PREFIX)
|
|
413
|
+
|
|
414
|
+
span_key = _get_span_key_from_baggage_key(baggage_key.to_s)
|
|
415
|
+
next unless span_key
|
|
416
|
+
|
|
417
|
+
attributes[span_key] = _parse_baggage_value(span_key, baggage_value)
|
|
418
|
+
end
|
|
419
|
+
attributes
|
|
420
|
+
rescue StandardError => e
|
|
421
|
+
Langfuse.configuration.logger.debug("Langfuse: Baggage extraction failed: #{e.message}")
|
|
422
|
+
{}
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# Parse a baggage value into the appropriate format
|
|
426
|
+
#
|
|
427
|
+
# @param span_key [String] The span attribute key
|
|
428
|
+
# @param baggage_value [String, Object] The baggage value
|
|
429
|
+
# @return [String, Array<String>] Parsed value
|
|
430
|
+
#
|
|
431
|
+
# @api private
|
|
432
|
+
def self._parse_baggage_value(span_key, baggage_value)
|
|
433
|
+
if span_key == OtelAttributes::TRACE_TAGS && baggage_value.is_a?(String)
|
|
434
|
+
baggage_value.split(",")
|
|
435
|
+
else
|
|
436
|
+
baggage_value.to_s
|
|
437
|
+
end
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
# Set a propagated attribute in baggage
|
|
441
|
+
#
|
|
442
|
+
# @param context [OpenTelemetry::Context] Current context
|
|
443
|
+
# @param key [String] Attribute key (user_id, session_id, version, tags, metadata)
|
|
444
|
+
# @param value [String, Array<String>, Hash<String, String>] Attribute value
|
|
445
|
+
# @param baggage_key [String] Baggage key prefix
|
|
446
|
+
# @return [OpenTelemetry::Context] New context with baggage set
|
|
447
|
+
#
|
|
448
|
+
# @api private
|
|
449
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
450
|
+
def self._set_baggage_attribute(context:, key:, value:, baggage_key:)
|
|
451
|
+
return context unless baggage_available?
|
|
452
|
+
|
|
453
|
+
if key == "metadata" && value.is_a?(Hash)
|
|
454
|
+
value.each do |k, v|
|
|
455
|
+
entry_key = "#{baggage_key}_#{k}"
|
|
456
|
+
context = OpenTelemetry::Baggage.set_value(context: context, key: entry_key, value: v.to_s)
|
|
457
|
+
end
|
|
458
|
+
elsif key == "tags" && value.is_a?(Array)
|
|
459
|
+
context = OpenTelemetry::Baggage.set_value(context: context, key: baggage_key, value: value.join(","))
|
|
460
|
+
else
|
|
461
|
+
context = OpenTelemetry::Baggage.set_value(context: context, key: baggage_key, value: value.to_s)
|
|
462
|
+
end
|
|
463
|
+
context
|
|
464
|
+
rescue StandardError => e
|
|
465
|
+
Langfuse.configuration.logger.warn("Langfuse: Failed to set baggage: #{e.message}")
|
|
466
|
+
context
|
|
467
|
+
end
|
|
468
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
469
|
+
end
|
|
470
|
+
end
|
|
471
|
+
# rubocop:enable Metrics/ModuleLength
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Langfuse
|
|
4
|
+
# Rails.cache adapter for distributed caching with Redis
|
|
5
|
+
#
|
|
6
|
+
# Wraps Rails.cache to provide distributed caching for prompts across
|
|
7
|
+
# multiple processes and servers. Requires Rails with Redis cache store.
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# adapter = Langfuse::RailsCacheAdapter.new(ttl: 60)
|
|
11
|
+
# adapter.set("greeting:1", prompt_data)
|
|
12
|
+
# adapter.get("greeting:1") # => prompt_data
|
|
13
|
+
#
|
|
14
|
+
class RailsCacheAdapter
|
|
15
|
+
attr_reader :ttl, :namespace, :lock_timeout
|
|
16
|
+
|
|
17
|
+
# Initialize a new Rails.cache adapter
|
|
18
|
+
#
|
|
19
|
+
# @param ttl [Integer] Time-to-live in seconds (default: 60)
|
|
20
|
+
# @param namespace [String] Cache key namespace (default: "langfuse")
|
|
21
|
+
# @param lock_timeout [Integer] Lock timeout in seconds for stampede protection (default: 10)
|
|
22
|
+
# @raise [ConfigurationError] if Rails.cache is not available
|
|
23
|
+
def initialize(ttl: 60, namespace: "langfuse", lock_timeout: 10)
|
|
24
|
+
validate_rails_cache!
|
|
25
|
+
|
|
26
|
+
@ttl = ttl
|
|
27
|
+
@namespace = namespace
|
|
28
|
+
@lock_timeout = lock_timeout
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Get a value from the cache
|
|
32
|
+
#
|
|
33
|
+
# @param key [String] Cache key
|
|
34
|
+
# @return [Object, nil] Cached value or nil if not found/expired
|
|
35
|
+
def get(key)
|
|
36
|
+
Rails.cache.read(namespaced_key(key))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Set a value in the cache
|
|
40
|
+
#
|
|
41
|
+
# @param key [String] Cache key
|
|
42
|
+
# @param value [Object] Value to cache
|
|
43
|
+
# @return [Object] The cached value
|
|
44
|
+
def set(key, value)
|
|
45
|
+
Rails.cache.write(namespaced_key(key), value, expires_in: ttl)
|
|
46
|
+
value
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Fetch a value from cache with distributed lock for stampede protection
|
|
50
|
+
#
|
|
51
|
+
# This method prevents cache stampedes (thundering herd) by ensuring only one
|
|
52
|
+
# process fetches from the source when the cache is empty. Other processes wait
|
|
53
|
+
# for the first one to populate the cache.
|
|
54
|
+
#
|
|
55
|
+
# Uses exponential backoff: 50ms, 100ms, 200ms (3 retries max, ~350ms total).
|
|
56
|
+
# If cache is still empty after waiting, falls back to fetching from source.
|
|
57
|
+
#
|
|
58
|
+
# @param key [String] Cache key
|
|
59
|
+
# @yield Block to execute if cache miss (should fetch fresh data)
|
|
60
|
+
# @return [Object] Cached or freshly fetched value
|
|
61
|
+
#
|
|
62
|
+
# @example
|
|
63
|
+
# adapter.fetch_with_lock("greeting:v1") do
|
|
64
|
+
# api_client.get_prompt("greeting")
|
|
65
|
+
# end
|
|
66
|
+
def fetch_with_lock(key)
|
|
67
|
+
# 1. Check cache first (fast path - no lock needed)
|
|
68
|
+
cached = get(key)
|
|
69
|
+
return cached if cached
|
|
70
|
+
|
|
71
|
+
# 2. Cache miss - try to acquire distributed lock
|
|
72
|
+
lock_key = "#{namespaced_key(key)}:lock"
|
|
73
|
+
|
|
74
|
+
if acquire_lock(lock_key)
|
|
75
|
+
begin
|
|
76
|
+
# We got the lock - fetch from source and populate cache
|
|
77
|
+
value = yield
|
|
78
|
+
set(key, value)
|
|
79
|
+
value
|
|
80
|
+
ensure
|
|
81
|
+
# Always release lock, even if block raises
|
|
82
|
+
release_lock(lock_key)
|
|
83
|
+
end
|
|
84
|
+
else
|
|
85
|
+
# Someone else has the lock - wait for them to populate cache
|
|
86
|
+
cached = wait_for_cache(key)
|
|
87
|
+
return cached if cached
|
|
88
|
+
|
|
89
|
+
# Cache still empty after waiting - fall back to fetching ourselves
|
|
90
|
+
# (This handles cases where lock holder crashed or took too long)
|
|
91
|
+
yield
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Clear the entire Langfuse cache namespace
|
|
96
|
+
#
|
|
97
|
+
# Note: This uses delete_matched which may not be available on all cache stores.
|
|
98
|
+
# Works with Redis, Memcached, and memory stores. File store support varies.
|
|
99
|
+
#
|
|
100
|
+
# @return [void]
|
|
101
|
+
def clear
|
|
102
|
+
# Delete all keys matching the namespace pattern
|
|
103
|
+
Rails.cache.delete_matched("#{namespace}:*")
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Get current cache size
|
|
107
|
+
#
|
|
108
|
+
# Note: Rails.cache doesn't provide a size method, so we return nil
|
|
109
|
+
# to indicate this operation is not supported.
|
|
110
|
+
#
|
|
111
|
+
# @return [nil]
|
|
112
|
+
def size
|
|
113
|
+
nil
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Check if cache is empty
|
|
117
|
+
#
|
|
118
|
+
# Note: Rails.cache doesn't provide an efficient way to check if empty,
|
|
119
|
+
# so we return false to indicate this operation is not supported.
|
|
120
|
+
#
|
|
121
|
+
# @return [Boolean] Always returns false (unsupported operation)
|
|
122
|
+
def empty?
|
|
123
|
+
false
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Build a cache key from prompt name and options
|
|
127
|
+
#
|
|
128
|
+
# @param name [String] Prompt name
|
|
129
|
+
# @param version [Integer, nil] Optional version
|
|
130
|
+
# @param label [String, nil] Optional label
|
|
131
|
+
# @return [String] Cache key
|
|
132
|
+
def self.build_key(name, version: nil, label: nil)
|
|
133
|
+
PromptCache.build_key(name, version: version, label: label)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
private
|
|
137
|
+
|
|
138
|
+
# Add namespace prefix to cache key
|
|
139
|
+
#
|
|
140
|
+
# @param key [String] Original cache key
|
|
141
|
+
# @return [String] Namespaced cache key
|
|
142
|
+
def namespaced_key(key)
|
|
143
|
+
"#{namespace}:#{key}"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Acquire a distributed lock using Rails.cache
|
|
147
|
+
#
|
|
148
|
+
# Uses atomic "write if not exists" operation to ensure only one process
|
|
149
|
+
# can acquire the lock.
|
|
150
|
+
#
|
|
151
|
+
# @param lock_key [String] Full lock key (already namespaced)
|
|
152
|
+
# @return [Boolean] true if lock was acquired, false if already held by another process
|
|
153
|
+
def acquire_lock(lock_key)
|
|
154
|
+
Rails.cache.write(
|
|
155
|
+
lock_key,
|
|
156
|
+
true,
|
|
157
|
+
unless_exist: true, # Atomic: only write if key doesn't exist
|
|
158
|
+
expires_in: lock_timeout # Auto-expire to prevent deadlocks
|
|
159
|
+
)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Release a distributed lock
|
|
163
|
+
#
|
|
164
|
+
# @param lock_key [String] Full lock key (already namespaced)
|
|
165
|
+
# @return [void]
|
|
166
|
+
def release_lock(lock_key)
|
|
167
|
+
Rails.cache.delete(lock_key)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Wait for cache to be populated by lock holder
|
|
171
|
+
#
|
|
172
|
+
# Uses exponential backoff: 50ms, 100ms, 200ms (3 retries, ~350ms total).
|
|
173
|
+
# This gives the lock holder time to fetch and populate the cache.
|
|
174
|
+
#
|
|
175
|
+
# @param key [String] Cache key (not namespaced)
|
|
176
|
+
# @return [Object, nil] Cached value if found, nil if still empty after waiting
|
|
177
|
+
def wait_for_cache(key)
|
|
178
|
+
intervals = [0.05, 0.1, 0.2] # 50ms, 100ms, 200ms (exponential backoff)
|
|
179
|
+
|
|
180
|
+
intervals.each do |interval|
|
|
181
|
+
sleep(interval)
|
|
182
|
+
cached = get(key)
|
|
183
|
+
return cached if cached
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
nil # Cache still empty after all retries
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Validate that Rails.cache is available
|
|
190
|
+
#
|
|
191
|
+
# @raise [ConfigurationError] if Rails.cache is not available
|
|
192
|
+
# @return [void]
|
|
193
|
+
def validate_rails_cache!
|
|
194
|
+
return if defined?(Rails) && Rails.respond_to?(:cache)
|
|
195
|
+
|
|
196
|
+
raise ConfigurationError,
|
|
197
|
+
"Rails.cache is not available. Rails cache backend requires Rails with a configured cache store."
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|