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,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