langfuse-rb 0.10.0 → 0.11.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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +44 -1
  3. data/lib/langfuse/api_client.rb +175 -509
  4. data/lib/langfuse/app_root_tracking.rb +164 -0
  5. data/lib/langfuse/cache_warmer.rb +21 -13
  6. data/lib/langfuse/chat_prompt_client.rb +21 -4
  7. data/lib/langfuse/client.rb +174 -229
  8. data/lib/langfuse/config.rb +308 -65
  9. data/lib/langfuse/evaluation.rb +8 -4
  10. data/lib/langfuse/exit_hook.rb +77 -0
  11. data/lib/langfuse/fork_safety.rb +71 -0
  12. data/lib/langfuse/masking_exporter.rb +98 -0
  13. data/lib/langfuse/observations.rb +2 -1
  14. data/lib/langfuse/otel_attributes.rb +1 -0
  15. data/lib/langfuse/otel_setup.rb +32 -44
  16. data/lib/langfuse/otel_span_batch.rb +113 -0
  17. data/lib/langfuse/otel_span_masking.rb +89 -0
  18. data/lib/langfuse/otel_span_patch_applier.rb +97 -0
  19. data/lib/langfuse/pending_score_queue.rb +62 -0
  20. data/lib/langfuse/prompt_cache.rb +11 -0
  21. data/lib/langfuse/prompt_cache_coordinator.rb +288 -0
  22. data/lib/langfuse/prompt_cache_events.rb +31 -10
  23. data/lib/langfuse/prompt_variables.rb +54 -0
  24. data/lib/langfuse/propagation.rb +101 -34
  25. data/lib/langfuse/rails_cache_adapter.rb +27 -2
  26. data/lib/langfuse/read_api.rb +242 -0
  27. data/lib/langfuse/resilient_metrics_reporter.rb +60 -0
  28. data/lib/langfuse/score_client.rb +209 -89
  29. data/lib/langfuse/score_value.rb +58 -0
  30. data/lib/langfuse/span_processor.rb +53 -4
  31. data/lib/langfuse/stale_while_revalidate.rb +3 -4
  32. data/lib/langfuse/text_prompt_client.rb +15 -6
  33. data/lib/langfuse/trace_export_guard.rb +47 -0
  34. data/lib/langfuse/traced_execution.rb +18 -12
  35. data/lib/langfuse/types.rb +15 -1
  36. data/lib/langfuse/version.rb +1 -1
  37. data/lib/langfuse.rb +157 -44
  38. metadata +16 -2
@@ -0,0 +1,288 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "prompt_fetch_result"
4
+ require_relative "prompt_cache_events"
5
+
6
+ module Langfuse
7
+ # Coordinates prompt fetch/cache behavior between the API transport and the
8
+ # configured cache backend. Both supported backends ({PromptCache} and
9
+ # {RailsCacheAdapter}) provide the full cache + SWR surface; only
10
+ # {RailsCacheAdapter} adds distributed-lock fetch, which is the one branch
11
+ # the dispatch needs to make.
12
+ #
13
+ # @api private
14
+ class PromptCacheCoordinator # rubocop:disable Metrics/ClassLength
15
+ # @param cache [PromptCache, RailsCacheAdapter, nil] Configured cache backend
16
+ # @param event_emitter [#emit_prompt_cache_event] Emitter for cache events
17
+ # @param fetch_prompt [#call] Callable that fetches prompt data from the API
18
+ # @return [PromptCacheCoordinator]
19
+ def initialize(cache:, event_emitter:, fetch_prompt:)
20
+ @cache = cache
21
+ @event_emitter = event_emitter
22
+ @fetch_prompt = fetch_prompt
23
+ @backend_name = compute_backend_name
24
+ end
25
+
26
+ # @return [String] Backend identifier reported in events and stats
27
+ attr_reader :backend_name
28
+
29
+ # Fetch a prompt and include cache metadata.
30
+ #
31
+ # @param name [String] Prompt name
32
+ # @param version [Integer, nil] Optional prompt version
33
+ # @param label [String, nil] Optional prompt label
34
+ # @param cache_ttl [Integer, nil] Optional TTL override (0 forces a bypass)
35
+ # @return [PromptFetchResult] Prompt data plus cache metadata
36
+ def get_prompt_result(name, version: nil, label: nil, cache_ttl: nil)
37
+ validate_fetch_options!(version, label, cache_ttl)
38
+ key = prompt_cache_key(name, version: version, label: label)
39
+
40
+ return fetch_uncached(key, CacheStatus::DISABLED) if @cache.nil?
41
+ return fetch_uncached(key, CacheStatus::BYPASS) if cache_ttl&.zero?
42
+
43
+ fetch_cached(key, cache_ttl)
44
+ end
45
+
46
+ # Refresh a prompt from the API, optionally writing through to cache.
47
+ #
48
+ # @param name [String] Prompt name
49
+ # @param version [Integer, nil] Optional prompt version
50
+ # @param label [String, nil] Optional prompt label
51
+ # @param cache_ttl [Integer, nil] Optional TTL override
52
+ # @return [PromptFetchResult] Prompt data plus cache metadata
53
+ def refresh_prompt(name, version: nil, label: nil, cache_ttl: nil)
54
+ validate_fetch_options!(version, label, cache_ttl)
55
+ key = prompt_cache_key(name, version: version, label: label)
56
+
57
+ emit(:refresh_start) { event_payload(key, CacheStatus::REFRESH, CacheSource::API) }
58
+ prompt_data = @fetch_prompt.call(name, version: version, label: label)
59
+ write_through(key, prompt_data, cache_ttl, status: CacheStatus::REFRESH) if @cache && !cache_ttl&.zero?
60
+ status = refresh_status(cache_ttl)
61
+ emit(:refresh_success) { event_payload(key, status, CacheSource::API) }
62
+ build_result(key, prompt_data, status, CacheSource::API)
63
+ rescue StandardError => e
64
+ emit(:refresh_failure) do
65
+ event_payload(key, CacheStatus::REFRESH, CacheSource::API,
66
+ error_class: e.class.name, error_message: e.message)
67
+ end
68
+ raise
69
+ end
70
+
71
+ # Inspect the logical and generated cache keys for a prompt.
72
+ #
73
+ # @param name [String] Prompt name
74
+ # @param version [Integer, nil] Optional prompt version
75
+ # @param label [String, nil] Optional prompt label
76
+ # @return [PromptCacheKey] Logical and generated cache keys
77
+ def prompt_cache_key(name, version: nil, label: nil)
78
+ raise ArgumentError, "Cannot specify both version and label" if version && label
79
+
80
+ logical_key = PromptCache.build_key(name, version: version, label: label)
81
+ storage_key = @cache ? @cache.storage_key(logical_key, name: name) : logical_key
82
+ PromptCacheKey.new(name: name, version: version, label: label, logical_key: logical_key, storage_key: storage_key)
83
+ end
84
+
85
+ # Invalidate one exact logical prompt cache key.
86
+ #
87
+ # @param name [String] Prompt name
88
+ # @param version [Integer, nil] Optional prompt version
89
+ # @param label [String, nil] Optional prompt label
90
+ # @return [PromptCacheKey] Invalidated key
91
+ def invalidate_prompt_cache(name, version: nil, label: nil)
92
+ key = prompt_cache_key(name, version: version, label: label)
93
+ deleted = @cache ? @cache.delete(key.storage_key) : false
94
+ emit(:delete) { event_payload(key, CacheStatus::MISS, CacheSource::CACHE, deleted: deleted) }
95
+ emit(:invalidate) { event_payload(key, CacheStatus::MISS, CacheSource::CACHE, scope: :exact) }
96
+ key
97
+ end
98
+
99
+ # Invalidate all cached variants for one prompt name.
100
+ #
101
+ # @param name [String] Prompt name
102
+ # @return [Integer, nil] New generation, or nil when caching is disabled
103
+ def invalidate_prompt_cache_by_name(name)
104
+ emit_name_invalidation(name, mutation: false)
105
+ end
106
+
107
+ # Invalidate after prompt mutation (create/update). Distinct from manual
108
+ # invalidation so observers can tell the two apart.
109
+ #
110
+ # @param name [String] Prompt name
111
+ # @return [Integer, nil] New generation
112
+ def invalidate_after_mutation(name)
113
+ emit_name_invalidation(name, mutation: true)
114
+ end
115
+
116
+ # Logically clear the entire prompt cache namespace.
117
+ #
118
+ # @return [Integer, nil] New global generation, or nil when caching is disabled
119
+ def clear_prompt_cache
120
+ generation = @cache&.clear_logically
121
+ emit(:clear, backend: @backend_name, generation: generation)
122
+ generation
123
+ end
124
+
125
+ # @return [Hash] Prompt cache statistics
126
+ def prompt_cache_stats
127
+ @cache ? @cache.stats : disabled_stats
128
+ end
129
+
130
+ private
131
+
132
+ def validate_fetch_options!(version, label, cache_ttl)
133
+ raise ArgumentError, "Cannot specify both version and label" if version && label
134
+ return if cache_ttl.nil?
135
+ raise ArgumentError, "cache_ttl must be a non-negative Integer" unless cache_ttl.is_a?(Integer)
136
+ raise ArgumentError, "cache_ttl must be non-negative" if cache_ttl.negative?
137
+ end
138
+
139
+ def fetch_uncached(key, status)
140
+ prompt_data = @fetch_prompt.call(key.name, version: key.version, label: key.label)
141
+ build_result(key, prompt_data, status, CacheSource::API)
142
+ end
143
+
144
+ # Single dispatch: SWR > distributed lock > simple get/set.
145
+ def fetch_cached(key, cache_ttl)
146
+ return fetch_with_swr(key, cache_ttl) if @cache.swr_enabled?
147
+ return fetch_with_lock(key, cache_ttl) if @cache.is_a?(RailsCacheAdapter)
148
+
149
+ cached = @cache.get(key.storage_key)
150
+ return cache_hit(key, cached) if cached
151
+
152
+ fetch_and_cache(key, cache_ttl, swr: false)
153
+ end
154
+
155
+ def fetch_with_swr(key, cache_ttl)
156
+ entry = @cache.entry(key.storage_key)
157
+ return cache_hit(key, entry.data) if entry.respond_to?(:fresh?) && entry.fresh?
158
+
159
+ if entry.respond_to?(:stale?) && entry.stale?
160
+ emit(:stale_serve) { event_payload(key, CacheStatus::STALE, CacheSource::CACHE) }
161
+ schedule_refresh(key, cache_ttl)
162
+ return build_result(key, entry.data, CacheStatus::STALE, CacheSource::CACHE)
163
+ end
164
+
165
+ fetch_and_cache(key, cache_ttl, swr: true)
166
+ end
167
+
168
+ def fetch_with_lock(key, cache_ttl)
169
+ cached = @cache.get(key.storage_key)
170
+ return cache_hit(key, cached) if cached
171
+
172
+ emit(:miss) { event_payload(key, CacheStatus::MISS, CacheSource::API) }
173
+ fetched = false
174
+ prompt_data = @cache.fetch_with_lock(key.storage_key, ttl: cache_ttl) do
175
+ fetched = true
176
+ @fetch_prompt.call(key.name, version: key.version, label: key.label)
177
+ end
178
+ emit(:write) { event_payload(key, CacheStatus::MISS, CacheSource::API) } if fetched
179
+ status = fetched ? CacheStatus::MISS : CacheStatus::HIT
180
+ source = fetched ? CacheSource::API : CacheSource::CACHE
181
+ build_result(key, prompt_data, status, source)
182
+ end
183
+
184
+ def fetch_and_cache(key, cache_ttl, swr:)
185
+ emit(:miss) { event_payload(key, CacheStatus::MISS, CacheSource::API) }
186
+ prompt_data = @fetch_prompt.call(key.name, version: key.version, label: key.label)
187
+ write_through(key, prompt_data, cache_ttl, swr: swr)
188
+ build_result(key, prompt_data, CacheStatus::MISS, CacheSource::API)
189
+ end
190
+
191
+ def write_through(key, prompt_data, cache_ttl, swr: false, status: CacheStatus::MISS)
192
+ if swr
193
+ @cache.write_with_stale_while_revalidate(key.storage_key, prompt_data, ttl: cache_ttl)
194
+ else
195
+ @cache.set(key.storage_key, prompt_data, ttl: cache_ttl)
196
+ end
197
+ emit(:write) { event_payload(key, status, CacheSource::API) }
198
+ end
199
+
200
+ def cache_hit(key, prompt_data)
201
+ emit(:hit) { event_payload(key, CacheStatus::HIT, CacheSource::CACHE) }
202
+ build_result(key, prompt_data, CacheStatus::HIT, CacheSource::CACHE)
203
+ end
204
+
205
+ def schedule_refresh(key, cache_ttl)
206
+ scheduled = @cache.refresh_async(
207
+ key.storage_key,
208
+ ttl: cache_ttl,
209
+ on_success: ->(_value) { emit_refresh_success(key) },
210
+ on_failure: ->(error) { emit_refresh_failure(key, error) }
211
+ ) { @fetch_prompt.call(key.name, version: key.version, label: key.label) }
212
+ emit(:refresh_start) { event_payload(key, CacheStatus::STALE, CacheSource::CACHE) } if scheduled
213
+ end
214
+
215
+ def emit_refresh_success(key)
216
+ emit(:refresh_success) { event_payload(key, CacheStatus::REFRESH, CacheSource::API) }
217
+ emit(:write) { event_payload(key, CacheStatus::REFRESH, CacheSource::API) }
218
+ end
219
+
220
+ def emit_refresh_failure(key, error)
221
+ emit(:refresh_failure) do
222
+ event_payload(key, CacheStatus::STALE, CacheSource::CACHE,
223
+ error_class: error.class.name, error_message: error.message)
224
+ end
225
+ end
226
+
227
+ def emit_name_invalidation(name, mutation:)
228
+ generation = @cache&.invalidate_name(name)
229
+ payload = { name: name, backend: @backend_name, generation: generation, scope: :name }
230
+ payload[:mutation] = true if mutation
231
+ emit(:invalidate, payload)
232
+ generation
233
+ end
234
+
235
+ def refresh_status(cache_ttl)
236
+ return CacheStatus::DISABLED unless @cache
237
+ return CacheStatus::BYPASS if cache_ttl&.zero?
238
+
239
+ CacheStatus::REFRESH
240
+ end
241
+
242
+ def build_result(key, prompt_data, status, source)
243
+ PromptFetchResult.new(
244
+ prompt: prompt_data,
245
+ logical_key: key.logical_key,
246
+ storage_key: key.storage_key,
247
+ cache_status: status,
248
+ source: source,
249
+ name: prompt_data["name"] || key.name,
250
+ version: prompt_data["version"] || key.version,
251
+ label: key.resolved_label
252
+ )
253
+ end
254
+
255
+ def emit(event, payload = nil, &)
256
+ @event_emitter.emit_prompt_cache_event(event, payload, &)
257
+ end
258
+
259
+ def event_payload(key, cache_status, source, **extra)
260
+ PromptCacheEvents.build_payload(
261
+ key,
262
+ cache_status: cache_status,
263
+ source: source,
264
+ backend: @backend_name,
265
+ extra: extra
266
+ )
267
+ end
268
+
269
+ def compute_backend_name
270
+ return CacheBackend::DISABLED unless @cache
271
+ return CacheBackend::RAILS if @cache.is_a?(RailsCacheAdapter)
272
+ return CacheBackend::MEMORY if @cache.is_a?(PromptCache)
273
+
274
+ @cache.class.name
275
+ end
276
+
277
+ def disabled_stats
278
+ {
279
+ backend: CacheBackend::DISABLED,
280
+ enabled: false,
281
+ current_generation_entries: nil,
282
+ orphaned_entries: nil,
283
+ total_entries: nil,
284
+ unsupported_counts: CacheBackend::UNSUPPORTED_COUNT_KEYS
285
+ }
286
+ end
287
+ end
288
+ end
@@ -10,6 +10,29 @@ module Langfuse
10
10
  # ActiveSupport::Notifications event name used for prompt cache events.
11
11
  PROMPT_CACHE_NOTIFICATION = "prompt_cache.langfuse"
12
12
 
13
+ # Build a prompt cache event payload from a key, status, source, and backend.
14
+ # Shared by the ApiClient mixin and PromptCacheCoordinator so a payload-shape
15
+ # change can't drift between the two emitters.
16
+ #
17
+ # @param key [PromptCacheKey] Logical and storage cache key
18
+ # @param cache_status [Symbol] Cache status
19
+ # @param source [Symbol] Event source
20
+ # @param backend [String] Backend identifier
21
+ # @param extra [Hash] Additional payload fields
22
+ # @return [Hash] Event payload
23
+ def self.build_payload(key, cache_status:, source:, backend:, extra: {})
24
+ {
25
+ name: key.name,
26
+ version: key.version,
27
+ label: key.resolved_label,
28
+ logical_key: key.logical_key,
29
+ storage_key: key.storage_key,
30
+ backend: backend,
31
+ cache_status: cache_status,
32
+ source: source
33
+ }.merge(extra)
34
+ end
35
+
13
36
  # Configure prompt cache event dispatch. Wraps the observer once into a
14
37
  # 1-arg callable so the per-event hot path never re-checks arity.
15
38
  #
@@ -56,16 +79,13 @@ module Langfuse
56
79
 
57
80
  # @api private
58
81
  def event_payload(key, cache_status, source, extra = {})
59
- {
60
- name: key.name,
61
- version: key.version,
62
- label: key.resolved_label,
63
- logical_key: key.logical_key,
64
- storage_key: key.storage_key,
65
- backend: cache_backend_name,
82
+ PromptCacheEvents.build_payload(
83
+ key,
66
84
  cache_status: cache_status,
67
- source: source
68
- }.merge(extra)
85
+ source: source,
86
+ backend: cache_backend_name,
87
+ extra: extra
88
+ )
69
89
  end
70
90
 
71
91
  # @api private
@@ -100,7 +120,8 @@ module Langfuse
100
120
  def wrap_cache_observer(observer)
101
121
  return nil if observer.nil?
102
122
 
103
- if observer.method(:call).arity == 1
123
+ arity = observer.respond_to?(:arity) ? observer.arity : observer.method(:call).arity
124
+ if arity == 1
104
125
  ->(payload) { observer.call(payload) }
105
126
  else
106
127
  ->(payload) { observer.call(payload[:event], payload) }
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mustache"
4
+
5
+ module Langfuse
6
+ # Extracts referenced variables from parsed Mustache templates.
7
+ #
8
+ # @api private
9
+ class PromptVariables
10
+ TAG_TYPES = %i[etag utag].freeze
11
+ SECTION_TYPES = %i[section inverted_section].freeze
12
+
13
+ class << self
14
+ # @api private
15
+ def extract(template)
16
+ tokens = Mustache::Template.new(template).tokens
17
+ collect(tokens, []).reject(&:empty?).uniq
18
+ end
19
+
20
+ private
21
+
22
+ def collect(tokens, scope)
23
+ tokens.each_with_object([]) do |token, variables|
24
+ next unless token.is_a?(Array)
25
+
26
+ variables.concat(token.first == :mustache ? from_tag(token, scope) : collect(token, scope))
27
+ end
28
+ end
29
+
30
+ def from_tag(token, scope)
31
+ return variable_path(token, scope) if TAG_TYPES.include?(token[1])
32
+ return section_paths(token, scope) if SECTION_TYPES.include?(token[1])
33
+
34
+ []
35
+ end
36
+
37
+ def variable_path(token, scope)
38
+ path = scoped_path(token, scope)
39
+ path.empty? ? [] : [path.join(".")]
40
+ end
41
+
42
+ def section_paths(token, scope)
43
+ section_path = scoped_path(token, scope)
44
+ body_scope = token[1] == :section ? section_path : scope
45
+ [section_path.join("."), *collect(token[4], body_scope)]
46
+ end
47
+
48
+ def scoped_path(token, scope)
49
+ segments = token.dig(2, 2)
50
+ segments == ["."] ? scope : scope + segments
51
+ end
52
+ end
53
+ end
54
+ end
@@ -6,7 +6,7 @@ module Langfuse
6
6
  # Attribute propagation utilities for Langfuse OpenTelemetry integration.
7
7
  #
8
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
9
+ # attributes that automatically propagate to all child spans
10
10
  # within the context.
11
11
  #
12
12
  # @example Basic usage
@@ -21,29 +21,31 @@ module Langfuse
21
21
  #
22
22
  # rubocop:disable Metrics/ModuleLength
23
23
  module Propagation
24
+ # Baggage key prefix for cross-service propagation
25
+ BAGGAGE_PREFIX = "langfuse_"
26
+
27
+ # Baggage key that records which Langfuse trace already owns the application root
28
+ LANGFUSE_TRACE_ID_BAGGAGE_KEY = "#{BAGGAGE_PREFIX}trace_id".freeze
29
+
30
+ ENVIRONMENT_VALUE_PATTERN = /\A(?!langfuse)[a-z0-9_-]+\z/
31
+ private_constant :ENVIRONMENT_VALUE_PATTERN
32
+
24
33
  # Map of propagated attribute keys to span attribute keys
25
34
  SPAN_KEY_MAP = {
26
35
  "user_id" => OtelAttributes::TRACE_USER_ID,
27
36
  "session_id" => OtelAttributes::TRACE_SESSION_ID,
28
37
  "version" => OtelAttributes::VERSION,
29
38
  "tags" => OtelAttributes::TRACE_TAGS,
30
- "metadata" => OtelAttributes::TRACE_METADATA
39
+ "metadata" => OtelAttributes::TRACE_METADATA,
40
+ "trace_name" => OtelAttributes::TRACE_NAME,
41
+ "release" => OtelAttributes::RELEASE,
42
+ "environment" => OtelAttributes::ENVIRONMENT
31
43
  }.freeze
32
44
 
33
45
  # 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_"
46
+ CONTEXT_KEYS = SPAN_KEY_MAP.keys.to_h do |key|
47
+ [key, OpenTelemetry::Context.create_key("#{BAGGAGE_PREFIX}#{key}")]
48
+ end.freeze
47
49
 
48
50
  # Propagate trace-level attributes to all spans created within this context.
49
51
  #
@@ -57,6 +59,9 @@ module Langfuse
57
59
  # @param metadata [Hash<String, String>, nil] Additional metadata (all values ≤200 characters)
58
60
  # @param version [String, nil] Version identifier (≤200 characters)
59
61
  # @param tags [Array<String>, nil] List of tags (each ≤200 characters)
62
+ # @param trace_name [String, nil] Trace name (≤200 characters)
63
+ # @param release [String, nil] Release identifier (≤200 characters)
64
+ # @param environment [String, nil] Lowercase environment identifier (≤40 characters)
60
65
  # @param as_baggage [Boolean] If true, propagates via OpenTelemetry baggage for cross-service propagation
61
66
  # @yield Block within which attributes are propagated
62
67
  # @return [Object] The result of the block
@@ -76,32 +81,29 @@ module Langfuse
76
81
  # # All spans inherit these attributes
77
82
  # end
78
83
  #
84
+ # rubocop:disable Metrics/ParameterLists
79
85
  def self.propagate_attributes(user_id: nil, session_id: nil, metadata: nil, version: nil, tags: nil,
80
- as_baggage: false, &block)
86
+ trace_name: nil, release: nil, environment: nil, as_baggage: false, &block)
81
87
  raise ArgumentError, "Block required" unless block
82
88
 
83
- _propagate_attributes(
84
- user_id: user_id,
85
- session_id: session_id,
86
- metadata: metadata,
87
- version: version,
88
- tags: tags,
89
- as_baggage: as_baggage,
90
- &block
91
- )
89
+ attributes = {
90
+ "user_id" => user_id, "session_id" => session_id, "metadata" => metadata,
91
+ "version" => version, "tags" => tags, "trace_name" => trace_name,
92
+ "release" => release, "environment" => environment
93
+ }
94
+ _propagate_attributes(attributes, as_baggage: as_baggage, &block)
92
95
  end
93
96
 
94
97
  # Internal implementation of propagate_attributes
95
98
  #
99
+ # @param attributes [Hash<String, Object>] Propagated attribute values
100
+ # @param as_baggage [Boolean] Whether to add the values to OpenTelemetry baggage
96
101
  # @api private
97
- def self._propagate_attributes(user_id: nil, session_id: nil, metadata: nil, version: nil, tags: nil,
98
- as_baggage: false, &)
102
+ def self._propagate_attributes(attributes, as_baggage:, &)
99
103
  current_context = OpenTelemetry::Context.current
100
104
  current_span = OpenTelemetry::Trace.current_span
101
105
 
102
- # Process each propagated attribute using PROPAGATED_ATTRIBUTES constant
103
- PROPAGATED_ATTRIBUTES.each do |key|
104
- value = binding.local_variable_get(key.to_sym)
106
+ attributes.each do |key, value|
105
107
  next if value.nil?
106
108
  next if key == "tags" && value.empty?
107
109
 
@@ -120,6 +122,7 @@ module Langfuse
120
122
  # Execute block in new context
121
123
  OpenTelemetry::Context.with_current(current_context, &)
122
124
  end
125
+ # rubocop:enable Metrics/ParameterLists
123
126
 
124
127
  # Validate an attribute value based on its type
125
128
  #
@@ -140,6 +143,8 @@ module Langfuse
140
143
  validated_metadata[k.to_s] = v.to_s if _validate_string_value(v, "metadata.#{k}")
141
144
  end
142
145
  validated_metadata.any? ? validated_metadata : nil
146
+ when "environment"
147
+ _validate_environment_value(value)
143
148
  else
144
149
  _validate_propagated_value(value, key)
145
150
  end
@@ -157,7 +162,7 @@ module Langfuse
157
162
  propagated_attributes = _extract_baggage_attributes(context)
158
163
 
159
164
  # Handle OTEL context values
160
- PROPAGATED_ATTRIBUTES.each do |key|
165
+ SPAN_KEY_MAP.each_key do |key|
161
166
  context_key = _get_propagated_context_key(key)
162
167
  value = context.value(context_key)
163
168
 
@@ -165,7 +170,10 @@ module Langfuse
165
170
 
166
171
  span_key = _get_propagated_span_key(key)
167
172
 
168
- if key == "metadata" && value.is_a?(Hash)
173
+ if key == "environment"
174
+ validated_environment = _validate_environment_value(value)
175
+ propagated_attributes[span_key] = validated_environment if validated_environment
176
+ elsif key == "metadata" && value.is_a?(Hash)
169
177
  value.each do |k, v|
170
178
  metadata_key = "#{OtelAttributes::TRACE_METADATA}.#{k}"
171
179
  propagated_attributes[metadata_key] = v.to_s
@@ -318,6 +326,29 @@ module Langfuse
318
326
  end
319
327
  # rubocop:enable Naming/PredicateMethod
320
328
 
329
+ # Validate a propagated environment value against the cross-SDK contract.
330
+ #
331
+ # @param value [Object] Environment value to validate
332
+ # @return [String, nil] Validated environment or nil
333
+ # @api private
334
+ def self._validate_environment_value(value)
335
+ return _drop_environment("value is not a string") unless value.is_a?(String)
336
+ return _drop_environment("value is over 40 characters (#{value.length} chars)") if value.length > 40
337
+
338
+ return value if ENVIRONMENT_VALUE_PATTERN.match?(value)
339
+
340
+ _drop_environment(
341
+ "must use lowercase letters, numbers, hyphens, or underscores and must not start with 'langfuse'"
342
+ )
343
+ end
344
+
345
+ def self._drop_environment(reason)
346
+ Langfuse.configuration.logger.warn(
347
+ "Langfuse: Propagated attribute 'environment' #{reason}. Dropping value."
348
+ )
349
+ nil
350
+ end
351
+
321
352
  # Get context key for a propagated attribute
322
353
  #
323
354
  # @param key [String] Attribute key (user_id, session_id, etc.)
@@ -378,6 +409,40 @@ module Langfuse
378
409
  defined?(OpenTelemetry::Baggage)
379
410
  end
380
411
 
412
+ # Get the Langfuse trace claim from OpenTelemetry baggage.
413
+ #
414
+ # @param context [OpenTelemetry::Context] Context to inspect
415
+ # @return [String, nil] Lowercase trace ID or nil
416
+ # @api private
417
+ def self._get_langfuse_trace_id_from_baggage(context)
418
+ return nil unless baggage_available?
419
+
420
+ OpenTelemetry::Baggage.values(context: context)[LANGFUSE_TRACE_ID_BAGGAGE_KEY]&.to_s&.downcase
421
+ rescue StandardError => e
422
+ Langfuse.configuration.logger.debug("Langfuse: Trace baggage read failed: #{e.message}")
423
+ nil
424
+ end
425
+
426
+ # Set the Langfuse trace claim in OpenTelemetry baggage.
427
+ #
428
+ # @param trace_id [String] Lowercase hexadecimal trace ID
429
+ # @param context [OpenTelemetry::Context] Context to extend
430
+ # @return [OpenTelemetry::Context] Context with the trace claim
431
+ # @api private
432
+ def self._set_langfuse_trace_id_in_baggage(trace_id, context:)
433
+ return context unless baggage_available?
434
+
435
+ normalized_trace_id = trace_id.downcase
436
+ return context if _get_langfuse_trace_id_from_baggage(context) == normalized_trace_id
437
+
438
+ OpenTelemetry::Baggage.set_value(
439
+ LANGFUSE_TRACE_ID_BAGGAGE_KEY, normalized_trace_id, context: context
440
+ )
441
+ rescue StandardError => e
442
+ Langfuse.configuration.logger.debug("Langfuse: Trace baggage write failed: #{e.message}")
443
+ context
444
+ end
445
+
381
446
  # Extract propagated attributes from baggage
382
447
  #
383
448
  # @param context [OpenTelemetry::Context] The context to read baggage from
@@ -399,7 +464,7 @@ module Langfuse
399
464
 
400
465
  attributes[span_key] = _parse_baggage_value(span_key, baggage_value)
401
466
  end
402
- attributes
467
+ attributes.compact
403
468
  rescue StandardError => e
404
469
  Langfuse.configuration.logger.debug("Langfuse: Baggage extraction failed: #{e.message}")
405
470
  {}
@@ -413,7 +478,9 @@ module Langfuse
413
478
  #
414
479
  # @api private
415
480
  def self._parse_baggage_value(span_key, baggage_value)
416
- if span_key == OtelAttributes::TRACE_TAGS && baggage_value.is_a?(String)
481
+ if span_key == OtelAttributes::ENVIRONMENT
482
+ _validate_environment_value(baggage_value)
483
+ elsif span_key == OtelAttributes::TRACE_TAGS && baggage_value.is_a?(String)
417
484
  baggage_value.split(",")
418
485
  else
419
486
  baggage_value.to_s