langfuse-rb 0.5.0 → 0.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3b2a00b43e48c855cee6d5ccf6b12c36641c469182ddb65231323dcc566d01f2
4
- data.tar.gz: 21b9bc01d7297bf7130b4530f25e4fffc8a0ac18036a7e6e97562767b2a4657a
3
+ metadata.gz: e320f34169f77bea79aa259867b62aa7164d77a693e864cb1d03cb86d0d188a2
4
+ data.tar.gz: 59a40715dbb46604f6287b46b80c34e1524f7279fed261b7795d781854df987f
5
5
  SHA512:
6
- metadata.gz: b121586667150434548323b0734f456f51fb06c21513bb59aaf7d8bbd07b8077ef23bbf2b87f2cac1514f5b220f54cff19175d97c11d19472306367a888aa97f
7
- data.tar.gz: 9b5790ee2504681417cb7fa77fc2ebcb3c4e484bcfd512ba04abd6fbad215b609235d4bf109b0481c7855822d0643203873d49754b485aba487f6e85fb84260e
6
+ metadata.gz: d18c3cb807c759c20f72a4259ccfb0502f1800b671aadcf14ea24f88e0be227ed200677f1cbabef22e467c06f2f1ce5f1cf0a0d1a379e14fe29327fce5597ec0
7
+ data.tar.gz: ff132aea8911b044a43de1a9abe1e54f3a680069b3b480f2e8109953600600b80abe4b923a13d3672f22ff1fe5c4ab3aef93246f2a9946d1c4f593495228ac0f
data/CHANGELOG.md CHANGED
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.0] - 2026-03-06
11
+
12
+ ### Added
13
+ - Tracing payload masking via `Config#mask` (#68)
14
+ - Cost details and usage details support on generations (#61)
15
+ - Client-level `environment` and `release` configuration (#52)
16
+ - Configurable parameters when creating scores (#48)
17
+
18
+ ### Fixed
19
+ - Prompt cache key defaults unlabeled/unversioned fetches to production, matching JS/Python semantics (#63)
20
+ - Tags sent as native arrays instead of JSON strings on OTel span attributes (#66)
21
+ - Enforce 200-character tag length limit on traces (#67)
22
+ - Score API parity between `Langfuse.create_score` and `Client#create_score` (#49)
23
+ - Corrected misleading YARD docstrings for SWR cache config
24
+
10
25
  ## [0.5.0] - 2026-02-09
11
26
 
12
27
  ### Added
@@ -62,7 +77,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
62
77
  - Migrated from legacy ingestion API to OTLP endpoint
63
78
  - Removed `tracing_enabled` configuration flag (#2)
64
79
 
65
- [Unreleased]: https://github.com/simplepractice/langfuse-rb/compare/v0.5.0...HEAD
80
+ [Unreleased]: https://github.com/simplepractice/langfuse-rb/compare/v0.6.0...HEAD
81
+ [0.6.0]: https://github.com/simplepractice/langfuse-rb/compare/v0.5.0...v0.6.0
66
82
  [0.5.0]: https://github.com/simplepractice/langfuse-rb/compare/v0.4.0...v0.5.0
67
83
  [0.4.0]: https://github.com/simplepractice/langfuse-rb/compare/v0.3.0...v0.4.0
68
84
  [0.3.0]: https://github.com/simplepractice/langfuse-rb/compare/v0.2.0...v0.3.0
@@ -285,10 +285,13 @@ module Langfuse
285
285
  #
286
286
  # @param name [String] Score name (required)
287
287
  # @param value [Numeric, Integer, String] Score value (type depends on data_type)
288
+ # @param id [String, nil] Score ID
288
289
  # @param trace_id [String, nil] Trace ID to associate with the score
290
+ # @param session_id [String, nil] Session ID to associate with the score
289
291
  # @param observation_id [String, nil] Observation ID to associate with the score
290
292
  # @param comment [String, nil] Optional comment
291
293
  # @param metadata [Hash, nil] Optional metadata hash
294
+ # @param environment [String, nil] Optional environment
292
295
  # @param data_type [Symbol] Data type (:numeric, :boolean, :categorical)
293
296
  # @param dataset_run_id [String, nil] Optional dataset run ID to associate with the score
294
297
  # @param config_id [String, nil] Optional score config ID
@@ -304,15 +307,18 @@ module Langfuse
304
307
  # @example Categorical score
305
308
  # client.create_score(name: "category", value: "high", trace_id: "abc123", data_type: :categorical)
306
309
  # rubocop:disable Metrics/ParameterLists
307
- def create_score(name:, value:, trace_id: nil, observation_id: nil, comment: nil, metadata: nil,
308
- data_type: :numeric, dataset_run_id: nil, config_id: nil)
310
+ def create_score(name:, value:, id: nil, trace_id: nil, session_id: nil, observation_id: nil, comment: nil,
311
+ metadata: nil, environment: nil, data_type: :numeric, dataset_run_id: nil, config_id: nil)
309
312
  @score_client.create(
310
313
  name: name,
311
314
  value: value,
315
+ id: id,
312
316
  trace_id: trace_id,
317
+ session_id: session_id,
313
318
  observation_id: observation_id,
314
319
  comment: comment,
315
320
  metadata: metadata,
321
+ environment: environment,
316
322
  data_type: data_type,
317
323
  dataset_run_id: dataset_run_id,
318
324
  config_id: config_id
@@ -46,10 +46,10 @@ module Langfuse
46
46
  # @return [Integer] Lock timeout in seconds for distributed cache stampede protection
47
47
  attr_accessor :cache_lock_timeout
48
48
 
49
- # @return [Boolean] Enable stale-while-revalidate caching (when true, sets cache_stale_ttl to cache_ttl if not customized)
49
+ # @return [Boolean] Enable stale-while-revalidate caching (requires cache_stale_ttl > 0 to activate)
50
50
  attr_accessor :cache_stale_while_revalidate
51
51
 
52
- # @return [Integer, Symbol] Stale TTL in seconds (grace period for serving stale data, default: 0 when SWR disabled, cache_ttl when SWR enabled)
52
+ # @return [Integer, Symbol] Stale TTL in seconds (grace period for serving stale data, default: 0)
53
53
  # Accepts :indefinite which is automatically normalized to 1000 years (31,536,000,000 seconds) for practical "never expire" behavior.
54
54
  attr_accessor :cache_stale_ttl
55
55
 
@@ -68,6 +68,16 @@ module Langfuse
68
68
  # @return [Symbol] ActiveJob queue name for async processing
69
69
  attr_accessor :job_queue
70
70
 
71
+ # @return [String, nil] Default tracing environment applied to new traces/observations
72
+ attr_accessor :environment
73
+
74
+ # @return [String, nil] Default release identifier applied to new traces/observations
75
+ attr_accessor :release
76
+
77
+ # @return [#call, nil] Mask callable applied to input, output, and metadata before serialization.
78
+ # Receives `data:` keyword argument. nil disables masking.
79
+ attr_accessor :mask
80
+
71
81
  # @return [String] Default Langfuse API base URL
72
82
  DEFAULT_BASE_URL = "https://cloud.langfuse.com"
73
83
 
@@ -107,11 +117,26 @@ module Langfuse
107
117
  # @return [Integer] Number of seconds representing indefinite cache duration (~1000 years)
108
118
  INDEFINITE_SECONDS = 1000 * 365 * 24 * 60 * 60
109
119
 
120
+ # @return [Array<String>] Common CI environment variables that contain a release SHA
121
+ COMMON_RELEASE_ENV_KEYS = %w[
122
+ RENDER_GIT_COMMIT
123
+ CI_COMMIT_SHA
124
+ CIRCLE_SHA1
125
+ SOURCE_VERSION
126
+ TRAVIS_COMMIT
127
+ GIT_COMMIT
128
+ GITHUB_SHA
129
+ BITBUCKET_COMMIT
130
+ BUILD_SOURCEVERSION
131
+ DRONE_COMMIT_SHA
132
+ ].freeze
133
+
110
134
  # Initialize a new Config object
111
135
  #
112
136
  # @yield [config] Optional block for configuration
113
137
  # @yieldparam config [Config] The config instance
114
138
  # @return [Config] a new Config instance
139
+ # rubocop:disable Metrics/AbcSize
115
140
  def initialize
116
141
  @public_key = ENV.fetch("LANGFUSE_PUBLIC_KEY", nil)
117
142
  @secret_key = ENV.fetch("LANGFUSE_SECRET_KEY", nil)
@@ -128,10 +153,14 @@ module Langfuse
128
153
  @batch_size = DEFAULT_BATCH_SIZE
129
154
  @flush_interval = DEFAULT_FLUSH_INTERVAL
130
155
  @job_queue = DEFAULT_JOB_QUEUE
156
+ @environment = env_value("LANGFUSE_TRACING_ENVIRONMENT")
157
+ @release = env_value("LANGFUSE_RELEASE") || detect_release_from_ci_env
158
+ @mask = nil
131
159
  @logger = default_logger
132
160
 
133
161
  yield(self) if block_given?
134
162
  end
163
+ # rubocop:enable Metrics/AbcSize
135
164
 
136
165
  # Validate the configuration
137
166
  #
@@ -154,6 +183,8 @@ module Langfuse
154
183
  validate_swr_config!
155
184
 
156
185
  validate_cache_backend!
186
+
187
+ validate_mask!
157
188
  end
158
189
  # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
159
190
 
@@ -223,5 +254,27 @@ module Langfuse
223
254
 
224
255
  raise ConfigurationError, "cache_refresh_threads must be positive"
225
256
  end
257
+
258
+ def validate_mask!
259
+ return if mask.nil? || mask.respond_to?(:call)
260
+
261
+ raise ConfigurationError, "mask must respond to #call"
262
+ end
263
+
264
+ def detect_release_from_ci_env
265
+ COMMON_RELEASE_ENV_KEYS.each do |key|
266
+ value = env_value(key)
267
+ return value if value
268
+ end
269
+
270
+ nil
271
+ end
272
+
273
+ def env_value(key)
274
+ value = ENV.fetch(key, nil)
275
+ return nil if value.nil? || value.empty?
276
+
277
+ value
278
+ end
226
279
  end
227
280
  end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Langfuse
4
+ # Central masking chokepoint for tracing payload fields.
5
+ #
6
+ # Applies a user-provided mask callable to input, output, and metadata
7
+ # before serialization. Fail-closed: if the mask raises, the entire
8
+ # field is replaced with {FALLBACK}.
9
+ #
10
+ # @api private
11
+ module Masking
12
+ # Replacement string used when the mask callable raises
13
+ FALLBACK = "<fully masked due to failed mask function>"
14
+
15
+ # Apply the mask callable to a data value.
16
+ #
17
+ # @param data [Object, nil] The value to mask (input, output, or metadata)
18
+ # @param mask [#call, nil] Callable receiving `data:` keyword; nil disables masking
19
+ # @return [Object] Masked data, original data (when mask is nil/data is nil), or {FALLBACK}
20
+ def self.apply(data, mask:)
21
+ return data if mask.nil? || data.nil?
22
+
23
+ mask.call(data: data)
24
+ rescue StandardError => e
25
+ Langfuse.configuration.logger.warn("Langfuse: Mask function failed: #{e.message}")
26
+ FALLBACK
27
+ end
28
+ end
29
+ end
@@ -111,13 +111,14 @@ module Langfuse
111
111
  end
112
112
 
113
113
  # Updates trace-level attributes (user_id, session_id, tags, etc.) for the entire trace.
114
+ # Tags exceeding 200 characters are silently dropped with a warning log.
114
115
  #
115
116
  # @param attrs [Hash, Types::TraceAttributes] Trace attributes to set
116
117
  # @return [self]
117
118
  def update_trace(attrs)
118
119
  return self unless @otel_span.recording?
119
120
 
120
- otel_attrs = OtelAttributes.create_trace_attributes(attrs.to_h)
121
+ otel_attrs = OtelAttributes.create_trace_attributes(attrs.to_h, mask: Langfuse.configuration.mask)
121
122
  otel_attrs.each { |key, value| @otel_span.set_attribute(key, value) }
122
123
  self
123
124
  end
@@ -196,9 +197,10 @@ module Langfuse
196
197
  # @param level [String] Log level (debug, default, warning, error)
197
198
  # @return [void]
198
199
  def event(name:, input: nil, level: "default")
200
+ masked_input = Masking.apply(input, mask: Langfuse.configuration.mask)
199
201
  attributes = {
200
- "langfuse.observation.input" => input&.to_json,
201
- "langfuse.observation.level" => level
202
+ OtelAttributes::OBSERVATION_INPUT => masked_input&.to_json,
203
+ OtelAttributes::OBSERVATION_LEVEL => level
202
204
  }.compact
203
205
 
204
206
  @otel_span.add_event(name, attributes: attributes)
@@ -242,7 +244,7 @@ module Langfuse
242
244
  attrs_hash = attrs.to_h.merge(kwargs)
243
245
 
244
246
  # Use @type instance variable set during initialization
245
- otel_attrs = OtelAttributes.create_observation_attributes(type, attrs_hash)
247
+ otel_attrs = OtelAttributes.create_observation_attributes(type, attrs_hash, mask: Langfuse.configuration.mask)
246
248
  otel_attrs.each { |key, value| @otel_span.set_attribute(key, value) }
247
249
  end
248
250
 
@@ -291,6 +293,35 @@ module Langfuse
291
293
  end
292
294
  end
293
295
 
296
+ # Shared setters for observation types that interact with a model (Generation, Embedding).
297
+ # @api private
298
+ module ModelSetters
299
+ # @param value [Hash] Usage hash with token counts
300
+ # @return [void]
301
+ # @deprecated Use #usage_details= instead.
302
+ def usage=(value)
303
+ self.usage_details = value
304
+ end
305
+
306
+ # @param value [Hash] Usage details hash (preserves key shape as provided)
307
+ # @return [void]
308
+ def usage_details=(value)
309
+ update_observation_attributes(usage_details: value)
310
+ end
311
+
312
+ # @param value [String] Model name (e.g., "gpt-4", "claude-3-opus")
313
+ # @return [void]
314
+ def model=(value)
315
+ update_observation_attributes(model: value)
316
+ end
317
+
318
+ # @param value [Hash] Model parameters (temperature, max_tokens, etc.)
319
+ # @return [void]
320
+ def model_parameters=(value)
321
+ update_observation_attributes(model_parameters: value)
322
+ end
323
+ end
324
+
294
325
  # Observation for LLM calls. Provides methods to set output, usage, and other LLM-specific metadata.
295
326
  #
296
327
  # @example Block-based API
@@ -299,7 +330,7 @@ module Langfuse
299
330
  # gen.input = [{ role: "user", content: "Hello" }]
300
331
  # response = call_llm(gen.input)
301
332
  # gen.output = response
302
- # gen.usage = { prompt_tokens: 100, completion_tokens: 50, total_tokens: 150 }
333
+ # gen.usage_details = { prompt_tokens: 100, completion_tokens: 50, total_tokens: 150 }
303
334
  # end
304
335
  #
305
336
  # @example Stateful API
@@ -315,6 +346,8 @@ module Langfuse
315
346
  # gen.end
316
347
  #
317
348
  class Generation < BaseObservation
349
+ include ModelSetters
350
+
318
351
  # @param otel_span [OpenTelemetry::SDK::Trace::Span] The underlying OTel span
319
352
  # @param otel_tracer [OpenTelemetry::SDK::Trace::Tracer] The OTel tracer
320
353
  # @param attributes [Hash, Types::GenerationAttributes, nil] Optional initial attributes
@@ -329,45 +362,10 @@ module Langfuse
329
362
  self
330
363
  end
331
364
 
332
- # @param value [Hash] Usage hash with token counts (:prompt_tokens, :completion_tokens, :total_tokens)
365
+ # @param value [Hash] Cost details hash (prefer :input, :output, :total for aggregate Langfuse cost metrics)
333
366
  # @return [void]
334
- def usage=(value)
335
- return unless @otel_span.recording?
336
-
337
- # Convert to Langfuse API format (camelCase keys)
338
- usage_hash = {
339
- promptTokens: value[:prompt_tokens] || value["prompt_tokens"],
340
- completionTokens: value[:completion_tokens] || value["completion_tokens"],
341
- totalTokens: value[:total_tokens] || value["total_tokens"]
342
- }.compact
343
-
344
- usage_json = usage_hash.to_json
345
- @otel_span.set_attribute("langfuse.observation.usage", usage_json)
346
- end
347
-
348
- # @param value [String] Model name (e.g., "gpt-4", "claude-3-opus")
349
- # @return [void]
350
- def model=(value)
351
- return unless @otel_span.recording?
352
-
353
- @otel_span.set_attribute("langfuse.observation.model", value.to_s)
354
- end
355
-
356
- # @param value [Hash] Model parameters (temperature, max_tokens, etc.)
357
- # @return [void]
358
- def model_parameters=(value)
359
- return unless @otel_span.recording?
360
-
361
- # Convert to Langfuse API format (camelCase keys)
362
- params_hash = {}
363
- value.each do |k, v|
364
- key_str = k.to_s
365
- # Convert snake_case to camelCase
366
- camel_key = key_str.gsub(/_([a-z])/) { Regexp.last_match(1).upcase }
367
- params_hash[camel_key] = v
368
- end
369
- params_json = params_hash.to_json
370
- @otel_span.set_attribute("langfuse.observation.modelParameters", params_json)
367
+ def cost_details=(value)
368
+ update_observation_attributes(cost_details: value)
371
369
  end
372
370
  end
373
371
 
@@ -630,7 +628,7 @@ module Langfuse
630
628
  # vectors = embedding_service.generate(embedding.input, model: embedding.model)
631
629
  # embedding.update(
632
630
  # output: vectors,
633
- # usage: { prompt_tokens: 20, total_tokens: 20 }
631
+ # usage_details: { prompt_tokens: 20, total_tokens: 20 }
634
632
  # )
635
633
  # end
636
634
  #
@@ -647,6 +645,8 @@ module Langfuse
647
645
  # embedding.end
648
646
  #
649
647
  class Embedding < BaseObservation
648
+ include ModelSetters
649
+
650
650
  # @param otel_span [OpenTelemetry::SDK::Trace::Span] The underlying OTel span
651
651
  # @param otel_tracer [OpenTelemetry::SDK::Trace::Tracer] The OTel tracer
652
652
  # @param attributes [Hash, Types::EmbeddingAttributes, nil] Optional initial attributes
@@ -660,23 +660,5 @@ module Langfuse
660
660
  update_observation_attributes(attrs)
661
661
  self
662
662
  end
663
-
664
- # @param value [Hash] Usage hash with token counts (:prompt_tokens, :total_tokens)
665
- # @return [void]
666
- def usage=(value)
667
- update_observation_attributes(usage_details: value)
668
- end
669
-
670
- # @param value [String] Model name (e.g., "text-embedding-ada-002")
671
- # @return [void]
672
- def model=(value)
673
- update_observation_attributes(model: value)
674
- end
675
-
676
- # @param value [Hash] Model parameters (temperature, max_tokens, etc.)
677
- # @return [void]
678
- def model_parameters=(value)
679
- update_observation_attributes(model_parameters: value)
680
- end
681
663
  end
682
664
  end
@@ -59,12 +59,16 @@ module Langfuse
59
59
  RELEASE = "langfuse.release"
60
60
  ENVIRONMENT = "langfuse.environment"
61
61
 
62
+ # Validation limits
63
+ MAX_TAG_LENGTH = 200
64
+
62
65
  # Creates OpenTelemetry attributes from Langfuse trace attributes
63
66
  #
64
67
  # Converts user-friendly trace attributes into the internal OpenTelemetry
65
68
  # attribute format required by the span processor.
66
69
  #
67
70
  # @param attrs [Types::TraceAttributes, Hash] Trace attributes object or hash
71
+ # @param mask [#call, nil] Mask callable applied to input, output, and metadata
68
72
  # @return [Hash] OpenTelemetry attributes hash with non-nil values
69
73
  #
70
74
  # @example
@@ -77,23 +81,25 @@ module Langfuse
77
81
  # )
78
82
  # otel_attrs = Langfuse::OtelAttributes.create_trace_attributes(attrs)
79
83
  #
80
- def self.create_trace_attributes(attrs)
84
+ def self.create_trace_attributes(attrs, mask: nil)
81
85
  # Convert to hash if it's a TraceAttributes object
82
86
  attrs = attrs.to_h
83
87
  get_value = ->(key) { get_hash_value(attrs, key) }
84
88
 
89
+ input, output, metadata = mask_payload_fields(get_value, mask: mask)
90
+
85
91
  attributes = {
86
92
  TRACE_NAME => get_value.call(:name),
87
93
  TRACE_USER_ID => get_value.call(:user_id),
88
94
  TRACE_SESSION_ID => get_value.call(:session_id),
89
95
  VERSION => get_value.call(:version),
90
96
  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)),
97
+ TRACE_INPUT => serialize(input),
98
+ TRACE_OUTPUT => serialize(output),
99
+ TRACE_TAGS => normalize_tags(get_value.call(:tags)),
94
100
  ENVIRONMENT => get_value.call(:environment),
95
101
  TRACE_PUBLIC => get_value.call(:public),
96
- **flatten_metadata(get_value.call(:metadata), TRACE_METADATA)
102
+ **flatten_metadata(metadata, TRACE_METADATA)
97
103
  }
98
104
 
99
105
  # Remove nil values
@@ -107,6 +113,7 @@ module Langfuse
107
113
  #
108
114
  # @param type [String] Observation type (e.g., "generation", "span", "event")
109
115
  # @param attrs [Types::SpanAttributes, Types::GenerationAttributes, Hash] Observation attributes
116
+ # @param mask [#call, nil] Mask callable applied to input, output, and metadata
110
117
  # @return [Hash] OpenTelemetry attributes hash with non-nil values
111
118
  #
112
119
  # @example
@@ -117,11 +124,11 @@ module Langfuse
117
124
  # )
118
125
  # otel_attrs = Langfuse::OtelAttributes.create_observation_attributes("generation", attrs)
119
126
  #
120
- def self.create_observation_attributes(type, attrs)
127
+ def self.create_observation_attributes(type, attrs, mask: nil)
121
128
  attrs = attrs.to_h
122
129
  get_value = ->(key) { get_hash_value(attrs, key) }
123
130
 
124
- otel_attributes = build_observation_base_attributes(type, get_value)
131
+ otel_attributes = build_observation_base_attributes(type, get_value, mask: mask)
125
132
  add_prompt_attributes(otel_attributes, get_value.call(:prompt))
126
133
 
127
134
  # Remove nil values
@@ -155,6 +162,27 @@ module Langfuse
155
162
  end
156
163
  end
157
164
 
165
+ # Filters tags to String-only elements within 200-char limit, returns nil if empty or nil
166
+ #
167
+ # @param tags [Array, nil] Raw tags array (each tag must be ≤200 characters; oversized tags are dropped with a warning)
168
+ # @return [Array<String>, nil] Filtered tags or nil
169
+ # @api private
170
+ def self.normalize_tags(tags)
171
+ return nil if tags.nil?
172
+
173
+ logger = Langfuse.configuration.logger
174
+ filtered = tags.select do |t|
175
+ next false unless t.is_a?(String)
176
+
177
+ if t.length > MAX_TAG_LENGTH
178
+ logger.warn("Langfuse: Tag exceeds #{MAX_TAG_LENGTH} characters (#{t.length} chars). Dropping.")
179
+ next false
180
+ end
181
+ true
182
+ end
183
+ filtered.empty? ? nil : filtered
184
+ end
185
+
158
186
  # Flattens and serializes metadata into OpenTelemetry attribute format
159
187
  #
160
188
  # Converts nested metadata objects into dot-notation attribute keys.
@@ -210,6 +238,20 @@ module Langfuse
210
238
  end
211
239
  end
212
240
 
241
+ # Applies masking to the three payload fields (input, output, metadata)
242
+ #
243
+ # @param get_value [Proc] Lambda to get values from attributes hash
244
+ # @param mask [#call, nil] Mask callable
245
+ # @return [Array(Object, Object, Object)] Masked [input, output, metadata]
246
+ # @api private
247
+ def self.mask_payload_fields(get_value, mask:)
248
+ [
249
+ Masking.apply(get_value.call(:input), mask: mask),
250
+ Masking.apply(get_value.call(:output), mask: mask),
251
+ Masking.apply(get_value.call(:metadata), mask: mask)
252
+ ]
253
+ end
254
+
213
255
  # Gets a value from a hash supporting both symbol and string keys
214
256
  # Handles false values correctly (doesn't treat false as nil)
215
257
  #
@@ -228,23 +270,26 @@ module Langfuse
228
270
  #
229
271
  # @param type [String] Observation type
230
272
  # @param get_value [Proc] Lambda to get values from attributes hash
273
+ # @param mask [#call, nil] Mask callable applied to input, output, and metadata
231
274
  # @return [Hash] Base observation attributes
232
275
  # @api private
233
- def self.build_observation_base_attributes(type, get_value)
276
+ def self.build_observation_base_attributes(type, get_value, mask: nil)
277
+ input, output, metadata = mask_payload_fields(get_value, mask: mask)
278
+
234
279
  {
235
280
  OBSERVATION_TYPE => type,
236
281
  OBSERVATION_LEVEL => get_value.call(:level),
237
282
  OBSERVATION_STATUS_MESSAGE => get_value.call(:status_message),
238
283
  VERSION => get_value.call(:version),
239
- OBSERVATION_INPUT => serialize(get_value.call(:input)),
240
- OBSERVATION_OUTPUT => serialize(get_value.call(:output)),
284
+ OBSERVATION_INPUT => serialize(input),
285
+ OBSERVATION_OUTPUT => serialize(output),
241
286
  OBSERVATION_MODEL => get_value.call(:model),
242
287
  OBSERVATION_USAGE_DETAILS => serialize(get_value.call(:usage_details)),
243
288
  OBSERVATION_COST_DETAILS => serialize(get_value.call(:cost_details)),
244
289
  OBSERVATION_COMPLETION_START_TIME => serialize(get_value.call(:completion_start_time)),
245
290
  OBSERVATION_MODEL_PARAMETERS => serialize(get_value.call(:model_parameters)),
246
291
  ENVIRONMENT => get_value.call(:environment),
247
- **flatten_metadata(get_value.call(:metadata), OBSERVATION_METADATA)
292
+ **flatten_metadata(metadata, OBSERVATION_METADATA)
248
293
  }
249
294
  end
250
295
 
@@ -56,9 +56,10 @@ module Langfuse
56
56
  @tracer_provider = OpenTelemetry::SDK::Trace::TracerProvider.new
57
57
  @tracer_provider.add_span_processor(processor)
58
58
 
59
- # Add span processor for propagated attributes
60
- # This must be added AFTER the BatchSpanProcessor to ensure attributes are set before export
61
- span_processor = SpanProcessor.new
59
+ # Add span processor for propagated attributes and env/release defaults
60
+ # This must be added AFTER the BatchSpanProcessor so it runs before export and can
61
+ # apply all attributes (propagated IDs, environment, release) to the spans being sent
62
+ span_processor = SpanProcessor.new(config: config)
62
63
  @tracer_provider.add_span_processor(span_processor)
63
64
 
64
65
  # Set as global tracer provider
@@ -164,6 +164,7 @@ module Langfuse
164
164
  key = name.to_s
165
165
  key += ":v#{version}" if version
166
166
  key += ":#{label}" if label
167
+ key += ":production" unless version || label
167
168
  key
168
169
  end
169
170
 
@@ -166,15 +166,12 @@ module Langfuse
166
166
  span_key = _get_propagated_span_key(key)
167
167
 
168
168
  if key == "metadata" && value.is_a?(Hash)
169
- # Handle metadata - flatten into individual attributes
170
169
  value.each do |k, v|
171
170
  metadata_key = "#{OtelAttributes::TRACE_METADATA}.#{k}"
172
171
  propagated_attributes[metadata_key] = v.to_s
173
172
  end
174
173
  elsif key == "tags" && value.is_a?(Array)
175
- # Handle tags - serialize as JSON array for span attributes
176
- serialized_tags = OtelAttributes.serialize(value)
177
- propagated_attributes[span_key] = serialized_tags if serialized_tags
174
+ propagated_attributes[span_key] = value unless value.empty?
178
175
  else
179
176
  propagated_attributes[span_key] = value.to_s
180
177
  end
@@ -209,7 +206,7 @@ module Langfuse
209
206
  def self._merge_tags(context, context_key, new_tags)
210
207
  existing = context.value(context_key) || []
211
208
  existing = existing.to_a if existing.respond_to?(:to_a)
212
- (existing + new_tags).uniq
209
+ (existing + new_tags).uniq.freeze
213
210
  end
214
211
 
215
212
  # Set a propagated attribute in context and on current span
@@ -229,36 +226,31 @@ module Langfuse
229
226
  baggage_key = _get_propagated_baggage_key(key)
230
227
 
231
228
  # Merge metadata/tags with existing context values
232
- value = if key == "metadata" && value.is_a?(Hash)
233
- _merge_metadata(context, context_key, value)
234
- elsif key == "tags" && value.is_a?(Array)
235
- _merge_tags(context, context_key, value)
236
- else
237
- value
238
- end
229
+ merged = if key == "metadata" && value.is_a?(Hash)
230
+ _merge_metadata(context, context_key, value)
231
+ elsif key == "tags" && value.is_a?(Array)
232
+ _merge_tags(context, context_key, value)
233
+ else
234
+ value
235
+ end
239
236
 
240
- # Set in context
241
- context = context.set_value(context_key, value)
237
+ context = context.set_value(context_key, merged)
242
238
 
243
239
  # Set on current span (if recording)
244
240
  if span&.recording?
245
- if key == "metadata" && value.is_a?(Hash)
246
- # Handle metadata - flatten into individual attributes
247
- value.each do |k, v|
241
+ if key == "metadata" && merged.is_a?(Hash)
242
+ merged.each do |k, v|
248
243
  metadata_key = "#{OtelAttributes::TRACE_METADATA}.#{k}"
249
244
  span.set_attribute(metadata_key, v.to_s)
250
245
  end
251
- elsif key == "tags" && value.is_a?(Array)
252
- # Handle tags - serialize as JSON array
253
- serialized_tags = OtelAttributes.serialize(value)
254
- span.set_attribute(span_key, serialized_tags) if serialized_tags
246
+ elsif key == "tags" && merged.is_a?(Array)
247
+ span.set_attribute(span_key, merged) unless merged.empty?
255
248
  else
256
- span.set_attribute(span_key, value.to_s)
249
+ span.set_attribute(span_key, merged.to_s)
257
250
  end
258
251
  end
259
252
 
260
253
  # Set in baggage (if requested and available)
261
- # Note: Baggage support requires opentelemetry-baggage gem
262
254
  if as_baggage
263
255
  unless baggage_available?
264
256
  Langfuse.configuration.logger.warn(
@@ -270,7 +262,7 @@ module Langfuse
270
262
  context = _set_baggage_attribute(
271
263
  context: context,
272
264
  key: key,
273
- value: value,
265
+ value: merged,
274
266
  baggage_key: baggage_key
275
267
  )
276
268
  end
@@ -51,10 +51,13 @@ module Langfuse
51
51
  #
52
52
  # @param name [String] Score name (required)
53
53
  # @param value [Numeric, Integer, String] Score value (type depends on data_type)
54
+ # @param id [String, nil] Score ID
54
55
  # @param trace_id [String, nil] Trace ID to associate with the score
56
+ # @param session_id [String, nil] Session ID to associate with the score
55
57
  # @param observation_id [String, nil] Observation ID to associate with the score
56
58
  # @param comment [String, nil] Optional comment
57
59
  # @param metadata [Hash, nil] Optional metadata hash
60
+ # @param environment [String, nil] Optional environment
58
61
  # @param data_type [Symbol] Data type (:numeric, :boolean, :categorical)
59
62
  # @param dataset_run_id [String, nil] Optional dataset run ID to associate with the score
60
63
  # @param config_id [String, nil] Optional score config ID
@@ -70,19 +73,23 @@ module Langfuse
70
73
  # @example Categorical score
71
74
  # create(name: "category", value: "high", trace_id: "abc123", data_type: :categorical)
72
75
  # rubocop:disable Metrics/ParameterLists
73
- def create(name:, value:, trace_id: nil, observation_id: nil, comment: nil, metadata: nil,
74
- data_type: :numeric, dataset_run_id: nil, config_id: nil)
76
+ def create(name:, value:, id: nil, trace_id: nil, session_id: nil, observation_id: nil, comment: nil,
77
+ metadata: nil, environment: nil, data_type: :numeric, dataset_run_id: nil, config_id: nil)
75
78
  validate_name(name)
79
+ # Keep identifier policy server-side to preserve cross-SDK parity and avoid blocking valid future payloads.
76
80
  normalized_value = normalize_value(value, data_type)
77
81
  data_type_str = Types::SCORE_DATA_TYPES[data_type] || raise(ArgumentError, "Invalid data_type: #{data_type}")
78
82
 
79
83
  event = build_score_event(
80
84
  name: name,
81
85
  value: normalized_value,
86
+ id: id,
82
87
  trace_id: trace_id,
88
+ session_id: session_id,
83
89
  observation_id: observation_id,
84
90
  comment: comment,
85
91
  metadata: metadata,
92
+ environment: environment,
86
93
  data_type: data_type_str,
87
94
  dataset_run_id: dataset_run_id,
88
95
  config_id: config_id
@@ -204,25 +211,30 @@ module Langfuse
204
211
  #
205
212
  # @param name [String] Score name
206
213
  # @param value [Object] Normalized score value
214
+ # @param id [String, nil] Score ID
207
215
  # @param trace_id [String, nil] Trace ID
216
+ # @param session_id [String, nil] Session ID
208
217
  # @param observation_id [String, nil] Observation ID
209
218
  # @param comment [String, nil] Comment
210
219
  # @param metadata [Hash, nil] Metadata
220
+ # @param environment [String, nil] Environment
211
221
  # @param data_type [String] Data type string (NUMERIC, BOOLEAN, CATEGORICAL)
212
222
  # @return [Hash] Event hash
213
- # rubocop:disable Metrics/ParameterLists
214
- def build_score_event(name:, value:, trace_id:, observation_id:, comment:, metadata:, data_type:,
215
- dataset_run_id: nil, config_id: nil)
223
+ # rubocop:disable Metrics/ParameterLists, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
224
+ def build_score_event(name:, value:, id:, trace_id:, session_id:, observation_id:, comment:, metadata:,
225
+ environment:, data_type:, dataset_run_id: nil, config_id: nil)
216
226
  body = {
217
- id: SecureRandom.uuid,
227
+ id: id || SecureRandom.uuid,
218
228
  name: name,
219
229
  value: value,
220
230
  dataType: data_type
221
231
  }
222
232
  body[:traceId] = trace_id if trace_id
233
+ body[:sessionId] = session_id if session_id
223
234
  body[:observationId] = observation_id if observation_id
224
235
  body[:comment] = comment if comment
225
236
  body[:metadata] = metadata if metadata
237
+ body[:environment] = environment if environment
226
238
  body[:datasetRunId] = dataset_run_id if dataset_run_id
227
239
  body[:configId] = config_id if config_id
228
240
 
@@ -233,7 +245,7 @@ module Langfuse
233
245
  body: body
234
246
  }
235
247
  end
236
- # rubocop:enable Metrics/ParameterLists
248
+ # rubocop:enable Metrics/ParameterLists, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
237
249
 
238
250
  # Normalize and validate score value based on data type
239
251
  #
@@ -3,14 +3,21 @@
3
3
  require "opentelemetry/sdk"
4
4
 
5
5
  module Langfuse
6
- # Span processor that automatically sets propagated attributes on new spans.
6
+ # Span processor that applies default and propagated trace attributes on new spans.
7
7
  #
8
- # This processor reads propagated attributes from OpenTelemetry context and
9
- # sets them on spans when they are created. This ensures that attributes set
10
- # via `propagate_attributes` are automatically applied to all child spans.
8
+ # On span start, this processor first applies configured trace defaults
9
+ # (environment/release), then overlays attributes propagated in OpenTelemetry
10
+ # context (user/session/metadata/tags/version). This ensures consistent
11
+ # trace dimensions while still honoring per-request propagation.
11
12
  #
12
13
  # @api private
13
14
  class SpanProcessor < OpenTelemetry::SDK::Trace::SpanProcessor
15
+ # @param config [Langfuse::Config, nil] SDK configuration used to build trace defaults
16
+ def initialize(config: Langfuse.configuration)
17
+ @default_trace_attributes = build_default_trace_attributes(config).freeze
18
+ super()
19
+ end
20
+
14
21
  # Called when a span starts
15
22
  #
16
23
  # @param span [OpenTelemetry::SDK::Trace::Span] The span that started
@@ -19,13 +26,8 @@ module Langfuse
19
26
  def on_start(span, parent_context)
20
27
  return unless span.recording?
21
28
 
22
- # Get propagated attributes from context
23
- propagated_attrs = Propagation.get_propagated_attributes_from_context(parent_context)
24
-
25
- # Set attributes on span
26
- propagated_attrs.each do |key, value|
27
- span.set_attribute(key, value)
28
- end
29
+ apply_attributes(span, @default_trace_attributes)
30
+ apply_attributes(span, propagated_attributes(parent_context))
29
31
  end
30
32
 
31
33
  # Called when a span ends
@@ -57,5 +59,25 @@ module Langfuse
57
59
  _ = timeout # Suppress unused argument warning
58
60
  0
59
61
  end
62
+
63
+ private
64
+
65
+ def build_default_trace_attributes(config)
66
+ return {} unless config
67
+
68
+ OtelAttributes.create_trace_attributes(
69
+ { environment: config.environment, release: config.release }
70
+ )
71
+ end
72
+
73
+ def propagated_attributes(parent_context)
74
+ return {} unless parent_context
75
+
76
+ Propagation.get_propagated_attributes_from_context(parent_context)
77
+ end
78
+
79
+ def apply_attributes(span, attributes)
80
+ attributes.each { |key, value| span.set_attribute(key, value) }
81
+ end
60
82
  end
61
83
  end
@@ -173,7 +173,7 @@ module Langfuse
173
173
  # @return [Hash, nil] Token usage and other model-specific usage metrics
174
174
  attr_accessor :usage_details
175
175
 
176
- # @return [Hash, nil] Cost breakdown for the generation (total_cost, etc.)
176
+ # @return [Hash, nil] Cost breakdown for the generation (prefer :input, :output, :total)
177
177
  attr_accessor :cost_details
178
178
 
179
179
  # @return [Hash, nil] Information about the prompt used from Langfuse prompt management
@@ -186,7 +186,7 @@ module Langfuse
186
186
  # @param model [String, nil] Model name
187
187
  # @param model_parameters [Hash, nil] Model parameters
188
188
  # @param usage_details [Hash, nil] Usage metrics
189
- # @param cost_details [Hash, nil] Cost breakdown
189
+ # @param cost_details [Hash, nil] Cost breakdown (prefer :input, :output, :total)
190
190
  # @param prompt [Hash, nil] Prompt information with :name, :version, :is_fallback keys
191
191
  # @param kwargs [Hash] Additional keyword arguments passed to SpanAttributes
192
192
  # rubocop:disable Metrics/ParameterLists
@@ -287,7 +287,7 @@ module Langfuse
287
287
  # @param input [Object, nil] Input data
288
288
  # @param output [Object, nil] Output data
289
289
  # @param metadata [Hash, nil] Additional metadata
290
- # @param tags [Array<String>, nil] Tags array
290
+ # @param tags [Array<String>, nil] Tags array (each tag must be ≤200 characters; oversized tags are dropped)
291
291
  # @param public [Boolean, nil] Public visibility flag
292
292
  # @param environment [String, nil] Environment identifier
293
293
  # rubocop:disable Metrics/ParameterLists
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Langfuse
4
- VERSION = "0.5.0"
4
+ VERSION = "0.6.0"
5
5
  end
data/lib/langfuse.rb CHANGED
@@ -45,6 +45,7 @@ require_relative "langfuse/rails_cache_adapter"
45
45
  require_relative "langfuse/cache_warmer"
46
46
  require_relative "langfuse/api_client"
47
47
  require_relative "langfuse/otel_setup"
48
+ require_relative "langfuse/masking"
48
49
  require_relative "langfuse/otel_attributes"
49
50
  require_relative "langfuse/propagation"
50
51
  require_relative "langfuse/span_processor"
@@ -190,11 +191,16 @@ module Langfuse
190
191
  #
191
192
  # @param name [String] Score name (required)
192
193
  # @param value [Numeric, Integer, String] Score value (type depends on data_type)
194
+ # @param id [String, nil] Score ID
193
195
  # @param trace_id [String, nil] Trace ID to associate with the score
196
+ # @param session_id [String, nil] Session ID to associate with the score
194
197
  # @param observation_id [String, nil] Observation ID to associate with the score
195
198
  # @param comment [String, nil] Optional comment
196
199
  # @param metadata [Hash, nil] Optional metadata hash
200
+ # @param environment [String, nil] Optional environment
197
201
  # @param data_type [Symbol] Data type (:numeric, :boolean, :categorical)
202
+ # @param dataset_run_id [String, nil] Optional dataset run ID to associate with the score
203
+ # @param config_id [String, nil] Optional score config ID
198
204
  # @return [void]
199
205
  # @raise [ArgumentError] if validation fails
200
206
  #
@@ -207,16 +213,21 @@ module Langfuse
207
213
  # @example Categorical score
208
214
  # Langfuse.create_score(name: "category", value: "high", trace_id: "abc123", data_type: :categorical)
209
215
  # rubocop:disable Metrics/ParameterLists
210
- def create_score(name:, value:, trace_id: nil, observation_id: nil, comment: nil, metadata: nil,
211
- data_type: :numeric)
216
+ def create_score(name:, value:, id: nil, trace_id: nil, session_id: nil, observation_id: nil, comment: nil,
217
+ metadata: nil, environment: nil, data_type: :numeric, dataset_run_id: nil, config_id: nil)
212
218
  client.create_score(
213
219
  name: name,
214
220
  value: value,
221
+ id: id,
215
222
  trace_id: trace_id,
223
+ session_id: session_id,
216
224
  observation_id: observation_id,
217
225
  comment: comment,
218
226
  metadata: metadata,
219
- data_type: data_type
227
+ environment: environment,
228
+ data_type: data_type,
229
+ dataset_run_id: dataset_run_id,
230
+ config_id: config_id
220
231
  )
221
232
  end
222
233
  # rubocop:enable Metrics/ParameterLists
@@ -341,12 +352,12 @@ module Langfuse
341
352
  # Serialize attributes
342
353
  # Only set attributes if span is still recording (should always be true here, but guard for safety)
343
354
  if otel_span.recording?
344
- otel_attrs = OtelAttributes.create_observation_attributes(type_str, attrs.to_h)
355
+ otel_attrs = OtelAttributes.create_observation_attributes(type_str, attrs.to_h, mask: configuration.mask)
345
356
  otel_attrs.each { |key, value| otel_span.set_attribute(key, value) }
346
357
  end
347
358
 
348
- # Wrap in appropriate class
349
- observation = wrap_otel_span(otel_span, type_str, otel_tracer, attributes: attrs)
359
+ # Wrap in appropriate class (attributes already set on span above — pass nil to avoid double-masking)
360
+ observation = wrap_otel_span(otel_span, type_str, otel_tracer)
350
361
 
351
362
  # Events auto-end immediately when created
352
363
  observation.end if type_str == OBSERVATION_TYPES[:event]
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: langfuse-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - SimplePractice
@@ -171,6 +171,7 @@ files:
171
171
  - lib/langfuse/experiment_result.rb
172
172
  - lib/langfuse/experiment_runner.rb
173
173
  - lib/langfuse/item_result.rb
174
+ - lib/langfuse/masking.rb
174
175
  - lib/langfuse/observations.rb
175
176
  - lib/langfuse/otel_attributes.rb
176
177
  - lib/langfuse/otel_setup.rb