langfuse-rb 0.5.0 → 0.7.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: 2bf0fdf8f1b31f237397c90d8db3fc61c40bc8a69e20111a949aa0bdffc8dd3e
4
+ data.tar.gz: b6b83329218d23b3ebd53562a4eb5bb1cd01179f07fdbf5d90b2b1c97fc192ef
5
5
  SHA512:
6
- metadata.gz: b121586667150434548323b0734f456f51fb06c21513bb59aaf7d8bbd07b8077ef23bbf2b87f2cac1514f5b220f54cff19175d97c11d19472306367a888aa97f
7
- data.tar.gz: 9b5790ee2504681417cb7fa77fc2ebcb3c4e484bcfd512ba04abd6fbad215b609235d4bf109b0481c7855822d0643203873d49754b485aba487f6e85fb84260e
6
+ metadata.gz: a0bda13a371bcc93d37d4ae17548f04c65f1cc8fdf4982756eebac468d280f5b3a44d51ecb5ed5406e2ffee204a958988b3423860916471d8eb1a9e728fcf51c
7
+ data.tar.gz: a2bebff2e84e94c5fa30b6774c89c35f63ed4d2af214a76348a370ddbd9326ff0c8346fac9b3601e9bf9427bdb56f73a3f309f7eb71d439bd31b70426461ae3d
data/CHANGELOG.md CHANGED
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.7.0] - 2026-04-14
11
+
12
+ ### Added
13
+ - Custom/deterministic trace ID support (#74)
14
+
15
+ ### Fixed
16
+ - Bump faraday, json, and addressable to patch CVEs (#75)
17
+
18
+ ### Documentation
19
+ - Align docs with implementation (#70, #76)
20
+
21
+ ## [0.6.0] - 2026-03-06
22
+
23
+ ### Added
24
+ - Tracing payload masking via `Config#mask` (#68)
25
+ - Cost details and usage details support on generations (#61)
26
+ - Client-level `environment` and `release` configuration (#52)
27
+ - Configurable parameters when creating scores (#48)
28
+
29
+ ### Fixed
30
+ - Prompt cache key defaults unlabeled/unversioned fetches to production, matching JS/Python semantics (#63)
31
+ - Tags sent as native arrays instead of JSON strings on OTel span attributes (#66)
32
+ - Enforce 200-character tag length limit on traces (#67)
33
+ - Score API parity between `Langfuse.create_score` and `Client#create_score` (#49)
34
+ - Corrected misleading YARD docstrings for SWR cache config
35
+
10
36
  ## [0.5.0] - 2026-02-09
11
37
 
12
38
  ### Added
@@ -54,7 +80,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
54
80
  - OpenTelemetry-based tracing with OTLP export
55
81
  - Distributed caching with Rails.cache backend and stampede protection
56
82
  - Prompt management (text and chat) with Mustache templating
57
- - In-memory caching with TTL and LRU eviction
83
+ - In-memory caching with TTL and bounded expiration-ordered eviction
58
84
  - Fallback prompt support
59
85
  - Global configuration pattern with `Langfuse.configure`
60
86
 
@@ -62,7 +88,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
62
88
  - Migrated from legacy ingestion API to OTLP endpoint
63
89
  - Removed `tracing_enabled` configuration flag (#2)
64
90
 
65
- [Unreleased]: https://github.com/simplepractice/langfuse-rb/compare/v0.5.0...HEAD
91
+ [Unreleased]: https://github.com/simplepractice/langfuse-rb/compare/v0.7.0...HEAD
92
+ [0.7.0]: https://github.com/simplepractice/langfuse-rb/compare/v0.6.0...v0.7.0
93
+ [0.6.0]: https://github.com/simplepractice/langfuse-rb/compare/v0.5.0...v0.6.0
66
94
  [0.5.0]: https://github.com/simplepractice/langfuse-rb/compare/v0.4.0...v0.5.0
67
95
  [0.4.0]: https://github.com/simplepractice/langfuse-rb/compare/v0.3.0...v0.4.0
68
96
  [0.3.0]: https://github.com/simplepractice/langfuse-rb/compare/v0.2.0...v0.3.0
@@ -85,7 +85,7 @@ module Langfuse
85
85
  # @example Warm with a different default label
86
86
  # results = warmer.warm_all(default_label: "staging")
87
87
  #
88
- # @example Warm without any label (latest versions)
88
+ # @example Warm without any label (API-determined selection)
89
89
  # results = warmer.warm_all(default_label: nil)
90
90
  #
91
91
  # @example With specific versions for some prompts
@@ -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
@@ -132,8 +133,7 @@ module Langfuse
132
133
  # @yield [observation] Optional block that receives the observation object
133
134
  # @return [BaseObservation, Object] The child observation (or block return value if block given)
134
135
  def start_observation(name, attrs = {}, as_type: :span, &block)
135
- # Call module-level factory with parent context
136
- # Skip validation to allow unknown types to fall back to Span
136
+ # Skip validation so unknown types fall back to Span in the factory.
137
137
  child = Langfuse.start_observation(
138
138
  name,
139
139
  attrs,
@@ -141,24 +141,9 @@ module Langfuse
141
141
  parent_span_context: @otel_span.context,
142
142
  skip_validation: true
143
143
  )
144
+ return child unless block
144
145
 
145
- if block
146
- # Block-based API: auto-ends when block completes
147
- # Set context and execute block
148
- current_context = OpenTelemetry::Context.current
149
- result = OpenTelemetry::Context.with_current(
150
- OpenTelemetry::Trace.context_with_span(child.otel_span, parent_context: current_context)
151
- ) do
152
- block.call(child)
153
- end
154
- # Only end if not already ended (events auto-end in start_observation)
155
- child.end unless as_type.to_s == OBSERVATION_TYPES[:event]
156
- result
157
- else
158
- # Stateful API - return observation
159
- # Events already auto-ended in start_observation
160
- child
161
- end
146
+ child.send(:run_in_context, &block)
162
147
  end
163
148
 
164
149
  # Sets observation-level input attributes.
@@ -196,9 +181,10 @@ module Langfuse
196
181
  # @param level [String] Log level (debug, default, warning, error)
197
182
  # @return [void]
198
183
  def event(name:, input: nil, level: "default")
184
+ masked_input = Masking.apply(input, mask: Langfuse.configuration.mask)
199
185
  attributes = {
200
- "langfuse.observation.input" => input&.to_json,
201
- "langfuse.observation.level" => level
186
+ OtelAttributes::OBSERVATION_INPUT => masked_input&.to_json,
187
+ OtelAttributes::OBSERVATION_LEVEL => level
202
188
  }.compact
203
189
 
204
190
  @otel_span.add_event(name, attributes: attributes)
@@ -242,7 +228,7 @@ module Langfuse
242
228
  attrs_hash = attrs.to_h.merge(kwargs)
243
229
 
244
230
  # Use @type instance variable set during initialization
245
- otel_attrs = OtelAttributes.create_observation_attributes(type, attrs_hash)
231
+ otel_attrs = OtelAttributes.create_observation_attributes(type, attrs_hash, mask: Langfuse.configuration.mask)
246
232
  otel_attrs.each { |key, value| @otel_span.set_attribute(key, value) }
247
233
  end
248
234
 
@@ -259,6 +245,27 @@ module Langfuse
259
245
  prompt
260
246
  end
261
247
  end
248
+
249
+ private
250
+
251
+ # Runs the block with this observation as the active OTel span,
252
+ # then ends the span in ensure (events excluded — they auto-end).
253
+ # @api private
254
+ def run_in_context
255
+ parent_ctx = OpenTelemetry::Context.current
256
+ span_ctx = OpenTelemetry::Trace.context_with_span(@otel_span, parent_context: parent_ctx)
257
+ OpenTelemetry::Context.with_current(span_ctx) { yield self }
258
+ ensure
259
+ safe_end
260
+ end
261
+
262
+ # Ends the span, swallowing errors so ensure never masks a block exception.
263
+ # @api private
264
+ def safe_end
265
+ self.end unless @type == OBSERVATION_TYPES[:event]
266
+ rescue StandardError
267
+ nil
268
+ end
262
269
  end
263
270
 
264
271
  # General-purpose observation for tracking operations, functions, or logical units of work.
@@ -291,6 +298,35 @@ module Langfuse
291
298
  end
292
299
  end
293
300
 
301
+ # Shared setters for observation types that interact with a model (Generation, Embedding).
302
+ # @api private
303
+ module ModelSetters
304
+ # @param value [Hash] Usage hash with token counts
305
+ # @return [void]
306
+ # @deprecated Use #usage_details= instead.
307
+ def usage=(value)
308
+ self.usage_details = value
309
+ end
310
+
311
+ # @param value [Hash] Usage details hash (preserves key shape as provided)
312
+ # @return [void]
313
+ def usage_details=(value)
314
+ update_observation_attributes(usage_details: value)
315
+ end
316
+
317
+ # @param value [String] Model name (e.g., "gpt-4", "claude-3-opus")
318
+ # @return [void]
319
+ def model=(value)
320
+ update_observation_attributes(model: value)
321
+ end
322
+
323
+ # @param value [Hash] Model parameters (temperature, max_tokens, etc.)
324
+ # @return [void]
325
+ def model_parameters=(value)
326
+ update_observation_attributes(model_parameters: value)
327
+ end
328
+ end
329
+
294
330
  # Observation for LLM calls. Provides methods to set output, usage, and other LLM-specific metadata.
295
331
  #
296
332
  # @example Block-based API
@@ -299,7 +335,7 @@ module Langfuse
299
335
  # gen.input = [{ role: "user", content: "Hello" }]
300
336
  # response = call_llm(gen.input)
301
337
  # gen.output = response
302
- # gen.usage = { prompt_tokens: 100, completion_tokens: 50, total_tokens: 150 }
338
+ # gen.usage_details = { prompt_tokens: 100, completion_tokens: 50, total_tokens: 150 }
303
339
  # end
304
340
  #
305
341
  # @example Stateful API
@@ -315,6 +351,8 @@ module Langfuse
315
351
  # gen.end
316
352
  #
317
353
  class Generation < BaseObservation
354
+ include ModelSetters
355
+
318
356
  # @param otel_span [OpenTelemetry::SDK::Trace::Span] The underlying OTel span
319
357
  # @param otel_tracer [OpenTelemetry::SDK::Trace::Tracer] The OTel tracer
320
358
  # @param attributes [Hash, Types::GenerationAttributes, nil] Optional initial attributes
@@ -329,45 +367,10 @@ module Langfuse
329
367
  self
330
368
  end
331
369
 
332
- # @param value [Hash] Usage hash with token counts (:prompt_tokens, :completion_tokens, :total_tokens)
333
- # @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")
370
+ # @param value [Hash] Cost details hash (prefer :input, :output, :total for aggregate Langfuse cost metrics)
349
371
  # @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)
372
+ def cost_details=(value)
373
+ update_observation_attributes(cost_details: value)
371
374
  end
372
375
  end
373
376
 
@@ -630,7 +633,7 @@ module Langfuse
630
633
  # vectors = embedding_service.generate(embedding.input, model: embedding.model)
631
634
  # embedding.update(
632
635
  # output: vectors,
633
- # usage: { prompt_tokens: 20, total_tokens: 20 }
636
+ # usage_details: { prompt_tokens: 20, total_tokens: 20 }
634
637
  # )
635
638
  # end
636
639
  #
@@ -647,6 +650,8 @@ module Langfuse
647
650
  # embedding.end
648
651
  #
649
652
  class Embedding < BaseObservation
653
+ include ModelSetters
654
+
650
655
  # @param otel_span [OpenTelemetry::SDK::Trace::Span] The underlying OTel span
651
656
  # @param otel_tracer [OpenTelemetry::SDK::Trace::Tracer] The OTel tracer
652
657
  # @param attributes [Hash, Types::EmbeddingAttributes, nil] Optional initial attributes
@@ -660,23 +665,5 @@ module Langfuse
660
665
  update_observation_attributes(attrs)
661
666
  self
662
667
  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
668
  end
682
669
  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
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Langfuse
6
+ # Deterministic and random trace/observation ID generation.
7
+ #
8
+ # Mirrors the Python and JS SDK helpers so the same seed produces the same
9
+ # trace ID across all three SDKs. This lets callers correlate Langfuse traces
10
+ # with external system identifiers (database primary keys, request IDs, etc.)
11
+ # and score or reference traces later without having to persist the generated
12
+ # Langfuse ID.
13
+ #
14
+ # @example Deterministic from an external ID
15
+ # trace_id = Langfuse::TraceId.create(seed: "order-12345")
16
+ # Langfuse.observe("process-order", trace_id: trace_id) { |span| ... }
17
+ # Langfuse.create_score(name: "quality", value: 0.9, trace_id: trace_id)
18
+ #
19
+ # @example Random (no seed)
20
+ # trace_id = Langfuse::TraceId.create
21
+ module TraceId
22
+ TRACE_ID_PATTERN = /\A[0-9a-f]{32}\z/
23
+ INVALID_TRACE_ID = ("0" * 32)
24
+
25
+ private_constant :TRACE_ID_PATTERN, :INVALID_TRACE_ID
26
+
27
+ class << self
28
+ # Generate a W3C trace ID (32 lowercase hex chars).
29
+ #
30
+ # With no seed, delegates to OpenTelemetry's random trace ID generator.
31
+ # With a seed, takes the first 16 bytes of SHA-256(seed) so the same
32
+ # input always produces the same trace ID.
33
+ #
34
+ # @note Avoid passing PII, secrets, or credentials as seeds — the raw seed
35
+ # value appears in application code and may leak through logs/backtraces.
36
+ # Use stable external identifiers (database PKs, UUIDs, request IDs).
37
+ # @param seed [String, nil] Optional seed for deterministic generation.
38
+ # Must be a String if provided; non-String values raise ArgumentError
39
+ # for cross-SDK parity (Python/JS both reject non-strings).
40
+ # @return [String] 32-character lowercase hex trace ID
41
+ # @raise [ArgumentError] if seed is not nil and not a String
42
+ def create(seed: nil)
43
+ return OpenTelemetry::Trace.generate_trace_id.unpack1("H*") if seed.nil?
44
+
45
+ Digest::SHA256.digest(validate_seed!(seed))[0, 16].unpack1("H*")
46
+ end
47
+
48
+ private
49
+
50
+ # @api private
51
+ def validate_seed!(seed)
52
+ raise ArgumentError, "seed must be a String, got #{seed.class}" unless seed.is_a?(String)
53
+
54
+ # ASCII-8BIT strings (binary) often already hold valid UTF-8 bytes
55
+ # but can't be transcoded — re-tag them instead.
56
+ return seed.dup.force_encoding("UTF-8") if seed.encoding == Encoding::ASCII_8BIT
57
+
58
+ seed.encode("UTF-8")
59
+ end
60
+
61
+ # @api private
62
+ def valid?(trace_id)
63
+ return false unless trace_id.is_a?(String) && TRACE_ID_PATTERN.match?(trace_id)
64
+
65
+ trace_id != INVALID_TRACE_ID
66
+ end
67
+
68
+ # Build a sampled OpenTelemetry SpanContext carrying the given hex trace ID.
69
+ #
70
+ # A random span_id is generated as a placeholder — only the trace_id is
71
+ # consumed by the child span that gets created.
72
+ #
73
+ # @api private
74
+ def to_span_context(trace_id)
75
+ raise ArgumentError, "Invalid trace_id: #{trace_id.inspect}" unless valid?(trace_id)
76
+
77
+ OpenTelemetry::Trace::SpanContext.new(
78
+ trace_id: [trace_id].pack("H*"),
79
+ span_id: OpenTelemetry::Trace.generate_span_id,
80
+ trace_flags: OpenTelemetry::Trace::TraceFlags::SAMPLED,
81
+ # Cross-SDK parity: Python uses is_remote=False (_create_remote_parent_span).
82
+ # Changing this would alter ParentBased sampler behavior across SDKs.
83
+ remote: false
84
+ )
85
+ end
86
+ end
87
+ end
88
+ 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.7.0"
5
5
  end
data/lib/langfuse.rb CHANGED
@@ -45,10 +45,12 @@ 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"
51
52
  require_relative "langfuse/observations"
53
+ require_relative "langfuse/trace_id"
52
54
  require_relative "langfuse/score_client"
53
55
  require_relative "langfuse/text_prompt_client"
54
56
  require_relative "langfuse/chat_prompt_client"
@@ -190,11 +192,16 @@ module Langfuse
190
192
  #
191
193
  # @param name [String] Score name (required)
192
194
  # @param value [Numeric, Integer, String] Score value (type depends on data_type)
195
+ # @param id [String, nil] Score ID
193
196
  # @param trace_id [String, nil] Trace ID to associate with the score
197
+ # @param session_id [String, nil] Session ID to associate with the score
194
198
  # @param observation_id [String, nil] Observation ID to associate with the score
195
199
  # @param comment [String, nil] Optional comment
196
200
  # @param metadata [Hash, nil] Optional metadata hash
201
+ # @param environment [String, nil] Optional environment
197
202
  # @param data_type [Symbol] Data type (:numeric, :boolean, :categorical)
203
+ # @param dataset_run_id [String, nil] Optional dataset run ID to associate with the score
204
+ # @param config_id [String, nil] Optional score config ID
198
205
  # @return [void]
199
206
  # @raise [ArgumentError] if validation fails
200
207
  #
@@ -207,16 +214,21 @@ module Langfuse
207
214
  # @example Categorical score
208
215
  # Langfuse.create_score(name: "category", value: "high", trace_id: "abc123", data_type: :categorical)
209
216
  # rubocop:disable Metrics/ParameterLists
210
- def create_score(name:, value:, trace_id: nil, observation_id: nil, comment: nil, metadata: nil,
211
- data_type: :numeric)
217
+ def create_score(name:, value:, id: nil, trace_id: nil, session_id: nil, observation_id: nil, comment: nil,
218
+ metadata: nil, environment: nil, data_type: :numeric, dataset_run_id: nil, config_id: nil)
212
219
  client.create_score(
213
220
  name: name,
214
221
  value: value,
222
+ id: id,
215
223
  trace_id: trace_id,
224
+ session_id: session_id,
216
225
  observation_id: observation_id,
217
226
  comment: comment,
218
227
  metadata: metadata,
219
- data_type: data_type
228
+ environment: environment,
229
+ data_type: data_type,
230
+ dataset_run_id: dataset_run_id,
231
+ config_id: config_id
220
232
  )
221
233
  end
222
234
  # rubocop:enable Metrics/ParameterLists
@@ -285,6 +297,24 @@ module Langfuse
285
297
  client.flush_scores if @client
286
298
  end
287
299
 
300
+ # Generate a trace ID (deterministic when seeded, random otherwise).
301
+ #
302
+ # Use this to correlate Langfuse traces with external identifiers. The
303
+ # same seed always produces the same trace ID across the Ruby, Python,
304
+ # and JS SDKs (SHA-256 of the seed, first 16 bytes, as 32 hex chars).
305
+ #
306
+ # @note Avoid PII or secrets as seeds. See {TraceId.create} for details.
307
+ # @param seed [String, nil] Optional deterministic seed
308
+ # @return [String] 32-character lowercase hex trace ID
309
+ # @raise [ArgumentError] if seed is not nil and not a String
310
+ #
311
+ # @example
312
+ # trace_id = Langfuse.create_trace_id(seed: "order-12345")
313
+ # Langfuse.observe("process", trace_id: trace_id) { |span| ... }
314
+ def create_trace_id(seed: nil)
315
+ TraceId.create(seed: seed)
316
+ end
317
+
288
318
  # Reset global configuration and client (useful for testing)
289
319
  #
290
320
  # @return [void]
@@ -308,11 +338,14 @@ module Langfuse
308
338
  # @param name [String] Descriptive name for the observation
309
339
  # @param attrs [Hash, Types::SpanAttributes, Types::GenerationAttributes, nil] Observation attributes
310
340
  # @param as_type [Symbol, String] Observation type (:span, :generation, :event, etc.)
341
+ # @param trace_id [String, nil] Optional 32-char lowercase hex trace ID to attach the observation to.
342
+ # Mutually exclusive with `parent_span_context`. Use {Langfuse.create_trace_id} to generate one.
311
343
  # @param parent_span_context [OpenTelemetry::Trace::SpanContext, nil] Parent span context for child observations
312
344
  # @param start_time [Time, Integer, nil] Optional start time (Time object or Unix timestamp in nanoseconds)
313
345
  # @param skip_validation [Boolean] Skip validation (for internal use). Defaults to false.
314
346
  # @return [BaseObservation] The observation wrapper (Span, Generation, or Event)
315
- # @raise [ArgumentError] if an invalid observation type is provided
347
+ # @raise [ArgumentError] if an invalid observation type is provided, an invalid `trace_id` is given,
348
+ # or both `trace_id` and `parent_span_context` are provided
316
349
  #
317
350
  # @example Create root span
318
351
  # span = Langfuse.start_observation("root-operation", { input: {...} })
@@ -321,14 +354,16 @@ module Langfuse
321
354
  # child = Langfuse.start_observation("llm-call", { model: "gpt-4" },
322
355
  # as_type: :generation,
323
356
  # parent_span_context: parent.otel_span.context)
324
- def start_observation(name, attrs = {}, as_type: :span, parent_span_context: nil, start_time: nil,
325
- skip_validation: false)
357
+ #
358
+ # @example Attach to a deterministic trace ID
359
+ # trace_id = Langfuse.create_trace_id(seed: "order-123")
360
+ # root = Langfuse.start_observation("process-order", trace_id: trace_id)
361
+ # rubocop:disable Metrics/ParameterLists
362
+ def start_observation(name, attrs = {}, as_type: :span, trace_id: nil, parent_span_context: nil,
363
+ start_time: nil, skip_validation: false)
364
+ parent_span_context = resolve_trace_context(trace_id, parent_span_context)
326
365
  type_str = as_type.to_s
327
-
328
- unless skip_validation || valid_observation_type?(as_type)
329
- valid_types = OBSERVATION_TYPES.values.sort.join(", ")
330
- raise ArgumentError, "Invalid observation type: #{type_str}. Valid types: #{valid_types}"
331
- end
366
+ validate_observation_type!(as_type, type_str) unless skip_validation
332
367
 
333
368
  otel_tracer = otel_tracer()
334
369
  otel_span = create_otel_span(
@@ -337,32 +372,27 @@ module Langfuse
337
372
  parent_span_context: parent_span_context,
338
373
  otel_tracer: otel_tracer
339
374
  )
375
+ apply_observation_attributes(otel_span, type_str, attrs)
340
376
 
341
- # Serialize attributes
342
- # Only set attributes if span is still recording (should always be true here, but guard for safety)
343
- if otel_span.recording?
344
- otel_attrs = OtelAttributes.create_observation_attributes(type_str, attrs.to_h)
345
- otel_attrs.each { |key, value| otel_span.set_attribute(key, value) }
346
- end
347
-
348
- # Wrap in appropriate class
349
- observation = wrap_otel_span(otel_span, type_str, otel_tracer, attributes: attrs)
350
-
377
+ observation = wrap_otel_span(otel_span, type_str, otel_tracer)
351
378
  # Events auto-end immediately when created
352
379
  observation.end if type_str == OBSERVATION_TYPES[:event]
353
-
354
380
  observation
355
381
  end
382
+ # rubocop:enable Metrics/ParameterLists
356
383
 
357
384
  # User-facing convenience method for creating root observations
358
385
  #
359
386
  # @param name [String] Descriptive name for the observation
360
387
  # @param attrs [Hash] Observation attributes (optional positional or keyword)
361
388
  # @param as_type [Symbol, String] Observation type (:span, :generation, :event, etc.)
389
+ # @param trace_id [String, nil] Optional 32-char lowercase hex trace ID to attach the observation to.
390
+ # Use {Langfuse.create_trace_id} to generate one. Forwarded to {.start_observation}.
362
391
  # @param kwargs [Hash] Additional keyword arguments merged into observation attributes (e.g., input:, output:, metadata:)
363
392
  # @yield [observation] Optional block that receives the observation object
364
393
  # @yieldparam observation [BaseObservation] The observation object
365
394
  # @return [BaseObservation, Object] The observation (or block return value if block given)
395
+ # @raise [ArgumentError] if an invalid `trace_id` is provided
366
396
  #
367
397
  # @example Block-based API (auto-ends)
368
398
  # Langfuse.observe("operation") do |obs|
@@ -374,28 +404,12 @@ module Langfuse
374
404
  # obs = Langfuse.observe("operation", input: { data: "test" })
375
405
  # obs.update(output: { result: "success" })
376
406
  # obs.end
377
- def observe(name, attrs = {}, as_type: :span, **kwargs, &block)
378
- # Merge positional attrs and keyword kwargs
407
+ def observe(name, attrs = {}, as_type: :span, trace_id: nil, **kwargs, &block)
379
408
  merged_attrs = attrs.to_h.merge(kwargs)
380
- observation = start_observation(name, merged_attrs, as_type: as_type)
381
-
382
- if block
383
- # Block-based API: auto-ends when block completes
384
- # Set context and execute block
385
- current_context = OpenTelemetry::Context.current
386
- result = OpenTelemetry::Context.with_current(
387
- OpenTelemetry::Trace.context_with_span(observation.otel_span, parent_context: current_context)
388
- ) do
389
- block.call(observation)
390
- end
391
- # Only end if not already ended (events auto-end in start_observation)
392
- observation.end unless as_type.to_s == OBSERVATION_TYPES[:event]
393
- result
394
- else
395
- # Stateful API - return observation
396
- # Events already auto-ended in start_observation
397
- observation
398
- end
409
+ observation = start_observation(name, merged_attrs, as_type: as_type, trace_id: trace_id)
410
+ return observation unless block
411
+
412
+ observation.send(:run_in_context, &block)
399
413
  end
400
414
 
401
415
  # Registry mapping observation type strings to their wrapper classes
@@ -414,6 +428,31 @@ module Langfuse
414
428
 
415
429
  private
416
430
 
431
+ # @api private
432
+ def resolve_trace_context(trace_id, parent_span_context)
433
+ return parent_span_context unless trace_id
434
+ raise ArgumentError, "Cannot specify both trace_id and parent_span_context" if parent_span_context
435
+
436
+ TraceId.send(:to_span_context, trace_id)
437
+ end
438
+
439
+ # @api private
440
+ def validate_observation_type!(as_type, type_str)
441
+ return if valid_observation_type?(as_type)
442
+
443
+ valid_types = OBSERVATION_TYPES.values.sort.join(", ")
444
+ raise ArgumentError, "Invalid observation type: #{type_str}. Valid types: #{valid_types}"
445
+ end
446
+
447
+ # @api private
448
+ def apply_observation_attributes(otel_span, type_str, attrs)
449
+ # Guard against ended spans — should always be recording here, but safe.
450
+ return unless otel_span.recording?
451
+
452
+ otel_attrs = OtelAttributes.create_observation_attributes(type_str, attrs.to_h, mask: configuration.mask)
453
+ otel_attrs.each { |key, value| otel_span.set_attribute(key, value) }
454
+ end
455
+
417
456
  # Validates that an observation type is valid
418
457
  #
419
458
  # Checks if the provided type (symbol or string) matches a valid observation type
metadata CHANGED
@@ -1,13 +1,14 @@
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.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - SimplePractice
8
+ autorequire:
8
9
  bindir: bin
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-04-14 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: faraday
@@ -171,6 +172,7 @@ files:
171
172
  - lib/langfuse/experiment_result.rb
172
173
  - lib/langfuse/experiment_runner.rb
173
174
  - lib/langfuse/item_result.rb
175
+ - lib/langfuse/masking.rb
174
176
  - lib/langfuse/observations.rb
175
177
  - lib/langfuse/otel_attributes.rb
176
178
  - lib/langfuse/otel_setup.rb
@@ -182,6 +184,7 @@ files:
182
184
  - lib/langfuse/stale_while_revalidate.rb
183
185
  - lib/langfuse/text_prompt_client.rb
184
186
  - lib/langfuse/timestamp_parser.rb
187
+ - lib/langfuse/trace_id.rb
185
188
  - lib/langfuse/traced_execution.rb
186
189
  - lib/langfuse/types.rb
187
190
  - lib/langfuse/version.rb
@@ -193,6 +196,7 @@ metadata:
193
196
  source_code_uri: https://github.com/simplepractice/langfuse-rb
194
197
  changelog_uri: https://github.com/simplepractice/langfuse-rb/blob/main/CHANGELOG.md
195
198
  rubygems_mfa_required: 'true'
199
+ post_install_message:
196
200
  rdoc_options: []
197
201
  require_paths:
198
202
  - lib
@@ -207,7 +211,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
207
211
  - !ruby/object:Gem::Version
208
212
  version: '0'
209
213
  requirements: []
210
- rubygems_version: 4.0.3
214
+ rubygems_version: 3.4.1
215
+ signing_key:
211
216
  specification_version: 4
212
217
  summary: Ruby SDK for Langfuse - LLM observability and prompt management
213
218
  test_files: []