langfuse-rb 0.12.0 → 0.12.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 99afbe7d86b8a94bdb08e95477b88bd6ced6a5ca01cbf794eec86d23cf716f0e
4
- data.tar.gz: 2098919014cab53a2a2f7b4e22b06c4a87f306cde866ef87e45033e558caf472
3
+ metadata.gz: 20d0cd9db76be223db4867cbc9f54f59ee5accc0058e29bab14561c457d8fecc
4
+ data.tar.gz: b43bd144dca940b375ac916feca399f9e10da54db201fff9f4d140acdd301ec5
5
5
  SHA512:
6
- metadata.gz: 8a5ee8263a21a8bfff3ec9ce2603a85ba2f26522aa757ddbe16e62cb96e46830fdc9c4958ebf28ca228b84a2b46bbe104ad29d2a7761d6bbdda5c67c67145bf3
7
- data.tar.gz: f9682ef80e7b52800f8d566c8d5a4132012441a653f4bed74ace178997a9fed5df904119e8b15c5c74aa6c61ca187af87e26bad91c878d86b0afc1579da8606c
6
+ metadata.gz: 66175d8c844c314ac8f62180e98d3bc95f8fb3934ea71880d7bd7e21fc5bbbd06f472a5b992cb4529929aab27cc153a7641f8f2a0f73ffb2ed869df127056980
7
+ data.tar.gz: 0e7a1e8e7a34aeaa964d8b1c0295c6448a3fa58e075c1bdda6e6aafe98b00ee9d48a7e904c3724b1b2ab42d166119bf80b6aa7be21eb750b072768d6588363d3
data/CHANGELOG.md CHANGED
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.12.1] - 2026-09-03
11
+
12
+ ### Fixed
13
+ - Emit Langfuse v4 experiment attributes for local and dataset-backed experiment observations (#118).
14
+
10
15
  ## [0.12.0] - 2026-08-20
11
16
 
12
17
  ### Changed
@@ -173,7 +178,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
173
178
  - Migrated from legacy ingestion API to OTLP endpoint
174
179
  - Removed `tracing_enabled` configuration flag (#2)
175
180
 
176
- [Unreleased]: https://github.com/simplepractice/langfuse-rb/compare/v0.12.0...HEAD
181
+ [Unreleased]: https://github.com/simplepractice/langfuse-rb/compare/v0.12.1...HEAD
182
+ [0.12.1]: https://github.com/simplepractice/langfuse-rb/compare/v0.12.0...v0.12.1
177
183
  [0.12.0]: https://github.com/simplepractice/langfuse-rb/compare/v0.11.0...v0.12.0
178
184
  [0.11.0]: https://github.com/simplepractice/langfuse-rb/compare/v0.10.1...v0.11.0
179
185
  [0.10.1]: https://github.com/simplepractice/langfuse-rb/compare/v0.10.0...v0.10.1
@@ -103,6 +103,7 @@ module Langfuse
103
103
  #
104
104
  # Executes the block inside an observed span, flushes the trace, then
105
105
  # creates a dataset run item linking this item to the resulting trace.
106
+ # Use {Client#run_experiment} when Langfuse v4 experiment attribution is required.
106
107
  #
107
108
  # @param run_name [String] run name for grouping
108
109
  # @param run_description [String, nil] optional run description
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "securerandom"
5
+
6
+ module Langfuse
7
+ # Builds the OpenTelemetry attributes that identify v4 experiment observations.
8
+ #
9
+ # @api private
10
+ module ExperimentAttributes
11
+ ENVIRONMENT = "sdk-experiment"
12
+
13
+ def self.generate_experiment_id
14
+ SecureRandom.hex(8)
15
+ end
16
+
17
+ def self.generate_item_id(input)
18
+ serialized_input = OtelAttributes.serialize(input, preserve_strings: true) || input.to_s
19
+ Digest::SHA256.hexdigest(serialized_input)[0, 16]
20
+ end
21
+
22
+ # rubocop:disable Metrics/ParameterLists
23
+ def self.propagated(experiment_id:, run_name:, item_id:, root_observation_id:,
24
+ dataset_id: nil, experiment_metadata: nil, item_metadata: nil, mask: nil)
25
+ attributes = {
26
+ OtelAttributes::EXPERIMENT_ID => experiment_id,
27
+ OtelAttributes::EXPERIMENT_NAME => run_name,
28
+ OtelAttributes::EXPERIMENT_DATASET_ID => dataset_id,
29
+ OtelAttributes::EXPERIMENT_ITEM_ID => item_id,
30
+ OtelAttributes::EXPERIMENT_ITEM_ROOT_OBSERVATION_ID => root_observation_id,
31
+ OtelAttributes::ENVIRONMENT => ENVIRONMENT
32
+ }.compact
33
+ attributes.merge!(masked_metadata(experiment_metadata, OtelAttributes::EXPERIMENT_METADATA, mask))
34
+ attributes.merge!(masked_metadata(item_metadata, OtelAttributes::EXPERIMENT_ITEM_METADATA, mask))
35
+ end
36
+ # rubocop:enable Metrics/ParameterLists
37
+
38
+ def self.root(description:, expected_output:, mask: nil)
39
+ masked_output = Masking.apply(expected_output, mask: mask)
40
+ {
41
+ OtelAttributes::EXPERIMENT_DESCRIPTION => description,
42
+ OtelAttributes::EXPERIMENT_ITEM_EXPECTED_OUTPUT =>
43
+ OtelAttributes.serialize(masked_output, preserve_strings: true)
44
+ }.compact
45
+ end
46
+
47
+ def self.observation_metadata(name:, run_name:, experiment_metadata:, item_metadata:,
48
+ dataset_id: nil, dataset_item_id: nil)
49
+ metadata = hash_metadata(item_metadata).merge(hash_metadata(experiment_metadata))
50
+ metadata[:experiment_name] = name
51
+ metadata[:experiment_run_name] = run_name
52
+ metadata[:dataset_id] = dataset_id if dataset_id
53
+ metadata[:dataset_item_id] = dataset_item_id if dataset_item_id
54
+ metadata
55
+ end
56
+
57
+ def self.masked_metadata(metadata, prefix, mask)
58
+ OtelAttributes.flatten_metadata(Masking.apply(metadata, mask: mask), prefix)
59
+ end
60
+ private_class_method :masked_metadata
61
+
62
+ def self.hash_metadata(metadata)
63
+ metadata.is_a?(Hash) ? metadata : {}
64
+ end
65
+ private_class_method :hash_metadata
66
+ end
67
+ end
@@ -18,26 +18,29 @@ module Langfuse
18
18
  # @return [String, nil] run description
19
19
  # @return [Array<ItemResult>] per-item results (all items, including failures)
20
20
  # @return [Array<Evaluation>] run-level evaluation results
21
+ # @return [String, nil] resolved experiment ID shared by the run
21
22
  # @return [String, nil] dataset run ID from the server
22
23
  # @return [String, nil] URL to the dataset run in Langfuse UI
23
24
  attr_reader :name, :run_name, :description, :item_results, :run_evaluations,
24
- :dataset_run_id, :dataset_run_url
25
+ :experiment_id, :dataset_run_id, :dataset_run_url
25
26
 
26
27
  # @param name [String] experiment/run name
27
28
  # @param item_results [Array<ItemResult>] per-item results
28
29
  # @param run_evaluations [Array<Evaluation>] run-level evaluations
29
30
  # @param run_name [String, nil] auto-generated run name
30
31
  # @param description [String, nil] run description
32
+ # @param experiment_id [String, nil] resolved experiment ID shared by the run
31
33
  # @param dataset_run_id [String, nil] dataset run ID from the server
32
34
  # @param dataset_run_url [String, nil] URL to the dataset run in Langfuse UI
33
35
  # rubocop:disable Metrics/ParameterLists
34
36
  def initialize(name:, item_results:, run_evaluations: [], run_name: nil, description: nil,
35
- dataset_run_id: nil, dataset_run_url: nil)
37
+ experiment_id: nil, dataset_run_id: nil, dataset_run_url: nil)
36
38
  @name = name
37
39
  @item_results = item_results
38
40
  @run_evaluations = run_evaluations
39
41
  @run_name = run_name
40
42
  @description = description
43
+ @experiment_id = experiment_id
41
44
  @dataset_run_id = dataset_run_id
42
45
  @dataset_run_url = dataset_run_url
43
46
  end
@@ -33,6 +33,8 @@ module Langfuse
33
33
  @description = description
34
34
  @run_name = run_name || "#{name} - #{Time.now.utc.iso8601}"
35
35
  @logger = Langfuse.configuration.logger
36
+ @fallback_experiment_id = ExperimentAttributes.generate_experiment_id
37
+ @resolved_experiment_id = nil
36
38
  @dataset_run_id = nil
37
39
  @dataset_id = nil
38
40
  end
@@ -53,6 +55,7 @@ module Langfuse
53
55
  run_evaluations: run_evals,
54
56
  run_name: @run_name,
55
57
  description: @description,
58
+ experiment_id: resolved_experiment_id,
56
59
  dataset_run_id: @dataset_run_id,
57
60
  dataset_run_url: build_dataset_run_url
58
61
  )
@@ -68,7 +71,7 @@ module Langfuse
68
71
  return ItemResult.new(item: item, trace_id: trace_id, observation_id: observation_id, error: task_error)
69
72
  end
70
73
 
71
- evaluations = execute_evaluators(item, output, trace_id)
74
+ evaluations = execute_evaluators(item, output, trace_id, observation_id)
72
75
  ItemResult.new(item: item, output: output, trace_id: trace_id,
73
76
  observation_id: observation_id, evaluations: evaluations)
74
77
  end
@@ -77,21 +80,19 @@ module Langfuse
77
80
  TracedExecution.call(
78
81
  trace_name: "experiment-#{@name}",
79
82
  input: item.input,
80
- metadata: @metadata,
81
- task: ->(_span) { @task.call(item) }
82
- ) do |span, trace_id|
83
- # Link before running task — server accepts forward-referenced trace IDs
84
- link_to_dataset_run(item, trace_id, span.id) if item.is_a?(DatasetItemClient)
85
- end
83
+ metadata: observation_metadata(item),
84
+ task: ->(_span) { @task.call(item) },
85
+ prepare_context: ->(span, trace_id) { prepare_experiment_context(item, span, trace_id) }
86
+ )
86
87
  end
87
88
 
88
- def execute_evaluators(item, output, trace_id)
89
+ def execute_evaluators(item, output, trace_id, observation_id)
89
90
  evaluations = @evaluators.flat_map do |evaluator|
90
91
  raw_result = call_evaluator(evaluator, item, output)
91
92
  normalize_evaluator_result(raw_result, source: "Evaluator")
92
93
  end
93
94
 
94
- evaluations.each { |evaluation| persist_score(evaluation, trace_id) }
95
+ evaluations.each { |evaluation| persist_score(evaluation, trace_id, observation_id) }
95
96
  evaluations
96
97
  end
97
98
 
@@ -104,10 +105,11 @@ module Langfuse
104
105
  nil
105
106
  end
106
107
 
107
- def persist_score(evaluation, trace_id)
108
+ def persist_score(evaluation, trace_id, observation_id)
108
109
  @client.create_score(
109
110
  name: evaluation.name, value: evaluation.value,
110
- trace_id: trace_id, comment: evaluation.comment, data_type: evaluation.data_type,
111
+ trace_id: trace_id, observation_id: observation_id,
112
+ comment: evaluation.comment, data_type: evaluation.data_type,
111
113
  config_id: evaluation.config_id, metadata: evaluation.metadata
112
114
  )
113
115
  rescue StandardError => e
@@ -145,13 +147,74 @@ module Langfuse
145
147
  trace_id: trace_id, observation_id: observation_id,
146
148
  metadata: @metadata, run_description: @description
147
149
  )
148
- unless @dataset_run_id
149
- @dataset_run_id = response&.dig("datasetRunId")
150
- @dataset_id = item.dataset_id if @dataset_run_id
151
- end
150
+ capture_dataset_run_identity(item, response)
152
151
  response
153
152
  rescue StandardError => e
154
153
  @logger.warn("Dataset run item linking failed: #{e.message}")
154
+ nil
155
+ end
156
+
157
+ # Do not expose a server run ID that differs from the identity already attached to observations.
158
+ def capture_dataset_run_identity(item, response)
159
+ candidate_id = response&.dig("datasetRunId")
160
+ return unless candidate_id
161
+ return if @resolved_experiment_id && @resolved_experiment_id != candidate_id
162
+
163
+ @dataset_run_id = candidate_id if @dataset_run_id.nil?
164
+ @dataset_id = item.dataset_id if @dataset_id.nil?
165
+ end
166
+
167
+ def prepare_experiment_context(item, span, trace_id)
168
+ response = link_to_dataset_run(item, trace_id, span.id) if item.is_a?(DatasetItemClient)
169
+ root_experiment_attributes(item).each do |key, value|
170
+ span.otel_span.set_attribute(key, value)
171
+ end
172
+ propagated_experiment_attributes(item, span.id, response)
173
+ end
174
+
175
+ def root_experiment_attributes(item)
176
+ ExperimentAttributes.root(
177
+ description: @description,
178
+ expected_output: item.expected_output,
179
+ mask: Langfuse.configuration.mask
180
+ )
181
+ end
182
+
183
+ def propagated_experiment_attributes(item, observation_id, response)
184
+ ExperimentAttributes.propagated(
185
+ experiment_id: resolved_experiment_id(response),
186
+ run_name: @run_name,
187
+ dataset_id: item_dataset_id(item),
188
+ item_id: item_id(item),
189
+ root_observation_id: observation_id,
190
+ experiment_metadata: @metadata,
191
+ item_metadata: item_metadata(item),
192
+ mask: Langfuse.configuration.mask
193
+ )
194
+ end
195
+
196
+ # A run keeps its first resolved identity even if later dataset links have a different result.
197
+ def resolved_experiment_id(response = nil)
198
+ @resolved_experiment_id ||= response&.dig("datasetRunId") || @fallback_experiment_id
199
+ end
200
+
201
+ def observation_metadata(item)
202
+ ExperimentAttributes.observation_metadata(
203
+ name: @name,
204
+ run_name: @run_name,
205
+ experiment_metadata: @metadata,
206
+ item_metadata: item_metadata(item),
207
+ dataset_id: item_dataset_id(item),
208
+ dataset_item_id: item.is_a?(DatasetItemClient) ? item.id : nil
209
+ )
210
+ end
211
+
212
+ def item_id(item)
213
+ item.is_a?(DatasetItemClient) ? item.id : ExperimentAttributes.generate_item_id(item.input)
214
+ end
215
+
216
+ def item_dataset_id(item)
217
+ item.dataset_id if item.is_a?(DatasetItemClient)
155
218
  end
156
219
 
157
220
  def flush_all
@@ -55,6 +55,17 @@ module Langfuse
55
55
  OBSERVATION_COMPLETION_START_TIME = "langfuse.observation.completion_start_time"
56
56
  IS_APP_ROOT = "langfuse.internal.is_app_root"
57
57
 
58
+ # Experiment attributes
59
+ EXPERIMENT_ID = "langfuse.experiment.id"
60
+ EXPERIMENT_NAME = "langfuse.experiment.name"
61
+ EXPERIMENT_DESCRIPTION = "langfuse.experiment.description"
62
+ EXPERIMENT_METADATA = "langfuse.experiment.metadata"
63
+ EXPERIMENT_DATASET_ID = "langfuse.experiment.dataset.id"
64
+ EXPERIMENT_ITEM_ID = "langfuse.experiment.item.id"
65
+ EXPERIMENT_ITEM_EXPECTED_OUTPUT = "langfuse.experiment.item.expected_output"
66
+ EXPERIMENT_ITEM_METADATA = "langfuse.experiment.item.metadata"
67
+ EXPERIMENT_ITEM_ROOT_OBSERVATION_ID = "langfuse.experiment.item.root_observation_id"
68
+
58
69
  # Common attributes
59
70
  VERSION = "langfuse.version"
60
71
  RELEASE = "langfuse.release"
@@ -30,6 +30,11 @@ module Langfuse
30
30
  ENVIRONMENT_VALUE_PATTERN = /\A(?!langfuse)[a-z0-9_-]+\z/
31
31
  private_constant :ENVIRONMENT_VALUE_PATTERN
32
32
 
33
+ EXPERIMENT_ATTRIBUTES_CONTEXT_KEY = OpenTelemetry::Context.create_key(
34
+ "#{BAGGAGE_PREFIX}experiment_attributes"
35
+ )
36
+ private_constant :EXPERIMENT_ATTRIBUTES_CONTEXT_KEY
37
+
33
38
  # Map of propagated attribute keys to span attribute keys
34
39
  SPAN_KEY_MAP = {
35
40
  "user_id" => OtelAttributes::TRACE_USER_ID,
@@ -186,7 +191,25 @@ module Langfuse
186
191
  end
187
192
  # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
188
193
 
189
- propagated_attributes
194
+ experiment_attributes = context.value(EXPERIMENT_ATTRIBUTES_CONTEXT_KEY) || {}
195
+ propagated_attributes.merge(experiment_attributes)
196
+ end
197
+
198
+ # Apply SDK-owned experiment attributes to the current span and future children.
199
+ #
200
+ # @param attributes [Hash<String, Object>] serialized OpenTelemetry experiment attributes
201
+ # @yield Block within which experiment attributes propagate
202
+ # @return [Object] the block result
203
+ # @api private
204
+ def self._with_experiment_attributes(attributes, &)
205
+ return yield if attributes.nil? || attributes.empty?
206
+
207
+ frozen_attributes = attributes.dup.freeze
208
+ current_span = OpenTelemetry::Trace.current_span
209
+ frozen_attributes.each { |key, value| current_span.set_attribute(key, value) } if current_span.recording?
210
+
211
+ context = OpenTelemetry::Context.current.set_value(EXPERIMENT_ATTRIBUTES_CONTEXT_KEY, frozen_attributes)
212
+ OpenTelemetry::Context.with_current(context, &)
190
213
  end
191
214
 
192
215
  # Merge metadata with existing context value
@@ -15,9 +15,9 @@ module Langfuse
15
15
  # @param input [Object] input set on the root observation
16
16
  # @param metadata [Hash] metadata set on the root observation and trace
17
17
  # @param task [Proc] the callable to execute — receives the span
18
- # @yield [span, trace_id] optional pre-task hook (e.g., dataset run linking)
18
+ # @param prepare_context [#call, nil] optional callable returning SDK-owned propagated attributes
19
19
  # @return [Array<(Object, String, String, StandardError | nil)>] output, trace_id, observation_id, error
20
- def self.call(trace_name:, input:, task:, metadata: {})
20
+ def self.call(trace_name:, input:, task:, metadata: {}, prepare_context: nil)
21
21
  trace_id = nil
22
22
  observation_id = nil
23
23
  output = nil
@@ -26,9 +26,11 @@ module Langfuse
26
26
  Langfuse.observe(trace_name, input: input, metadata: metadata) do |span|
27
27
  trace_id = span.trace_id
28
28
  observation_id = span.id
29
- Langfuse.propagate_attributes(trace_name: trace_name, metadata: metadata) do
30
- yield(span, trace_id) if block_given?
31
- output, task_error = execute_task(span, task)
29
+ experiment_attributes = prepare_context&.call(span, trace_id) || {}
30
+ Propagation._with_experiment_attributes(experiment_attributes) do
31
+ Langfuse.propagate_attributes(trace_name: trace_name, metadata: metadata) do
32
+ output, task_error = execute_task(span, task)
33
+ end
32
34
  end
33
35
  end
34
36
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Langfuse
4
- VERSION = "0.12.0"
4
+ VERSION = "0.12.1"
5
5
  end
data/lib/langfuse.rb CHANGED
@@ -74,6 +74,7 @@ require_relative "langfuse/otel_setup"
74
74
  require_relative "langfuse/masking"
75
75
  require_relative "langfuse/masking_exporter"
76
76
  require_relative "langfuse/otel_attributes"
77
+ require_relative "langfuse/experiment_attributes"
77
78
  require_relative "langfuse/propagation"
78
79
  require_relative "langfuse/app_root_tracking"
79
80
  require_relative "langfuse/span_processor"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: langfuse-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.12.0
4
+ version: 0.12.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - SimplePractice
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-20 00:00:00.000000000 Z
11
+ date: 2026-09-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -197,6 +197,7 @@ files:
197
197
  - lib/langfuse/dataset_item_client.rb
198
198
  - lib/langfuse/evaluation.rb
199
199
  - lib/langfuse/exit_hook.rb
200
+ - lib/langfuse/experiment_attributes.rb
200
201
  - lib/langfuse/experiment_item.rb
201
202
  - lib/langfuse/experiment_result.rb
202
203
  - lib/langfuse/experiment_runner.rb