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,275 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Langfuse
|
|
4
|
+
# Serialization layer that converts Langfuse domain models to OpenTelemetry span attributes format
|
|
5
|
+
#
|
|
6
|
+
# This module provides methods to convert user-friendly Langfuse attribute objects
|
|
7
|
+
# into the internal OpenTelemetry attribute format required by the span processor.
|
|
8
|
+
#
|
|
9
|
+
# @example Converting trace attributes
|
|
10
|
+
# attrs = Langfuse::Types::TraceAttributes.new(
|
|
11
|
+
# name: "user-checkout-flow",
|
|
12
|
+
# user_id: "user-123",
|
|
13
|
+
# tags: ["checkout", "payment"],
|
|
14
|
+
# metadata: { version: "2.1.0" }
|
|
15
|
+
# )
|
|
16
|
+
# otel_attrs = Langfuse::OtelAttributes.create_trace_attributes(attrs)
|
|
17
|
+
# span.set_attributes(otel_attrs)
|
|
18
|
+
#
|
|
19
|
+
# @example Converting observation attributes
|
|
20
|
+
# attrs = Langfuse::Types::GenerationAttributes.new(
|
|
21
|
+
# model: "gpt-4",
|
|
22
|
+
# input: { messages: [...] },
|
|
23
|
+
# usage_details: { prompt_tokens: 100 }
|
|
24
|
+
# )
|
|
25
|
+
# otel_attrs = Langfuse::OtelAttributes.create_observation_attributes("generation", attrs)
|
|
26
|
+
# span.set_attributes(otel_attrs)
|
|
27
|
+
#
|
|
28
|
+
# rubocop:disable Metrics/ModuleLength
|
|
29
|
+
module OtelAttributes
|
|
30
|
+
# Trace attributes
|
|
31
|
+
TRACE_NAME = "langfuse.trace.name"
|
|
32
|
+
# TRACE_USER_ID and TRACE_SESSION_ID are without langfuse prefix
|
|
33
|
+
# because they follow OpenTelemetry semantic conventions
|
|
34
|
+
TRACE_USER_ID = "user.id"
|
|
35
|
+
TRACE_SESSION_ID = "session.id"
|
|
36
|
+
TRACE_INPUT = "langfuse.trace.input"
|
|
37
|
+
TRACE_OUTPUT = "langfuse.trace.output"
|
|
38
|
+
TRACE_METADATA = "langfuse.trace.metadata"
|
|
39
|
+
TRACE_TAGS = "langfuse.trace.tags"
|
|
40
|
+
TRACE_PUBLIC = "langfuse.trace.public"
|
|
41
|
+
|
|
42
|
+
# Observation attributes
|
|
43
|
+
OBSERVATION_TYPE = "langfuse.observation.type"
|
|
44
|
+
OBSERVATION_INPUT = "langfuse.observation.input"
|
|
45
|
+
OBSERVATION_OUTPUT = "langfuse.observation.output"
|
|
46
|
+
OBSERVATION_METADATA = "langfuse.observation.metadata"
|
|
47
|
+
OBSERVATION_LEVEL = "langfuse.observation.level"
|
|
48
|
+
OBSERVATION_STATUS_MESSAGE = "langfuse.observation.status_message"
|
|
49
|
+
OBSERVATION_MODEL = "langfuse.observation.model.name"
|
|
50
|
+
OBSERVATION_MODEL_PARAMETERS = "langfuse.observation.model.parameters"
|
|
51
|
+
OBSERVATION_USAGE_DETAILS = "langfuse.observation.usage_details"
|
|
52
|
+
OBSERVATION_COST_DETAILS = "langfuse.observation.cost_details"
|
|
53
|
+
OBSERVATION_PROMPT_NAME = "langfuse.observation.prompt.name"
|
|
54
|
+
OBSERVATION_PROMPT_VERSION = "langfuse.observation.prompt.version"
|
|
55
|
+
OBSERVATION_COMPLETION_START_TIME = "langfuse.observation.completion_start_time"
|
|
56
|
+
|
|
57
|
+
# Common attributes
|
|
58
|
+
VERSION = "langfuse.version"
|
|
59
|
+
RELEASE = "langfuse.release"
|
|
60
|
+
ENVIRONMENT = "langfuse.environment"
|
|
61
|
+
|
|
62
|
+
# Creates OpenTelemetry attributes from Langfuse trace attributes
|
|
63
|
+
#
|
|
64
|
+
# Converts user-friendly trace attributes into the internal OpenTelemetry
|
|
65
|
+
# attribute format required by the span processor.
|
|
66
|
+
#
|
|
67
|
+
# @param attrs [Types::TraceAttributes, Hash] Trace attributes object or hash
|
|
68
|
+
# @return [Hash] OpenTelemetry attributes hash with non-nil values
|
|
69
|
+
#
|
|
70
|
+
# @example
|
|
71
|
+
# attrs = Langfuse::Types::TraceAttributes.new(
|
|
72
|
+
# name: "user-checkout-flow",
|
|
73
|
+
# user_id: "user-123",
|
|
74
|
+
# session_id: "session-456",
|
|
75
|
+
# tags: ["checkout", "payment"],
|
|
76
|
+
# metadata: { version: "2.1.0" }
|
|
77
|
+
# )
|
|
78
|
+
# otel_attrs = Langfuse::OtelAttributes.create_trace_attributes(attrs)
|
|
79
|
+
#
|
|
80
|
+
def self.create_trace_attributes(attrs)
|
|
81
|
+
# Convert to hash if it's a TraceAttributes object
|
|
82
|
+
attrs = attrs.to_h
|
|
83
|
+
get_value = ->(key) { get_hash_value(attrs, key) }
|
|
84
|
+
|
|
85
|
+
attributes = {
|
|
86
|
+
TRACE_NAME => get_value.call(:name),
|
|
87
|
+
TRACE_USER_ID => get_value.call(:user_id),
|
|
88
|
+
TRACE_SESSION_ID => get_value.call(:session_id),
|
|
89
|
+
VERSION => get_value.call(:version),
|
|
90
|
+
RELEASE => get_value.call(:release),
|
|
91
|
+
TRACE_INPUT => serialize(get_value.call(:input)),
|
|
92
|
+
TRACE_OUTPUT => serialize(get_value.call(:output)),
|
|
93
|
+
TRACE_TAGS => serialize(get_value.call(:tags)),
|
|
94
|
+
ENVIRONMENT => get_value.call(:environment),
|
|
95
|
+
TRACE_PUBLIC => get_value.call(:public),
|
|
96
|
+
**flatten_metadata(get_value.call(:metadata), TRACE_METADATA)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
# Remove nil values
|
|
100
|
+
attributes.compact
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Creates OpenTelemetry attributes from Langfuse observation attributes
|
|
104
|
+
#
|
|
105
|
+
# Converts user-friendly observation attributes into the internal OpenTelemetry
|
|
106
|
+
# attribute format required by the span processor.
|
|
107
|
+
#
|
|
108
|
+
# @param type [String] Observation type (e.g., "generation", "span", "event")
|
|
109
|
+
# @param attrs [Types::SpanAttributes, Types::GenerationAttributes, Hash] Observation attributes
|
|
110
|
+
# @return [Hash] OpenTelemetry attributes hash with non-nil values
|
|
111
|
+
#
|
|
112
|
+
# @example
|
|
113
|
+
# attrs = Langfuse::Types::GenerationAttributes.new(
|
|
114
|
+
# model: "gpt-4",
|
|
115
|
+
# input: { messages: [...] },
|
|
116
|
+
# usage_details: { prompt_tokens: 100 }
|
|
117
|
+
# )
|
|
118
|
+
# otel_attrs = Langfuse::OtelAttributes.create_observation_attributes("generation", attrs)
|
|
119
|
+
#
|
|
120
|
+
def self.create_observation_attributes(type, attrs)
|
|
121
|
+
attrs = attrs.to_h
|
|
122
|
+
get_value = ->(key) { get_hash_value(attrs, key) }
|
|
123
|
+
|
|
124
|
+
otel_attributes = build_observation_base_attributes(type, get_value)
|
|
125
|
+
add_prompt_attributes(otel_attributes, get_value.call(:prompt))
|
|
126
|
+
|
|
127
|
+
# Remove nil values
|
|
128
|
+
otel_attributes.compact
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Safely serializes an object to JSON string
|
|
132
|
+
#
|
|
133
|
+
# @param obj [Object, nil] Object to serialize
|
|
134
|
+
# @param preserve_strings [Boolean] If true, preserves strings as-is; if false, JSON-serializes everything including strings
|
|
135
|
+
# @return [String, nil] JSON string, original string (if preserve_strings is true), or nil if nil/undefined
|
|
136
|
+
#
|
|
137
|
+
# @example Always JSON-serialize (default)
|
|
138
|
+
# serialize({ key: "value" }) # => '{"key":"value"}'
|
|
139
|
+
# serialize("string") # => '"string"'
|
|
140
|
+
# serialize(nil) # => nil
|
|
141
|
+
#
|
|
142
|
+
# @example Preserve strings
|
|
143
|
+
# serialize("already a string", preserve_strings: true) # => "already a string"
|
|
144
|
+
# serialize([1, 2, 3], preserve_strings: true) # => "[1,2,3]"
|
|
145
|
+
#
|
|
146
|
+
# @api private
|
|
147
|
+
def self.serialize(obj, preserve_strings: false)
|
|
148
|
+
return nil if obj.nil?
|
|
149
|
+
return obj if preserve_strings && obj.is_a?(String)
|
|
150
|
+
|
|
151
|
+
begin
|
|
152
|
+
obj.to_json
|
|
153
|
+
rescue StandardError
|
|
154
|
+
nil
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Flattens and serializes metadata into OpenTelemetry attribute format
|
|
159
|
+
#
|
|
160
|
+
# Converts nested metadata objects into dot-notation attribute keys.
|
|
161
|
+
# For example, `{ database: { host: 'localhost' } }` becomes
|
|
162
|
+
# `{ 'langfuse.trace.metadata.database.host': 'localhost' }`.
|
|
163
|
+
#
|
|
164
|
+
# @param metadata [Hash, Array, Object, nil] Metadata to flatten
|
|
165
|
+
# @param prefix [String] Prefix for attribute keys (e.g., "langfuse.trace.metadata")
|
|
166
|
+
# @return [Hash] Flattened metadata attributes
|
|
167
|
+
#
|
|
168
|
+
# @example
|
|
169
|
+
# flatten_metadata({ user: { id: 123 } }, "langfuse.trace.metadata")
|
|
170
|
+
# # => { "langfuse.trace.metadata.user.id" => "123" }
|
|
171
|
+
#
|
|
172
|
+
def self.flatten_metadata(metadata, prefix)
|
|
173
|
+
return {} if metadata.nil?
|
|
174
|
+
|
|
175
|
+
# Handle non-hash metadata (arrays, primitives, etc.)
|
|
176
|
+
unless metadata.is_a?(Hash)
|
|
177
|
+
serialized = serialize(metadata, preserve_strings: true)
|
|
178
|
+
return serialized ? { prefix => serialized } : {}
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Recursively flatten hash metadata
|
|
182
|
+
result = {}
|
|
183
|
+
metadata.each do |key, value|
|
|
184
|
+
next if value.nil?
|
|
185
|
+
|
|
186
|
+
new_key = "#{prefix}.#{key}"
|
|
187
|
+
result.merge!(flatten_hash_value(value, new_key))
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
result
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Flattens a single hash value (recursively if it's a hash, serializes otherwise)
|
|
194
|
+
#
|
|
195
|
+
# @param value [Object] Value to flatten
|
|
196
|
+
# @param key [String] Attribute key prefix
|
|
197
|
+
# @return [Hash] Flattened attributes hash
|
|
198
|
+
# @api private
|
|
199
|
+
def self.flatten_hash_value(value, key)
|
|
200
|
+
if value.is_a?(Hash)
|
|
201
|
+
# Recursively flatten nested hashes
|
|
202
|
+
flatten_metadata(value, key)
|
|
203
|
+
elsif value.is_a?(Array)
|
|
204
|
+
# Serialize arrays to JSON
|
|
205
|
+
serialized = serialize(value, preserve_strings: true)
|
|
206
|
+
serialized ? { key => serialized } : {}
|
|
207
|
+
else
|
|
208
|
+
# Convert simple values (strings, numbers, booleans) to strings
|
|
209
|
+
{ key => value.to_s }
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Gets a value from a hash supporting both symbol and string keys
|
|
214
|
+
# Handles false values correctly (doesn't treat false as nil)
|
|
215
|
+
#
|
|
216
|
+
# @param hash [Hash] Hash to get value from
|
|
217
|
+
# @param key [Symbol, String] Key to look up
|
|
218
|
+
# @return [Object, nil] Value from hash or nil
|
|
219
|
+
# @api private
|
|
220
|
+
def self.get_hash_value(hash, key)
|
|
221
|
+
return hash[key] if hash.key?(key)
|
|
222
|
+
return hash[key.to_s] if hash.key?(key.to_s)
|
|
223
|
+
|
|
224
|
+
nil
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Builds base observation attributes (without prompt)
|
|
228
|
+
#
|
|
229
|
+
# @param type [String] Observation type
|
|
230
|
+
# @param get_value [Proc] Lambda to get values from attributes hash
|
|
231
|
+
# @return [Hash] Base observation attributes
|
|
232
|
+
# @api private
|
|
233
|
+
def self.build_observation_base_attributes(type, get_value)
|
|
234
|
+
{
|
|
235
|
+
OBSERVATION_TYPE => type,
|
|
236
|
+
OBSERVATION_LEVEL => get_value.call(:level),
|
|
237
|
+
OBSERVATION_STATUS_MESSAGE => get_value.call(:status_message),
|
|
238
|
+
VERSION => get_value.call(:version),
|
|
239
|
+
OBSERVATION_INPUT => serialize(get_value.call(:input)),
|
|
240
|
+
OBSERVATION_OUTPUT => serialize(get_value.call(:output)),
|
|
241
|
+
OBSERVATION_MODEL => get_value.call(:model),
|
|
242
|
+
OBSERVATION_USAGE_DETAILS => serialize(get_value.call(:usage_details)),
|
|
243
|
+
OBSERVATION_COST_DETAILS => serialize(get_value.call(:cost_details)),
|
|
244
|
+
OBSERVATION_COMPLETION_START_TIME => serialize(get_value.call(:completion_start_time)),
|
|
245
|
+
OBSERVATION_MODEL_PARAMETERS => serialize(get_value.call(:model_parameters)),
|
|
246
|
+
ENVIRONMENT => get_value.call(:environment),
|
|
247
|
+
**flatten_metadata(get_value.call(:metadata), OBSERVATION_METADATA)
|
|
248
|
+
}
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Adds prompt attributes if prompt is present and not a fallback
|
|
252
|
+
#
|
|
253
|
+
# @param otel_attributes [Hash] Attributes hash to modify
|
|
254
|
+
# @param prompt [Hash, Object, nil] Prompt hash or object
|
|
255
|
+
# @return [void]
|
|
256
|
+
# @api private
|
|
257
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
258
|
+
def self.add_prompt_attributes(otel_attributes, prompt)
|
|
259
|
+
return unless prompt
|
|
260
|
+
|
|
261
|
+
# Handle hash-like prompts
|
|
262
|
+
if prompt.is_a?(Hash) || prompt.respond_to?(:[])
|
|
263
|
+
return if prompt[:is_fallback] || prompt["is_fallback"]
|
|
264
|
+
|
|
265
|
+
otel_attributes[OBSERVATION_PROMPT_NAME] = prompt[:name] || prompt["name"]
|
|
266
|
+
otel_attributes[OBSERVATION_PROMPT_VERSION] = prompt[:version] || prompt["version"]
|
|
267
|
+
# Handle objects with name/version methods (already converted in Trace#generation)
|
|
268
|
+
elsif prompt.respond_to?(:name) && prompt.respond_to?(:version)
|
|
269
|
+
otel_attributes[OBSERVATION_PROMPT_NAME] = prompt.name
|
|
270
|
+
otel_attributes[OBSERVATION_PROMPT_VERSION] = prompt.version
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/ModuleLength
|
|
275
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "opentelemetry/sdk"
|
|
4
|
+
require "opentelemetry/exporter/otlp"
|
|
5
|
+
require "opentelemetry/trace/propagation/trace_context"
|
|
6
|
+
require "base64"
|
|
7
|
+
|
|
8
|
+
module Langfuse
|
|
9
|
+
# OpenTelemetry initialization and setup
|
|
10
|
+
#
|
|
11
|
+
# Handles configuration of the OTel SDK with Langfuse OTLP exporter
|
|
12
|
+
# when tracing is enabled.
|
|
13
|
+
#
|
|
14
|
+
module OtelSetup
|
|
15
|
+
class << self
|
|
16
|
+
attr_reader :tracer_provider
|
|
17
|
+
|
|
18
|
+
# Initialize OpenTelemetry with Langfuse OTLP exporter
|
|
19
|
+
#
|
|
20
|
+
# @param config [Langfuse::Config] The Langfuse configuration
|
|
21
|
+
# @return [void]
|
|
22
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
23
|
+
def setup(config)
|
|
24
|
+
# Create OTLP exporter configured for Langfuse
|
|
25
|
+
exporter = OpenTelemetry::Exporter::OTLP::Exporter.new(
|
|
26
|
+
endpoint: "#{config.base_url}/api/public/otel/v1/traces",
|
|
27
|
+
headers: build_headers(config.public_key, config.secret_key),
|
|
28
|
+
compression: "gzip"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Create processor based on async configuration
|
|
32
|
+
# IMPORTANT: Always use BatchSpanProcessor (even in sync mode) to ensure spans
|
|
33
|
+
# are exported together, which allows proper parent-child relationship detection
|
|
34
|
+
processor = if config.tracing_async
|
|
35
|
+
# Async: BatchSpanProcessor batches and sends in background
|
|
36
|
+
OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(
|
|
37
|
+
exporter,
|
|
38
|
+
max_queue_size: config.batch_size * 2, # Buffer more than batch_size
|
|
39
|
+
schedule_delay: config.flush_interval * 1000, # Convert seconds to milliseconds
|
|
40
|
+
max_export_batch_size: config.batch_size
|
|
41
|
+
)
|
|
42
|
+
else
|
|
43
|
+
# Sync: BatchSpanProcessor with minimal delay (flushes on force_flush)
|
|
44
|
+
# This collects spans from the same trace and exports them together,
|
|
45
|
+
# which is critical for correct parent_observation_id calculation
|
|
46
|
+
OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(
|
|
47
|
+
exporter,
|
|
48
|
+
max_queue_size: config.batch_size * 2,
|
|
49
|
+
schedule_delay: 60_000, # 60 seconds (relies on explicit force_flush)
|
|
50
|
+
max_export_batch_size: config.batch_size
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Create TracerProvider with processor
|
|
55
|
+
@tracer_provider = OpenTelemetry::SDK::Trace::TracerProvider.new
|
|
56
|
+
@tracer_provider.add_span_processor(processor)
|
|
57
|
+
|
|
58
|
+
# Add span processor for propagated attributes
|
|
59
|
+
# This must be added AFTER the BatchSpanProcessor to ensure attributes are set before export
|
|
60
|
+
span_processor = SpanProcessor.new
|
|
61
|
+
@tracer_provider.add_span_processor(span_processor)
|
|
62
|
+
|
|
63
|
+
# Set as global tracer provider
|
|
64
|
+
OpenTelemetry.tracer_provider = @tracer_provider
|
|
65
|
+
|
|
66
|
+
# Configure W3C TraceContext propagator if not already set
|
|
67
|
+
if OpenTelemetry.propagation.is_a?(OpenTelemetry::Context::Propagation::NoopTextMapPropagator)
|
|
68
|
+
OpenTelemetry.propagation = OpenTelemetry::Trace::Propagation::TraceContext::TextMapPropagator.new
|
|
69
|
+
config.logger.debug("Langfuse: Configured W3C TraceContext propagator")
|
|
70
|
+
else
|
|
71
|
+
config.logger.debug("Langfuse: Using existing propagator: #{OpenTelemetry.propagation.class}")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
mode = config.tracing_async ? "async" : "sync"
|
|
75
|
+
config.logger.info("Langfuse tracing initialized with OpenTelemetry (#{mode} mode)")
|
|
76
|
+
end
|
|
77
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
78
|
+
|
|
79
|
+
# Shutdown the tracer provider and flush any pending spans
|
|
80
|
+
#
|
|
81
|
+
# @param timeout [Integer] Timeout in seconds
|
|
82
|
+
# @return [void]
|
|
83
|
+
def shutdown(timeout: 30)
|
|
84
|
+
return unless @tracer_provider
|
|
85
|
+
|
|
86
|
+
@tracer_provider.shutdown(timeout: timeout)
|
|
87
|
+
@tracer_provider = nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Force flush all pending spans
|
|
91
|
+
#
|
|
92
|
+
# @param timeout [Integer] Timeout in seconds
|
|
93
|
+
# @return [void]
|
|
94
|
+
def force_flush(timeout: 30)
|
|
95
|
+
return unless @tracer_provider
|
|
96
|
+
|
|
97
|
+
@tracer_provider.force_flush(timeout: timeout)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Check if OTel is initialized
|
|
101
|
+
#
|
|
102
|
+
# @return [Boolean]
|
|
103
|
+
def initialized?
|
|
104
|
+
!@tracer_provider.nil?
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
# Build HTTP headers for Langfuse OTLP endpoint
|
|
110
|
+
#
|
|
111
|
+
# @param public_key [String] Langfuse public API key
|
|
112
|
+
# @param secret_key [String] Langfuse secret API key
|
|
113
|
+
# @return [Hash] HTTP headers with Basic Auth
|
|
114
|
+
def build_headers(public_key, secret_key)
|
|
115
|
+
credentials = "#{public_key}:#{secret_key}"
|
|
116
|
+
encoded = Base64.strict_encode64(credentials)
|
|
117
|
+
{
|
|
118
|
+
"Authorization" => "Basic #{encoded}"
|
|
119
|
+
}
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
|
|
5
|
+
module Langfuse
|
|
6
|
+
# Simple in-memory cache for prompt data with TTL
|
|
7
|
+
#
|
|
8
|
+
# Thread-safe cache implementation for storing prompt responses
|
|
9
|
+
# with time-to-live expiration.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# cache = Langfuse::PromptCache.new(ttl: 60)
|
|
13
|
+
# cache.set("greeting:1", prompt_data)
|
|
14
|
+
# cache.get("greeting:1") # => prompt_data
|
|
15
|
+
#
|
|
16
|
+
class PromptCache
|
|
17
|
+
# Cache entry with data and expiration time
|
|
18
|
+
CacheEntry = Struct.new(:data, :expires_at) do
|
|
19
|
+
def expired?
|
|
20
|
+
Time.now > expires_at
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
attr_reader :ttl, :max_size
|
|
25
|
+
|
|
26
|
+
# Initialize a new cache
|
|
27
|
+
#
|
|
28
|
+
# @param ttl [Integer] Time-to-live in seconds (default: 60)
|
|
29
|
+
# @param max_size [Integer] Maximum cache size (default: 1000)
|
|
30
|
+
def initialize(ttl: 60, max_size: 1000)
|
|
31
|
+
@ttl = ttl
|
|
32
|
+
@max_size = max_size
|
|
33
|
+
@cache = {}
|
|
34
|
+
@monitor = Monitor.new
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Get a value from the cache
|
|
38
|
+
#
|
|
39
|
+
# @param key [String] Cache key
|
|
40
|
+
# @return [Object, nil] Cached value or nil if not found/expired
|
|
41
|
+
def get(key)
|
|
42
|
+
@monitor.synchronize do
|
|
43
|
+
entry = @cache[key]
|
|
44
|
+
return nil unless entry
|
|
45
|
+
return nil if entry.expired?
|
|
46
|
+
|
|
47
|
+
entry.data
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Set a value in the cache
|
|
52
|
+
#
|
|
53
|
+
# @param key [String] Cache key
|
|
54
|
+
# @param value [Object] Value to cache
|
|
55
|
+
# @return [Object] The cached value
|
|
56
|
+
def set(key, value)
|
|
57
|
+
@monitor.synchronize do
|
|
58
|
+
# Evict oldest entry if at max size
|
|
59
|
+
evict_oldest if @cache.size >= max_size
|
|
60
|
+
|
|
61
|
+
expires_at = Time.now + ttl
|
|
62
|
+
@cache[key] = CacheEntry.new(value, expires_at)
|
|
63
|
+
value
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Clear the entire cache
|
|
68
|
+
#
|
|
69
|
+
# @return [void]
|
|
70
|
+
def clear
|
|
71
|
+
@monitor.synchronize do
|
|
72
|
+
@cache.clear
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Remove expired entries from cache
|
|
77
|
+
#
|
|
78
|
+
# @return [Integer] Number of entries removed
|
|
79
|
+
def cleanup_expired
|
|
80
|
+
@monitor.synchronize do
|
|
81
|
+
initial_size = @cache.size
|
|
82
|
+
@cache.delete_if { |_key, entry| entry.expired? }
|
|
83
|
+
initial_size - @cache.size
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Get current cache size
|
|
88
|
+
#
|
|
89
|
+
# @return [Integer] Number of entries in cache
|
|
90
|
+
def size
|
|
91
|
+
@monitor.synchronize do
|
|
92
|
+
@cache.size
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Check if cache is empty
|
|
97
|
+
#
|
|
98
|
+
# @return [Boolean]
|
|
99
|
+
def empty?
|
|
100
|
+
@monitor.synchronize do
|
|
101
|
+
@cache.empty?
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Build a cache key from prompt name and options
|
|
106
|
+
#
|
|
107
|
+
# @param name [String] Prompt name
|
|
108
|
+
# @param version [Integer, nil] Optional version
|
|
109
|
+
# @param label [String, nil] Optional label
|
|
110
|
+
# @return [String] Cache key
|
|
111
|
+
def self.build_key(name, version: nil, label: nil)
|
|
112
|
+
key = name.to_s
|
|
113
|
+
key += ":v#{version}" if version
|
|
114
|
+
key += ":#{label}" if label
|
|
115
|
+
key
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
# Evict the oldest entry from cache
|
|
121
|
+
#
|
|
122
|
+
# @return [void]
|
|
123
|
+
def evict_oldest
|
|
124
|
+
return if @cache.empty?
|
|
125
|
+
|
|
126
|
+
# Find entry with earliest expiration
|
|
127
|
+
oldest_key = @cache.min_by { |_key, entry| entry.expires_at }&.first
|
|
128
|
+
@cache.delete(oldest_key) if oldest_key
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|