langfuse-rb 0.6.0 → 0.8.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: ba92036fbe7b63b8355a113e44ff3f62cfcb11817ae3b3c1c444756af55ebf84
4
+ data.tar.gz: 44097d4d441ad6d2d8780a95cb836d0368ed0b772f16036e301ece7e018df0a1
5
5
  SHA512:
6
- metadata.gz: d18c3cb807c759c20f72a4259ccfb0502f1800b671aadcf14ea24f88e0be227ed200677f1cbabef22e467c06f2f1ce5f1cf0a0d1a379e14fe29327fce5597ec0
7
- data.tar.gz: ff132aea8911b044a43de1a9abe1e54f3a680069b3b480f2e8109953600600b80abe4b923a13d3672f22ff1fe5c4ab3aef93246f2a9946d1c4f593495228ac0f
6
+ metadata.gz: 6e8880c58683dc7ff719f0c966515841e9a3e3df024f88175297090949a6befaddf23528b24a65d3825fa40e33118348087c0dbe5312543c16bfef65856eaf12
7
+ data.tar.gz: a82f8be469125e99355c5b6a1bd747a1f2e395dd6e0eb098f8319a0976419dfd60087c6252e26c2570db15994ed084d3156069019541c3b32acc7f1827c92792
data/CHANGELOG.md CHANGED
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.8.0] - 2026-04-24
11
+
12
+ ### Added
13
+ - Probabilistic trace sampling with score parity (#60)
14
+ - Dataset run lifecycle methods: `get_dataset_run`, `list_dataset_runs`, `delete_dataset_run` (#62)
15
+
16
+ ### Fixed
17
+ - Tracing is now isolated-by-default with lazy setup and smart export filtering (#77)
18
+
19
+ ### Documentation
20
+ - Align docs with implementation (#78)
21
+
22
+ ## [0.7.0] - 2026-04-14
23
+
24
+ ### Added
25
+ - Custom/deterministic trace ID support (#74)
26
+
27
+ ### Fixed
28
+ - Bump faraday, json, and addressable to patch CVEs (#75)
29
+
30
+ ### Documentation
31
+ - Align docs with implementation (#70, #76)
32
+
10
33
  ## [0.6.0] - 2026-03-06
11
34
 
12
35
  ### Added
@@ -69,7 +92,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
69
92
  - OpenTelemetry-based tracing with OTLP export
70
93
  - Distributed caching with Rails.cache backend and stampede protection
71
94
  - Prompt management (text and chat) with Mustache templating
72
- - In-memory caching with TTL and LRU eviction
95
+ - In-memory caching with TTL and bounded expiration-ordered eviction
73
96
  - Fallback prompt support
74
97
  - Global configuration pattern with `Langfuse.configure`
75
98
 
@@ -77,7 +100,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77
100
  - Migrated from legacy ingestion API to OTLP endpoint
78
101
  - Removed `tracing_enabled` configuration flag (#2)
79
102
 
80
- [Unreleased]: https://github.com/simplepractice/langfuse-rb/compare/v0.6.0...HEAD
103
+ [Unreleased]: https://github.com/simplepractice/langfuse-rb/compare/v0.8.0...HEAD
104
+ [0.8.0]: https://github.com/simplepractice/langfuse-rb/compare/v0.7.0...v0.8.0
105
+ [0.7.0]: https://github.com/simplepractice/langfuse-rb/compare/v0.6.0...v0.7.0
81
106
  [0.6.0]: https://github.com/simplepractice/langfuse-rb/compare/v0.5.0...v0.6.0
82
107
  [0.5.0]: https://github.com/simplepractice/langfuse-rb/compare/v0.4.0...v0.5.0
83
108
  [0.4.0]: https://github.com/simplepractice/langfuse-rb/compare/v0.3.0...v0.4.0
data/README.md CHANGED
@@ -8,69 +8,49 @@
8
8
 
9
9
  > Ruby SDK for [Langfuse](https://langfuse.com) - Open-source LLM observability and prompt management.
10
10
 
11
- <br>
12
-
13
- ### Features
14
-
15
- - 🎯 **Prompt Management** - Centralized prompt versioning with Mustache templating
16
- - 📊 **LLM Tracing** - Zero-boilerplate observability built on OpenTelemetry
17
- - ⚡ **Performance** - In-memory or Redis-backed caching with stampede protection, both supporting stale-while-revalidate cache strategy
18
- - 💬 **Chat & Text Prompts** - First-class support for both formats
19
- - 🔄 **Automatic Retries** - Built-in exponential backoff for resilient API calls
20
- - 🛡️ **Fallback Support** - Graceful degradation when API unavailable
21
- - 🚀 **Rails-Friendly** - Global configuration pattern, works with any Ruby project
22
-
23
- <br>
24
-
25
- ### Installation
11
+ ## Installation
26
12
 
27
13
  ```ruby
28
- # Add to Gemfile & bundle install
29
- gem 'langfuse-rb'
14
+ gem "langfuse-rb"
30
15
  ```
31
16
 
32
- <br>
33
-
34
- ### Quick Start
35
-
36
- > Configure once at startup
17
+ ## Quick Start
37
18
 
38
19
  ```ruby
39
- # config/initializers/langfuse.rb (Rails)
40
- # Or at the top of your script
41
20
  Langfuse.configure do |config|
42
- config.public_key = ENV['LANGFUSE_PUBLIC_KEY']
43
- config.secret_key = ENV['LANGFUSE_SECRET_KEY']
44
- # Optional: for self-hosted instances
45
- config.base_url = ENV.fetch('LANGFUSE_BASE_URL', 'https://cloud.langfuse.com')
46
-
47
- # Optional: Enable stale-while-revalidate for best performance
48
- config.cache_backend = :rails # or :memory
49
- config.cache_stale_while_revalidate = true
21
+ config.public_key = ENV["LANGFUSE_PUBLIC_KEY"]
22
+ config.secret_key = ENV["LANGFUSE_SECRET_KEY"]
23
+ config.base_url = ENV.fetch("LANGFUSE_BASE_URL", "https://cloud.langfuse.com")
24
+
25
+ # Optional: sample traces and trace-linked scores deterministically
26
+ config.sample_rate = 1.0
50
27
  end
28
+
29
+ message = Langfuse.client.compile_prompt(
30
+ "greeting",
31
+ variables: { name: "Alice" }
32
+ )
51
33
  ```
52
34
 
53
- > Fetch and use a prompt
35
+ Langfuse tracing is isolated by default. `Langfuse.configure` stores configuration only; it does not replace `OpenTelemetry.tracer_provider`.
54
36
 
55
- ```ruby
56
- prompt = Langfuse.client.get_prompt("greeting")
57
- message = prompt.compile(name: "Alice")
58
- # => "Hello Alice!"
59
- ```
37
+ `sample_rate` is applied to traces and trace-linked scores. Rebuild the client with `Langfuse.reset!` before expecting runtime sampling changes to take effect.
60
38
 
61
- > Trace an LLM call
39
+ ## Trace an LLM Call
62
40
 
63
41
  ```ruby
64
42
  Langfuse.observe("chat-completion", as_type: :generation) do |gen|
43
+ gen.model = "gpt-4.1-mini"
44
+ gen.input = [{ role: "user", content: "Hello!" }]
45
+
65
46
  response = openai_client.chat(
66
47
  parameters: {
67
- model: "gpt-4",
48
+ model: "gpt-4.1-mini",
68
49
  messages: [{ role: "user", content: "Hello!" }]
69
50
  }
70
51
  )
71
52
 
72
53
  gen.update(
73
- model: "gpt-4",
74
54
  output: response.dig("choices", 0, "message", "content"),
75
55
  usage_details: {
76
56
  prompt_tokens: response.dig("usage", "prompt_tokens"),
@@ -80,39 +60,14 @@ Langfuse.observe("chat-completion", as_type: :generation) do |gen|
80
60
  end
81
61
  ```
82
62
 
83
- > [!IMPORTANT]
84
- > For complete reference see [docs](./docs/) section.
85
-
86
- <br>
87
-
88
- ### Requirements
89
-
90
- - Ruby >= 3.2.0
91
- - No Rails dependency (works with any Ruby project)
92
-
93
- <br>
94
-
95
- ### Contributing
96
-
97
- We welcome contributions! Please:
98
-
99
- 1. Check existing [issues](https://github.com/simplepractice/langfuse-rb/issues)
100
- 2. Open an issue to discuss your idea
101
- 3. Fork the repo and create a feature branch
102
- 4. Write tests (maintain >95% coverage)
103
- 5. Ensure `bundle exec rspec` and `bundle exec rubocop` pass
104
- 6. Submit a pull request
105
-
106
- > [!TIP]
107
- > See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines.
108
-
109
- <br>
110
-
111
- ### Support
63
+ ## Start Here
112
64
 
113
- - **[GitHub Issues](https://github.com/simplepractice/langfuse-rb/issues)** - Bug reports and feature requests
114
- - **[Langfuse Documentation](https://langfuse.com/docs)** - Platform documentation
115
- - **[API Reference](https://api.reference.langfuse.com)** - REST API reference
65
+ - [Documentation Hub](docs/README.md)
66
+ - [Getting Started](docs/GETTING_STARTED.md)
67
+ - [Prompts](docs/PROMPTS.md)
68
+ - [Tracing](docs/TRACING.md)
69
+ - [Scoring](docs/SCORING.md)
70
+ - [Rails Patterns](docs/RAILS.md)
116
71
 
117
72
  ## License
118
73
 
@@ -260,6 +260,61 @@ module Langfuse
260
260
  end
261
261
  end
262
262
 
263
+ # Fetch a dataset run by dataset and run name
264
+ #
265
+ # @param dataset_name [String] Dataset name (required)
266
+ # @param run_name [String] Run name (required)
267
+ # @return [Hash] The dataset run data
268
+ # @raise [NotFoundError] if the dataset run is not found
269
+ # @raise [UnauthorizedError] if authentication fails
270
+ # @raise [ApiError] for other API errors
271
+ def get_dataset_run(dataset_name:, run_name:)
272
+ with_faraday_error_handling do
273
+ response = connection.get(dataset_run_path(dataset_name: dataset_name, run_name: run_name))
274
+ handle_response(response)
275
+ end
276
+ end
277
+
278
+ # List dataset runs in a dataset
279
+ #
280
+ # @param dataset_name [String] Dataset name (required)
281
+ # @param page [Integer, nil] Optional page number for pagination
282
+ # @param limit [Integer, nil] Optional limit per page
283
+ # @return [Array<Hash>] Array of dataset run hashes
284
+ # @raise [UnauthorizedError] if authentication fails
285
+ # @raise [ApiError] for other API errors
286
+ def list_dataset_runs(dataset_name:, page: nil, limit: nil)
287
+ result = list_dataset_runs_paginated(dataset_name: dataset_name, page: page, limit: limit)
288
+ result["data"] || []
289
+ end
290
+
291
+ # Full paginated response including "meta" for internal pagination use
292
+ #
293
+ # @api private
294
+ # @return [Hash] Full response hash with "data" array and "meta" pagination info
295
+ def list_dataset_runs_paginated(dataset_name:, page: nil, limit: nil)
296
+ with_faraday_error_handling do
297
+ response = connection.get(dataset_runs_path(dataset_name), build_dataset_runs_params(page: page, limit: limit))
298
+ handle_response(response)
299
+ end
300
+ end
301
+
302
+ # Delete a dataset run by name
303
+ #
304
+ # @param dataset_name [String] Dataset name (required)
305
+ # @param run_name [String] Run name (required)
306
+ # @return [Hash, nil] Response body, or nil for 204 responses
307
+ # @raise [NotFoundError] if the dataset run is not found
308
+ # @raise [UnauthorizedError] if authentication fails
309
+ # @raise [ApiError] for other API errors
310
+ # @note 404 responses raise NotFoundError to preserve strict delete semantics
311
+ def delete_dataset_run(dataset_name:, run_name:)
312
+ with_faraday_error_handling do
313
+ response = connection.delete(dataset_run_path(dataset_name: dataset_name, run_name: run_name))
314
+ response.status == 204 ? nil : handle_response(response)
315
+ end
316
+ end
317
+
263
318
  # Fetch projects accessible with the current API keys
264
319
  #
265
320
  # @return [Hash] The parsed response body containing project data
@@ -604,6 +659,23 @@ module Langfuse
604
659
  }.compact
605
660
  end
606
661
 
662
+ # Build params for list_dataset_runs
663
+ def build_dataset_runs_params(page:, limit:)
664
+ { page: page, limit: limit }.compact
665
+ end
666
+
667
+ # Build endpoint path for dataset runs
668
+ def dataset_runs_path(dataset_name)
669
+ encoded_name = URI.encode_uri_component(dataset_name)
670
+ "/api/public/datasets/#{encoded_name}/runs"
671
+ end
672
+
673
+ # Build endpoint path for a specific dataset run
674
+ def dataset_run_path(dataset_name:, run_name:)
675
+ encoded_run_name = URI.encode_uri_component(run_name)
676
+ "#{dataset_runs_path(dataset_name)}/#{encoded_run_name}"
677
+ end
678
+
607
679
  # Build query params for list_traces, mapping snake_case to camelCase
608
680
  # rubocop:disable Metrics/ParameterLists
609
681
  def build_traces_params(page:, limit:, user_id:, name:, session_id:,
@@ -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
@@ -588,6 +588,51 @@ module Langfuse
588
588
  )
589
589
  end
590
590
 
591
+ # Fetch a dataset run by dataset and run name
592
+ #
593
+ # @param dataset_name [String] Dataset name (required)
594
+ # @param run_name [String] Run name (required)
595
+ # @return [Hash] The dataset run data, including linked run items
596
+ # @raise [NotFoundError] if the dataset run is not found
597
+ # @raise [UnauthorizedError] if authentication fails
598
+ # @raise [ApiError] for other API errors
599
+ def get_dataset_run(dataset_name:, run_name:)
600
+ api_client.get_dataset_run(dataset_name: dataset_name, run_name: run_name)
601
+ end
602
+
603
+ # List dataset runs for a dataset
604
+ #
605
+ # When page is nil (default), auto-paginates to fetch all runs.
606
+ # When page is provided, returns only that single page.
607
+ #
608
+ # @param dataset_name [String] Dataset name (required)
609
+ # @param page [Integer, nil] Optional page number for pagination
610
+ # @param limit [Integer, nil] Optional limit per page
611
+ # @return [Array<Hash>] Array of dataset run hashes
612
+ # @raise [UnauthorizedError] if authentication fails
613
+ # @raise [ApiError] for other API errors
614
+ def list_dataset_runs(dataset_name:, page: nil, limit: nil)
615
+ if page
616
+ fetch_dataset_runs_page(dataset_name: dataset_name, page: page, limit: limit)
617
+ else
618
+ fetch_all_dataset_runs(dataset_name: dataset_name, limit: limit)
619
+ end
620
+ end
621
+
622
+ # Delete a dataset run by name
623
+ #
624
+ # @param dataset_name [String] Dataset name (required)
625
+ # @param run_name [String] Run name (required)
626
+ # @return [nil]
627
+ # @raise [NotFoundError] if the dataset run is not found
628
+ # @raise [UnauthorizedError] if authentication fails
629
+ # @raise [ApiError] for other API errors
630
+ # @note 404 responses raise NotFoundError to preserve strict delete semantics
631
+ def delete_dataset_run(dataset_name:, run_name:)
632
+ api_client.delete_dataset_run(dataset_name: dataset_name, run_name: run_name)
633
+ nil
634
+ end
635
+
591
636
  # Run an experiment against local data or a named dataset
592
637
  #
593
638
  # @param name [String] Experiment/run name (required)
@@ -656,17 +701,35 @@ module Langfuse
656
701
  end
657
702
 
658
703
  def fetch_all_dataset_items(limit:, **filters)
659
- per_page = limit || DATASET_ITEMS_PAGE_SIZE
660
- first_result = api_client.list_dataset_items_paginated(page: 1, limit: per_page, **filters)
661
- items = first_result["data"] || []
704
+ fetch_all_paginated_data(limit: limit) do |page:, per_page:|
705
+ api_client.list_dataset_items_paginated(page: page, limit: per_page, **filters)
706
+ end
707
+ end
708
+
709
+ def fetch_dataset_runs_page(dataset_name:, page:, limit:)
710
+ api_client.list_dataset_runs(dataset_name: dataset_name, page: page, limit: limit)
711
+ end
712
+
713
+ def fetch_all_dataset_runs(dataset_name:, limit:)
714
+ fetch_all_paginated_data(limit: limit) do |page:, per_page:|
715
+ api_client.list_dataset_runs_paginated(dataset_name: dataset_name, page: page, limit: per_page)
716
+ end
717
+ end
718
+
719
+ # Keep dataset collection pagination semantics in one place so edge cases
720
+ # like missing or zero totalPages stay consistent across APIs.
721
+ def fetch_all_paginated_data(limit:)
722
+ page_size = limit || DATASET_ITEMS_PAGE_SIZE
723
+ first_result = yield(page: 1, per_page: page_size)
724
+ records = first_result["data"] || []
662
725
  total_pages = first_result.dig("meta", "totalPages") || 1
663
726
 
664
- (2..total_pages).each do |pg|
665
- result = api_client.list_dataset_items_paginated(page: pg, limit: per_page, **filters)
666
- items.concat(result["data"] || [])
727
+ (2..total_pages).each do |page|
728
+ result = yield(page: page, per_page: page_size)
729
+ records.concat(result["data"] || [])
667
730
  end
668
731
 
669
- items
732
+ records
670
733
  end
671
734
 
672
735
  def resolve_experiment_items(data, dataset_name)
@@ -18,6 +18,7 @@ module Langfuse
18
18
  # c.secret_key = "sk_..."
19
19
  # end
20
20
  #
21
+ # rubocop:disable Metrics/ClassLength
21
22
  class Config
22
23
  # @return [String, nil] Langfuse public API key
23
24
  attr_accessor :public_key
@@ -74,6 +75,12 @@ module Langfuse
74
75
  # @return [String, nil] Default release identifier applied to new traces/observations
75
76
  attr_accessor :release
76
77
 
78
+ # @return [Float] Trace sampling rate from 0.0 to 1.0
79
+ attr_reader :sample_rate
80
+
81
+ # @return [#call, nil] Callback that decides whether an ended span should export to Langfuse.
82
+ attr_accessor :should_export_span
83
+
77
84
  # @return [#call, nil] Mask callable applied to input, output, and metadata before serialization.
78
85
  # Receives `data:` keyword argument. nil disables masking.
79
86
  attr_accessor :mask
@@ -114,6 +121,9 @@ module Langfuse
114
121
  # @return [Symbol] Default ActiveJob queue name
115
122
  DEFAULT_JOB_QUEUE = :default
116
123
 
124
+ # @return [Float] Default trace sampling rate (sample all traces)
125
+ DEFAULT_SAMPLE_RATE = 1.0
126
+
117
127
  # @return [Integer] Number of seconds representing indefinite cache duration (~1000 years)
118
128
  INDEFINITE_SECONDS = 1000 * 365 * 24 * 60 * 60
119
129
 
@@ -136,7 +146,6 @@ module Langfuse
136
146
  # @yield [config] Optional block for configuration
137
147
  # @yieldparam config [Config] The config instance
138
148
  # @return [Config] a new Config instance
139
- # rubocop:disable Metrics/AbcSize
140
149
  def initialize
141
150
  @public_key = ENV.fetch("LANGFUSE_PUBLIC_KEY", nil)
142
151
  @secret_key = ENV.fetch("LANGFUSE_SECRET_KEY", nil)
@@ -153,14 +162,11 @@ module Langfuse
153
162
  @batch_size = DEFAULT_BATCH_SIZE
154
163
  @flush_interval = DEFAULT_FLUSH_INTERVAL
155
164
  @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
165
+ initialize_tracing_defaults
159
166
  @logger = default_logger
160
167
 
161
168
  yield(self) if block_given?
162
169
  end
163
- # rubocop:enable Metrics/AbcSize
164
170
 
165
171
  # Validate the configuration
166
172
  #
@@ -183,7 +189,8 @@ module Langfuse
183
189
  validate_swr_config!
184
190
 
185
191
  validate_cache_backend!
186
-
192
+ validate_sample_rate!
193
+ validate_should_export_span!
187
194
  validate_mask!
188
195
  end
189
196
  # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
@@ -205,6 +212,15 @@ module Langfuse
205
212
  cache_stale_ttl == :indefinite ? INDEFINITE_SECONDS : cache_stale_ttl
206
213
  end
207
214
 
215
+ # Set trace sampling rate.
216
+ #
217
+ # @param value [Numeric, String] Sampling rate from 0.0 to 1.0
218
+ # @raise [ConfigurationError] if value is non-numeric or outside 0.0..1.0
219
+ # @return [Float]
220
+ def sample_rate=(value)
221
+ @sample_rate = coerce_sample_rate(value)
222
+ end
223
+
208
224
  private
209
225
 
210
226
  def default_logger
@@ -215,6 +231,14 @@ module Langfuse
215
231
  end
216
232
  end
217
233
 
234
+ def initialize_tracing_defaults
235
+ @environment = env_value("LANGFUSE_TRACING_ENVIRONMENT")
236
+ @release = env_value("LANGFUSE_RELEASE") || detect_release_from_ci_env
237
+ self.sample_rate = env_value("LANGFUSE_SAMPLE_RATE") || DEFAULT_SAMPLE_RATE
238
+ @should_export_span = nil
239
+ @mask = nil
240
+ end
241
+
218
242
  def validate_cache_backend!
219
243
  valid_backends = %i[memory rails]
220
244
  return if valid_backends.include?(cache_backend)
@@ -255,12 +279,24 @@ module Langfuse
255
279
  raise ConfigurationError, "cache_refresh_threads must be positive"
256
280
  end
257
281
 
282
+ def validate_sample_rate!
283
+ return if sample_rate.is_a?(Numeric) && sample_rate.between?(0.0, 1.0)
284
+
285
+ raise ConfigurationError, "sample_rate must be between 0.0 and 1.0"
286
+ end
287
+
258
288
  def validate_mask!
259
289
  return if mask.nil? || mask.respond_to?(:call)
260
290
 
261
291
  raise ConfigurationError, "mask must respond to #call"
262
292
  end
263
293
 
294
+ def validate_should_export_span!
295
+ return if should_export_span.nil? || should_export_span.respond_to?(:call)
296
+
297
+ raise ConfigurationError, "should_export_span must respond to #call"
298
+ end
299
+
264
300
  def detect_release_from_ci_env
265
301
  COMMON_RELEASE_ENV_KEYS.each do |key|
266
302
  value = env_value(key)
@@ -276,5 +312,22 @@ module Langfuse
276
312
 
277
313
  value
278
314
  end
315
+
316
+ def coerce_sample_rate(value)
317
+ numeric_value = if value.is_a?(Numeric)
318
+ value.to_f
319
+ elsif value.is_a?(String)
320
+ Float(value)
321
+ else
322
+ raise ConfigurationError, "sample_rate must be numeric"
323
+ end
324
+
325
+ return numeric_value if numeric_value.between?(0.0, 1.0)
326
+
327
+ raise ConfigurationError, "sample_rate must be between 0.0 and 1.0"
328
+ rescue ArgumentError, TypeError
329
+ raise ConfigurationError, "sample_rate must be numeric"
330
+ end
279
331
  end
332
+ # rubocop:enable Metrics/ClassLength
280
333
  end
@@ -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.