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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +44 -1
- data/lib/langfuse/api_client.rb +175 -509
- data/lib/langfuse/app_root_tracking.rb +164 -0
- data/lib/langfuse/cache_warmer.rb +21 -13
- data/lib/langfuse/chat_prompt_client.rb +21 -4
- data/lib/langfuse/client.rb +174 -229
- data/lib/langfuse/config.rb +308 -65
- data/lib/langfuse/evaluation.rb +8 -4
- data/lib/langfuse/exit_hook.rb +77 -0
- data/lib/langfuse/fork_safety.rb +71 -0
- data/lib/langfuse/masking_exporter.rb +98 -0
- data/lib/langfuse/observations.rb +2 -1
- data/lib/langfuse/otel_attributes.rb +1 -0
- data/lib/langfuse/otel_setup.rb +32 -44
- data/lib/langfuse/otel_span_batch.rb +113 -0
- data/lib/langfuse/otel_span_masking.rb +89 -0
- data/lib/langfuse/otel_span_patch_applier.rb +97 -0
- data/lib/langfuse/pending_score_queue.rb +62 -0
- data/lib/langfuse/prompt_cache.rb +11 -0
- data/lib/langfuse/prompt_cache_coordinator.rb +288 -0
- data/lib/langfuse/prompt_cache_events.rb +31 -10
- data/lib/langfuse/prompt_variables.rb +54 -0
- data/lib/langfuse/propagation.rb +101 -34
- data/lib/langfuse/rails_cache_adapter.rb +27 -2
- data/lib/langfuse/read_api.rb +242 -0
- data/lib/langfuse/resilient_metrics_reporter.rb +60 -0
- data/lib/langfuse/score_client.rb +209 -89
- data/lib/langfuse/score_value.rb +58 -0
- data/lib/langfuse/span_processor.rb +53 -4
- data/lib/langfuse/stale_while_revalidate.rb +3 -4
- data/lib/langfuse/text_prompt_client.rb +15 -6
- data/lib/langfuse/trace_export_guard.rb +47 -0
- data/lib/langfuse/traced_execution.rb +18 -12
- data/lib/langfuse/types.rb +15 -1
- data/lib/langfuse/version.rb +1 -1
- data/lib/langfuse.rb +157 -44
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/langfuse/propagation.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
84
|
-
user_id
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 == "
|
|
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::
|
|
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
|