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.
@@ -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