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,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Langfuse
4
+ # Owns application-root state for active span trees.
5
+ #
6
+ # @api private
7
+ module AppRootTracking
8
+ APP_ROOT_INELIGIBLE_SPANS = [
9
+ %w[litellm raw_gen_ai_request]
10
+ ].freeze
11
+ private_constant :APP_ROOT_INELIGIBLE_SPANS
12
+
13
+ # Return whether an exported span can be an application root when its parent is untracked.
14
+ #
15
+ # LiteLLM can emit `raw_gen_ai_request` after its exported parent finishes.
16
+ # The late child must not become a second application root.
17
+ #
18
+ # @param span [OpenTelemetry::SDK::Trace::Span] Span to inspect
19
+ # @return [Boolean] Whether the span can be an application root
20
+ # @api private
21
+ def self.eligible_without_tracked_parent?(span)
22
+ return true if span.parent_span_id == OpenTelemetry::Trace::INVALID_SPAN_ID
23
+
24
+ identity = [span.instrumentation_scope&.name, span.name]
25
+ !APP_ROOT_INELIGIBLE_SPANS.include?(identity)
26
+ end
27
+
28
+ # Defers a finished span until its active ancestors have final export decisions.
29
+ #
30
+ # @api private
31
+ class Tracker
32
+ ReadySpan = Struct.new(:span, :app_root, keyword_init: true)
33
+ private_constant :ReadySpan
34
+
35
+ State = Struct.new(
36
+ :span,
37
+ :trace_claimed,
38
+ :untracked_parent_root_eligible,
39
+ :parent_span_id,
40
+ :active_child_count,
41
+ :finished,
42
+ :exportable,
43
+ :enqueued,
44
+ keyword_init: true
45
+ )
46
+ private_constant :State
47
+
48
+ def initialize
49
+ @mutex = Mutex.new
50
+ @state_by_span_id = {}
51
+ end
52
+
53
+ # @param span [OpenTelemetry::SDK::Trace::Span] The active span
54
+ # @param trace_claimed [Boolean] Whether propagated context already owns the root
55
+ # @param untracked_parent_root_eligible [Boolean] Whether an untracked parent permits a root
56
+ # @return [void]
57
+ def remember(span, trace_claimed:, untracked_parent_root_eligible:)
58
+ @mutex.synchronize do
59
+ parent_state = @state_by_span_id[span.parent_span_id]
60
+ parent_state.active_child_count += 1 if parent_state
61
+ @state_by_span_id[span.context.span_id] = build_state(
62
+ span,
63
+ trace_claimed: trace_claimed,
64
+ untracked_parent_root_eligible: untracked_parent_root_eligible
65
+ )
66
+ end
67
+ end
68
+
69
+ # Resolve finished spans whose ancestor export decisions are final.
70
+ #
71
+ # @param span [OpenTelemetry::SDK::Trace::Span] The finished span
72
+ # @param exportable [Boolean] Whether the final export filter accepted the span
73
+ # @return [Array<ReadySpan>] Spans that the batch processor can enqueue
74
+ def finish(span, exportable:)
75
+ @mutex.synchronize do
76
+ state = @state_by_span_id[span.context.span_id]
77
+ return [] unless state
78
+
79
+ state.finished = true
80
+ state.exportable = exportable
81
+ ready_spans = resolve_ready_spans
82
+ release_finished_states
83
+ ready_spans
84
+ end
85
+ end
86
+
87
+ # @return [Boolean] Whether the tracker has no active span trees
88
+ def empty?
89
+ @mutex.synchronize { @state_by_span_id.empty? }
90
+ end
91
+
92
+ private
93
+
94
+ def build_state(span, trace_claimed:, untracked_parent_root_eligible:)
95
+ State.new(
96
+ span: span,
97
+ trace_claimed: trace_claimed,
98
+ untracked_parent_root_eligible: untracked_parent_root_eligible,
99
+ parent_span_id: span.parent_span_id,
100
+ active_child_count: 0,
101
+ finished: false,
102
+ exportable: nil,
103
+ enqueued: false
104
+ )
105
+ end
106
+
107
+ def resolve_ready_spans
108
+ @state_by_span_id.values.filter_map do |state|
109
+ next unless state.finished && state.exportable && !state.enqueued
110
+
111
+ app_root = app_root_status(state)
112
+ next if app_root.nil?
113
+
114
+ state.enqueued = true
115
+ ReadySpan.new(span: state.span, app_root: app_root)
116
+ end
117
+ end
118
+
119
+ def app_root_status(state)
120
+ trace_claimed = state.trace_claimed
121
+ parent_span_id = state.parent_span_id
122
+ parent_state = @state_by_span_id[parent_span_id]
123
+ return false unless parent_state || state.untracked_parent_root_eligible
124
+
125
+ while parent_state
126
+ return nil unless parent_state.finished
127
+ return false if parent_state.exportable
128
+
129
+ trace_claimed = parent_state.trace_claimed
130
+ parent_span_id = parent_state.parent_span_id
131
+ parent_state = @state_by_span_id[parent_span_id]
132
+ end
133
+ !trace_claimed
134
+ end
135
+
136
+ def release_finished_states
137
+ releasable_span_ids = @state_by_span_id.filter_map do |span_id, state|
138
+ span_id if releasable?(state)
139
+ end
140
+ until releasable_span_ids.empty?
141
+ span_id = releasable_span_ids.pop
142
+ state = @state_by_span_id[span_id]
143
+ next unless releasable?(state)
144
+
145
+ parent_span_id = release_state(span_id, state)
146
+ releasable_span_ids << parent_span_id if parent_span_id
147
+ end
148
+ end
149
+
150
+ def releasable?(state)
151
+ state&.finished && state.active_child_count.zero? && (!state.exportable || state.enqueued)
152
+ end
153
+
154
+ def release_state(span_id, state)
155
+ @state_by_span_id.delete(span_id)
156
+ parent_state = @state_by_span_id[state.parent_span_id]
157
+ return unless parent_state
158
+
159
+ parent_state.active_child_count -= 1
160
+ state.parent_span_id
161
+ end
162
+ end
163
+ end
164
+ end
@@ -148,25 +148,22 @@ module Langfuse
148
148
  #
149
149
  # @return [Boolean]
150
150
  def cache_enabled?
151
- cache = client.api_client.cache
152
- return false if cache.nil?
153
-
154
- cache.ttl&.positive? || false
151
+ client.prompt_cache_stats[:enabled] == true
155
152
  end
156
153
 
157
154
  # Get cache statistics (if supported by backend)
158
155
  #
159
156
  # @return [Hash, nil] Cache stats or nil if not supported
160
157
  def cache_stats
161
- cache = client.api_client.cache
162
- return nil unless cache
163
-
164
- stats = {}
165
- stats[:backend] = cache.class.name.split("::").last
166
- stats[:ttl] = cache.ttl if cache.respond_to?(:ttl)
167
- stats[:size] = cache.size if cache.respond_to?(:size)
168
- stats[:max_size] = cache.max_size if cache.respond_to?(:max_size)
169
- stats
158
+ stats = client.prompt_cache_stats
159
+ return nil unless stats[:enabled]
160
+
161
+ {
162
+ backend: public_backend_name(stats[:backend]),
163
+ ttl: stats[:ttl],
164
+ size: stats[:size],
165
+ max_size: stats[:max_size]
166
+ }
170
167
  end
171
168
 
172
169
  private
@@ -213,6 +210,17 @@ module Langfuse
213
210
  def record_failure(results, name, error)
214
211
  results[:failed] << { name: name, error: error }
215
212
  end
213
+
214
+ def public_backend_name(backend)
215
+ case backend
216
+ when CacheBackend::MEMORY
217
+ "PromptCache"
218
+ when CacheBackend::RAILS
219
+ "RailsCacheAdapter"
220
+ else
221
+ backend.to_s
222
+ end
223
+ end
216
224
  end
217
225
 
218
226
  # Error raised when cache warming fails with warm!
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "prompt_renderer"
4
+ require_relative "prompt_variables"
4
5
 
5
6
  module Langfuse
6
7
  # Chat prompt client for compiling chat prompts with variable substitution
@@ -11,7 +12,7 @@ module Langfuse
11
12
  # @example Basic usage
12
13
  # prompt_data = api_client.get_prompt("support_chat")
13
14
  # chat_prompt = Langfuse::ChatPromptClient.new(prompt_data)
14
- # chat_prompt.compile(variables: { user_name: "Alice", issue: "login" })
15
+ # chat_prompt.compile(user_name: "Alice", issue: "login")
15
16
  # # => [{ role: "system", content: "You are a support agent..." }, ...]
16
17
  #
17
18
  # @example Accessing metadata
@@ -28,6 +29,9 @@ module Langfuse
28
29
  # @return [Integer] Prompt version number
29
30
  attr_reader :version
30
31
 
32
+ # @return [Array<Hash>] Raw prompt template (array of role/content message hashes)
33
+ attr_reader :prompt
34
+
31
35
  # @return [Array<String>] Labels assigned to this prompt
32
36
  attr_reader :labels
33
37
 
@@ -37,9 +41,6 @@ module Langfuse
37
41
  # @return [Hash] Prompt configuration
38
42
  attr_reader :config
39
43
 
40
- # @return [Array<Hash>] Array of message hashes and placeholder entries
41
- attr_reader :prompt
42
-
43
44
  # @return [String, nil] Optional commit message for this prompt version
44
45
  attr_reader :commit_message
45
46
 
@@ -73,6 +74,22 @@ module Langfuse
73
74
  "chat"
74
75
  end
75
76
 
77
+ # Return the unique variables referenced by all message templates
78
+ #
79
+ # Section names are included because callers must provide their values.
80
+ # Message placeholder entries are not Mustache templates and are excluded.
81
+ #
82
+ # @return [Array<String>] Referenced variable names in message and source order
83
+ # @raise [Mustache::Parser::SyntaxError] if a message contains invalid Mustache syntax
84
+ def variables
85
+ prompt.each_with_object([]) do |message, names|
86
+ normalized = symbolize_keys(message)
87
+ next if normalized[:type].to_s == PLACEHOLDER_TYPE
88
+
89
+ names.concat(PromptVariables.extract(normalized[:content] || ""))
90
+ end.uniq
91
+ end
92
+
76
93
  # Compile the chat prompt with variable substitution and message placeholders
77
94
  #
78
95
  # Returns an array of message hashes with roles and compiled content.