langfuse-rb 0.6.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: e320f34169f77bea79aa259867b62aa7164d77a693e864cb1d03cb86d0d188a2
4
- data.tar.gz: 59a40715dbb46604f6287b46b80c34e1524f7279fed261b7795d781854df987f
3
+ metadata.gz: 2bf0fdf8f1b31f237397c90d8db3fc61c40bc8a69e20111a949aa0bdffc8dd3e
4
+ data.tar.gz: b6b83329218d23b3ebd53562a4eb5bb1cd01179f07fdbf5d90b2b1c97fc192ef
5
5
  SHA512:
6
- metadata.gz: d18c3cb807c759c20f72a4259ccfb0502f1800b671aadcf14ea24f88e0be227ed200677f1cbabef22e467c06f2f1ce5f1cf0a0d1a379e14fe29327fce5597ec0
7
- data.tar.gz: ff132aea8911b044a43de1a9abe1e54f3a680069b3b480f2e8109953600600b80abe4b923a13d3672f22ff1fe5c4ab3aef93246f2a9946d1c4f593495228ac0f
6
+ metadata.gz: a0bda13a371bcc93d37d4ae17548f04c65f1cc8fdf4982756eebac468d280f5b3a44d51ecb5ed5406e2ffee204a958988b3423860916471d8eb1a9e728fcf51c
7
+ data.tar.gz: a2bebff2e84e94c5fa30b6774c89c35f63ed4d2af214a76348a370ddbd9326ff0c8346fac9b3601e9bf9427bdb56f73a3f309f7eb71d439bd31b70426461ae3d
data/CHANGELOG.md CHANGED
@@ -7,6 +7,17 @@ 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
+
10
21
  ## [0.6.0] - 2026-03-06
11
22
 
12
23
  ### Added
@@ -69,7 +80,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
69
80
  - OpenTelemetry-based tracing with OTLP export
70
81
  - Distributed caching with Rails.cache backend and stampede protection
71
82
  - Prompt management (text and chat) with Mustache templating
72
- - In-memory caching with TTL and LRU eviction
83
+ - In-memory caching with TTL and bounded expiration-ordered eviction
73
84
  - Fallback prompt support
74
85
  - Global configuration pattern with `Langfuse.configure`
75
86
 
@@ -77,7 +88,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77
88
  - Migrated from legacy ingestion API to OTLP endpoint
78
89
  - Removed `tracing_enabled` configuration flag (#2)
79
90
 
80
- [Unreleased]: https://github.com/simplepractice/langfuse-rb/compare/v0.6.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
81
93
  [0.6.0]: https://github.com/simplepractice/langfuse-rb/compare/v0.5.0...v0.6.0
82
94
  [0.5.0]: https://github.com/simplepractice/langfuse-rb/compare/v0.4.0...v0.5.0
83
95
  [0.4.0]: https://github.com/simplepractice/langfuse-rb/compare/v0.3.0...v0.4.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
@@ -133,8 +133,7 @@ module Langfuse
133
133
  # @yield [observation] Optional block that receives the observation object
134
134
  # @return [BaseObservation, Object] The child observation (or block return value if block given)
135
135
  def start_observation(name, attrs = {}, as_type: :span, &block)
136
- # Call module-level factory with parent context
137
- # Skip validation to allow unknown types to fall back to Span
136
+ # Skip validation so unknown types fall back to Span in the factory.
138
137
  child = Langfuse.start_observation(
139
138
  name,
140
139
  attrs,
@@ -142,24 +141,9 @@ module Langfuse
142
141
  parent_span_context: @otel_span.context,
143
142
  skip_validation: true
144
143
  )
144
+ return child unless block
145
145
 
146
- if block
147
- # Block-based API: auto-ends when block completes
148
- # Set context and execute block
149
- current_context = OpenTelemetry::Context.current
150
- result = OpenTelemetry::Context.with_current(
151
- OpenTelemetry::Trace.context_with_span(child.otel_span, parent_context: current_context)
152
- ) do
153
- block.call(child)
154
- end
155
- # Only end if not already ended (events auto-end in start_observation)
156
- child.end unless as_type.to_s == OBSERVATION_TYPES[:event]
157
- result
158
- else
159
- # Stateful API - return observation
160
- # Events already auto-ended in start_observation
161
- child
162
- end
146
+ child.send(:run_in_context, &block)
163
147
  end
164
148
 
165
149
  # Sets observation-level input attributes.
@@ -261,6 +245,27 @@ module Langfuse
261
245
  prompt
262
246
  end
263
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
264
269
  end
265
270
 
266
271
  # General-purpose observation for tracking operations, functions, or logical units of work.
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Langfuse
4
- VERSION = "0.6.0"
4
+ VERSION = "0.7.0"
5
5
  end
data/lib/langfuse.rb CHANGED
@@ -50,6 +50,7 @@ require_relative "langfuse/otel_attributes"
50
50
  require_relative "langfuse/propagation"
51
51
  require_relative "langfuse/span_processor"
52
52
  require_relative "langfuse/observations"
53
+ require_relative "langfuse/trace_id"
53
54
  require_relative "langfuse/score_client"
54
55
  require_relative "langfuse/text_prompt_client"
55
56
  require_relative "langfuse/chat_prompt_client"
@@ -296,6 +297,24 @@ module Langfuse
296
297
  client.flush_scores if @client
297
298
  end
298
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
+
299
318
  # Reset global configuration and client (useful for testing)
300
319
  #
301
320
  # @return [void]
@@ -319,11 +338,14 @@ module Langfuse
319
338
  # @param name [String] Descriptive name for the observation
320
339
  # @param attrs [Hash, Types::SpanAttributes, Types::GenerationAttributes, nil] Observation attributes
321
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.
322
343
  # @param parent_span_context [OpenTelemetry::Trace::SpanContext, nil] Parent span context for child observations
323
344
  # @param start_time [Time, Integer, nil] Optional start time (Time object or Unix timestamp in nanoseconds)
324
345
  # @param skip_validation [Boolean] Skip validation (for internal use). Defaults to false.
325
346
  # @return [BaseObservation] The observation wrapper (Span, Generation, or Event)
326
- # @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
327
349
  #
328
350
  # @example Create root span
329
351
  # span = Langfuse.start_observation("root-operation", { input: {...} })
@@ -332,14 +354,16 @@ module Langfuse
332
354
  # child = Langfuse.start_observation("llm-call", { model: "gpt-4" },
333
355
  # as_type: :generation,
334
356
  # parent_span_context: parent.otel_span.context)
335
- def start_observation(name, attrs = {}, as_type: :span, parent_span_context: nil, start_time: nil,
336
- 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)
337
365
  type_str = as_type.to_s
338
-
339
- unless skip_validation || valid_observation_type?(as_type)
340
- valid_types = OBSERVATION_TYPES.values.sort.join(", ")
341
- raise ArgumentError, "Invalid observation type: #{type_str}. Valid types: #{valid_types}"
342
- end
366
+ validate_observation_type!(as_type, type_str) unless skip_validation
343
367
 
344
368
  otel_tracer = otel_tracer()
345
369
  otel_span = create_otel_span(
@@ -348,32 +372,27 @@ module Langfuse
348
372
  parent_span_context: parent_span_context,
349
373
  otel_tracer: otel_tracer
350
374
  )
375
+ apply_observation_attributes(otel_span, type_str, attrs)
351
376
 
352
- # Serialize attributes
353
- # Only set attributes if span is still recording (should always be true here, but guard for safety)
354
- if otel_span.recording?
355
- otel_attrs = OtelAttributes.create_observation_attributes(type_str, attrs.to_h, mask: configuration.mask)
356
- otel_attrs.each { |key, value| otel_span.set_attribute(key, value) }
357
- end
358
-
359
- # Wrap in appropriate class (attributes already set on span above — pass nil to avoid double-masking)
360
377
  observation = wrap_otel_span(otel_span, type_str, otel_tracer)
361
-
362
378
  # Events auto-end immediately when created
363
379
  observation.end if type_str == OBSERVATION_TYPES[:event]
364
-
365
380
  observation
366
381
  end
382
+ # rubocop:enable Metrics/ParameterLists
367
383
 
368
384
  # User-facing convenience method for creating root observations
369
385
  #
370
386
  # @param name [String] Descriptive name for the observation
371
387
  # @param attrs [Hash] Observation attributes (optional positional or keyword)
372
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}.
373
391
  # @param kwargs [Hash] Additional keyword arguments merged into observation attributes (e.g., input:, output:, metadata:)
374
392
  # @yield [observation] Optional block that receives the observation object
375
393
  # @yieldparam observation [BaseObservation] The observation object
376
394
  # @return [BaseObservation, Object] The observation (or block return value if block given)
395
+ # @raise [ArgumentError] if an invalid `trace_id` is provided
377
396
  #
378
397
  # @example Block-based API (auto-ends)
379
398
  # Langfuse.observe("operation") do |obs|
@@ -385,28 +404,12 @@ module Langfuse
385
404
  # obs = Langfuse.observe("operation", input: { data: "test" })
386
405
  # obs.update(output: { result: "success" })
387
406
  # obs.end
388
- def observe(name, attrs = {}, as_type: :span, **kwargs, &block)
389
- # Merge positional attrs and keyword kwargs
407
+ def observe(name, attrs = {}, as_type: :span, trace_id: nil, **kwargs, &block)
390
408
  merged_attrs = attrs.to_h.merge(kwargs)
391
- observation = start_observation(name, merged_attrs, as_type: as_type)
392
-
393
- if block
394
- # Block-based API: auto-ends when block completes
395
- # Set context and execute block
396
- current_context = OpenTelemetry::Context.current
397
- result = OpenTelemetry::Context.with_current(
398
- OpenTelemetry::Trace.context_with_span(observation.otel_span, parent_context: current_context)
399
- ) do
400
- block.call(observation)
401
- end
402
- # Only end if not already ended (events auto-end in start_observation)
403
- observation.end unless as_type.to_s == OBSERVATION_TYPES[:event]
404
- result
405
- else
406
- # Stateful API - return observation
407
- # Events already auto-ended in start_observation
408
- observation
409
- 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)
410
413
  end
411
414
 
412
415
  # Registry mapping observation type strings to their wrapper classes
@@ -425,6 +428,31 @@ module Langfuse
425
428
 
426
429
  private
427
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
+
428
456
  # Validates that an observation type is valid
429
457
  #
430
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.6.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
@@ -183,6 +184,7 @@ files:
183
184
  - lib/langfuse/stale_while_revalidate.rb
184
185
  - lib/langfuse/text_prompt_client.rb
185
186
  - lib/langfuse/timestamp_parser.rb
187
+ - lib/langfuse/trace_id.rb
186
188
  - lib/langfuse/traced_execution.rb
187
189
  - lib/langfuse/types.rb
188
190
  - lib/langfuse/version.rb
@@ -194,6 +196,7 @@ metadata:
194
196
  source_code_uri: https://github.com/simplepractice/langfuse-rb
195
197
  changelog_uri: https://github.com/simplepractice/langfuse-rb/blob/main/CHANGELOG.md
196
198
  rubygems_mfa_required: 'true'
199
+ post_install_message:
197
200
  rdoc_options: []
198
201
  require_paths:
199
202
  - lib
@@ -208,7 +211,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
208
211
  - !ruby/object:Gem::Version
209
212
  version: '0'
210
213
  requirements: []
211
- rubygems_version: 4.0.3
214
+ rubygems_version: 3.4.1
215
+ signing_key:
212
216
  specification_version: 4
213
217
  summary: Ruby SDK for Langfuse - LLM observability and prompt management
214
218
  test_files: []