langfuse-rb 0.7.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: 2bf0fdf8f1b31f237397c90d8db3fc61c40bc8a69e20111a949aa0bdffc8dd3e
4
- data.tar.gz: b6b83329218d23b3ebd53562a4eb5bb1cd01179f07fdbf5d90b2b1c97fc192ef
3
+ metadata.gz: ba92036fbe7b63b8355a113e44ff3f62cfcb11817ae3b3c1c444756af55ebf84
4
+ data.tar.gz: 44097d4d441ad6d2d8780a95cb836d0368ed0b772f16036e301ece7e018df0a1
5
5
  SHA512:
6
- metadata.gz: a0bda13a371bcc93d37d4ae17548f04c65f1cc8fdf4982756eebac468d280f5b3a44d51ecb5ed5406e2ffee204a958988b3423860916471d8eb1a9e728fcf51c
7
- data.tar.gz: a2bebff2e84e94c5fa30b6774c89c35f63ed4d2af214a76348a370ddbd9326ff0c8346fac9b3601e9bf9427bdb56f73a3f309f7eb71d439bd31b70426461ae3d
6
+ metadata.gz: 6e8880c58683dc7ff719f0c966515841e9a3e3df024f88175297090949a6befaddf23528b24a65d3825fa40e33118348087c0dbe5312543c16bfef65856eaf12
7
+ data.tar.gz: a82f8be469125e99355c5b6a1bd747a1f2e395dd6e0eb098f8319a0976419dfd60087c6252e26c2570db15994ed084d3156069019541c3b32acc7f1827c92792
data/CHANGELOG.md CHANGED
@@ -7,6 +7,18 @@ 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
+
10
22
  ## [0.7.0] - 2026-04-14
11
23
 
12
24
  ### Added
@@ -88,7 +100,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
100
  - Migrated from legacy ingestion API to OTLP endpoint
89
101
  - Removed `tracing_enabled` configuration flag (#2)
90
102
 
91
- [Unreleased]: https://github.com/simplepractice/langfuse-rb/compare/v0.7.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
92
105
  [0.7.0]: https://github.com/simplepractice/langfuse-rb/compare/v0.6.0...v0.7.0
93
106
  [0.6.0]: https://github.com/simplepractice/langfuse-rb/compare/v0.5.0...v0.6.0
94
107
  [0.5.0]: https://github.com/simplepractice/langfuse-rb/compare/v0.4.0...v0.5.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:,
@@ -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
@@ -2,104 +2,78 @@
2
2
 
3
3
  require "opentelemetry/sdk"
4
4
  require "opentelemetry/exporter/otlp"
5
- require "opentelemetry/trace/propagation/trace_context"
6
5
  require "base64"
7
6
 
8
7
  module Langfuse
9
- # OpenTelemetry initialization and setup
10
- #
11
- # Handles configuration of the OTel SDK with Langfuse OTLP exporter
12
- # when tracing is enabled.
13
- #
8
+ # OpenTelemetry initialization and setup for Langfuse tracing.
9
+ # rubocop:disable Metrics/ModuleLength
14
10
  module OtelSetup
11
+ TRACING_CONFIG_FIELDS = %i[
12
+ public_key
13
+ secret_key
14
+ base_url
15
+ environment
16
+ release
17
+ sample_rate
18
+ should_export_span
19
+ tracing_async
20
+ batch_size
21
+ flush_interval
22
+ ].freeze
23
+ private_constant(:TRACING_CONFIG_FIELDS)
24
+
15
25
  class << self
16
- # @return [OpenTelemetry::SDK::Trace::TracerProvider, nil] The configured tracer provider
26
+ # @return [OpenTelemetry::SDK::Trace::TracerProvider, nil] The configured internal tracer provider
17
27
  attr_reader :tracer_provider
18
28
 
19
- # Initialize OpenTelemetry with Langfuse OTLP exporter
29
+ # Initialize Langfuse's internal tracer provider without mutating global OpenTelemetry state.
20
30
  #
21
31
  # @param config [Langfuse::Config] The Langfuse configuration
22
- # @return [void]
23
- # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
32
+ # @return [OpenTelemetry::SDK::Trace::TracerProvider]
24
33
  def setup(config)
25
- # Create OTLP exporter configured for Langfuse
26
- exporter = OpenTelemetry::Exporter::OTLP::Exporter.new(
27
- endpoint: "#{config.base_url}/api/public/otel/v1/traces",
28
- headers: build_headers(config.public_key, config.secret_key),
29
- compression: "gzip"
30
- )
31
-
32
- # Create processor based on async configuration
33
- # IMPORTANT: Always use BatchSpanProcessor (even in sync mode) to ensure spans
34
- # are exported together, which allows proper parent-child relationship detection
35
- processor = if config.tracing_async
36
- # Async: BatchSpanProcessor batches and sends in background
37
- OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(
38
- exporter,
39
- max_queue_size: config.batch_size * 2, # Buffer more than batch_size
40
- schedule_delay: config.flush_interval * 1000, # Convert seconds to milliseconds
41
- max_export_batch_size: config.batch_size
42
- )
43
- else
44
- # Sync: BatchSpanProcessor with minimal delay (flushes on force_flush)
45
- # This collects spans from the same trace and exports them together,
46
- # which is critical for correct parent_observation_id calculation
47
- OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(
48
- exporter,
49
- max_queue_size: config.batch_size * 2,
50
- schedule_delay: 60_000, # 60 seconds (relies on explicit force_flush)
51
- max_export_batch_size: config.batch_size
52
- )
53
- end
54
-
55
- # Create TracerProvider with processor
56
- @tracer_provider = OpenTelemetry::SDK::Trace::TracerProvider.new
57
- @tracer_provider.add_span_processor(processor)
58
-
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)
63
- @tracer_provider.add_span_processor(span_processor)
64
-
65
- # Set as global tracer provider
66
- OpenTelemetry.tracer_provider = @tracer_provider
67
-
68
- # Configure W3C TraceContext propagator if not already set
69
- if OpenTelemetry.propagation.is_a?(OpenTelemetry::Context::Propagation::NoopTextMapPropagator)
70
- OpenTelemetry.propagation = OpenTelemetry::Trace::Propagation::TraceContext::TextMapPropagator.new
71
- config.logger.debug("Langfuse: Configured W3C TraceContext propagator")
72
- else
73
- config.logger.debug("Langfuse: Using existing propagator: #{OpenTelemetry.propagation.class}")
34
+ validate_tracing_config!(config)
35
+ return existing_provider_for(config) if initialized?
36
+
37
+ candidate_provider = nil
38
+ provider = nil
39
+ created = false
40
+ candidate_provider = build_tracer_provider(config)
41
+ provider, created = publish_provider(candidate_provider, tracing_config_snapshot(config))
42
+ unless created
43
+ candidate_provider.shutdown(timeout: 30)
44
+ return existing_provider_for(config)
74
45
  end
75
46
 
76
- mode = config.tracing_async ? "async" : "sync"
77
- config.logger.info("Langfuse tracing initialized with OpenTelemetry (#{mode} mode)")
47
+ log_initialized(config)
48
+ provider
49
+ rescue StandardError
50
+ rollback_provider(provider) if created
51
+ raise
78
52
  end
79
- # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
80
53
 
81
- # Shutdown the tracer provider and flush any pending spans
54
+ # Shutdown the internal tracer provider and flush any pending spans.
82
55
  #
83
56
  # @param timeout [Integer] Timeout in seconds
84
57
  # @return [void]
85
58
  def shutdown(timeout: 30)
86
- return unless @tracer_provider
87
-
88
- @tracer_provider.shutdown(timeout: timeout)
89
- @tracer_provider = nil
59
+ provider = nil
60
+ setup_mutex.synchronize do
61
+ provider = @tracer_provider
62
+ @tracer_provider = nil
63
+ @config_snapshot = nil
64
+ end
65
+ provider&.shutdown(timeout: timeout)
90
66
  end
91
67
 
92
- # Force flush all pending spans
68
+ # Force flush all pending spans on the internal tracer provider.
93
69
  #
94
70
  # @param timeout [Integer] Timeout in seconds
95
71
  # @return [void]
96
72
  def force_flush(timeout: 30)
97
- return unless @tracer_provider
98
-
99
- @tracer_provider.force_flush(timeout: timeout)
73
+ @tracer_provider&.force_flush(timeout: timeout)
100
74
  end
101
75
 
102
- # Check if OTel is initialized
76
+ # Check if Langfuse tracing has been initialized.
103
77
  #
104
78
  # @return [Boolean]
105
79
  def initialized?
@@ -108,18 +82,104 @@ module Langfuse
108
82
 
109
83
  private
110
84
 
111
- # Build HTTP headers for Langfuse OTLP endpoint
112
- #
113
- # @param public_key [String] Langfuse public API key
114
- # @param secret_key [String] Langfuse secret API key
115
- # @return [Hash] HTTP headers with Basic Auth
85
+ def existing_provider_for(config)
86
+ snapshot = tracing_config_snapshot(config)
87
+ if @config_snapshot == snapshot
88
+ config.logger.debug("Langfuse tracing already initialized; reusing existing tracer provider")
89
+ else
90
+ config.logger.warn(
91
+ "Langfuse tracing is already initialized. Changes to #{TRACING_CONFIG_FIELDS.join(', ')} " \
92
+ "require Langfuse.reset! before they take effect."
93
+ )
94
+ end
95
+ @tracer_provider
96
+ end
97
+
98
+ def publish_provider(provider, snapshot)
99
+ created = false
100
+ current = nil
101
+
102
+ # This mutex only guards publication so setup never exposes a half-built provider.
103
+ setup_mutex.synchronize do
104
+ if @tracer_provider
105
+ current = @tracer_provider
106
+ else
107
+ @tracer_provider = provider
108
+ @config_snapshot = snapshot
109
+ current = provider
110
+ created = true
111
+ end
112
+ end
113
+
114
+ [current, created]
115
+ end
116
+
117
+ def rollback_provider(provider)
118
+ setup_mutex.synchronize do
119
+ return unless @tracer_provider.equal?(provider)
120
+
121
+ @tracer_provider = nil
122
+ @config_snapshot = nil
123
+ end
124
+ provider.shutdown(timeout: 1)
125
+ rescue StandardError
126
+ nil
127
+ end
128
+
129
+ def build_tracer_provider(config)
130
+ provider = OpenTelemetry::SDK::Trace::TracerProvider.new(
131
+ sampler: build_sampler(config.sample_rate)
132
+ )
133
+ provider.add_span_processor(
134
+ SpanProcessor.new(config: config, exporter: build_exporter(config))
135
+ )
136
+ provider
137
+ end
138
+
139
+ def build_exporter(config)
140
+ OpenTelemetry::Exporter::OTLP::Exporter.new(
141
+ endpoint: "#{config.base_url}/api/public/otel/v1/traces",
142
+ headers: build_headers(config.public_key, config.secret_key),
143
+ compression: "gzip"
144
+ )
145
+ end
146
+
147
+ def log_initialized(config)
148
+ mode = config.tracing_async ? "async" : "sync"
149
+ config.logger.info("Langfuse tracing initialized with OpenTelemetry (#{mode} mode)")
150
+ end
151
+
152
+ def validate_tracing_config!(config)
153
+ raise ConfigurationError, "public_key is required" if blank?(config.public_key)
154
+ raise ConfigurationError, "secret_key is required" if blank?(config.secret_key)
155
+ raise ConfigurationError, "base_url cannot be empty" if blank?(config.base_url)
156
+ return if config.should_export_span.nil? || config.should_export_span.respond_to?(:call)
157
+
158
+ raise ConfigurationError, "should_export_span must respond to #call"
159
+ end
160
+
161
+ def tracing_config_snapshot(config)
162
+ TRACING_CONFIG_FIELDS.to_h { |field| [field, config.public_send(field)] }.freeze
163
+ end
164
+
165
+ def setup_mutex
166
+ @setup_mutex ||= Mutex.new
167
+ end
168
+
169
+ def blank?(value)
170
+ value.nil? || value.empty?
171
+ end
172
+
116
173
  def build_headers(public_key, secret_key)
117
174
  credentials = "#{public_key}:#{secret_key}"
118
175
  encoded = Base64.strict_encode64(credentials)
119
- {
120
- "Authorization" => "Basic #{encoded}"
121
- }
176
+ { "Authorization" => "Basic #{encoded}" }
177
+ end
178
+
179
+ def build_sampler(sample_rate)
180
+ Sampling.build_sampler(sample_rate) || OpenTelemetry::SDK::Trace::Samplers::ALWAYS_ON
122
181
  end
123
182
  end
124
183
  end
184
+ # rubocop:enable Metrics/ModuleLength
125
185
  end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Langfuse
4
+ # Shared sampling helpers for trace and score emission.
5
+ #
6
+ # @api private
7
+ module Sampling
8
+ module_function
9
+
10
+ # Build the sampler used by both trace export and trace-linked score emission.
11
+ #
12
+ # @param sample_rate [Float] Sampling rate from 0.0 to 1.0
13
+ # @return [OpenTelemetry::SDK::Trace::Samplers::TraceIdRatioBased, nil]
14
+ def build_sampler(sample_rate)
15
+ return nil if sample_rate >= 1.0
16
+
17
+ OpenTelemetry::SDK::Trace::Samplers::TraceIdRatioBased.new(sample_rate)
18
+ end
19
+ end
20
+ end
@@ -31,6 +31,8 @@ module Langfuse
31
31
  # @return [Logger] Logger instance
32
32
  attr_reader :logger
33
33
 
34
+ HEX_TRACE_ID_PATTERN = /\A[0-9a-f]{32}\z/
35
+
34
36
  # Initialize a new ScoreClient
35
37
  #
36
38
  # @param api_client [ApiClient] The API client for sending batches
@@ -43,6 +45,9 @@ module Langfuse
43
45
  @mutex = Mutex.new
44
46
  @flush_thread = nil
45
47
  @shutdown = false
48
+ # Match the immutable tracing setup contract: once this client exists, later config
49
+ # mutations must not change score sampling without rebuilding the client.
50
+ @score_sampler = Sampling.build_sampler(config.sample_rate)
46
51
 
47
52
  start_flush_timer
48
53
  end
@@ -76,28 +81,19 @@ module Langfuse
76
81
  def create(name:, value:, id: nil, trace_id: nil, session_id: nil, observation_id: nil, comment: nil,
77
82
  metadata: nil, environment: nil, data_type: :numeric, dataset_run_id: nil, config_id: nil)
78
83
  validate_name(name)
79
- # Keep identifier policy server-side to preserve cross-SDK parity and avoid blocking valid future payloads.
80
84
  normalized_value = normalize_value(value, data_type)
81
85
  data_type_str = Types::SCORE_DATA_TYPES[data_type] || raise(ArgumentError, "Invalid data_type: #{data_type}")
82
86
 
87
+ return unless enqueue_trace_linked_score?(trace_id)
88
+
83
89
  event = build_score_event(
84
- name: name,
85
- value: normalized_value,
86
- id: id,
87
- trace_id: trace_id,
88
- session_id: session_id,
89
- observation_id: observation_id,
90
- comment: comment,
91
- metadata: metadata,
92
- environment: environment,
93
- data_type: data_type_str,
94
- dataset_run_id: dataset_run_id,
95
- config_id: config_id
90
+ name: name, value: normalized_value, id: id, trace_id: trace_id,
91
+ session_id: session_id, observation_id: observation_id, comment: comment,
92
+ metadata: metadata, environment: environment, data_type: data_type_str,
93
+ dataset_run_id: dataset_run_id, config_id: config_id
96
94
  )
97
95
 
98
96
  @queue << event
99
-
100
- # Trigger flush if batch size reached
101
97
  flush if @queue.size >= config.batch_size
102
98
  rescue StandardError => e
103
99
  logger.error("Langfuse score creation failed: #{e.message}")
@@ -294,14 +290,43 @@ module Langfuse
294
290
  # @return [Hash] Hash with :trace_id and :observation_id (may be nil)
295
291
  def extract_ids_from_active_span
296
292
  span = OpenTelemetry::Trace.current_span
297
- return { trace_id: nil, observation_id: nil } unless span&.recording?
293
+ span_context = span&.context
294
+ return { trace_id: nil, observation_id: nil } unless span_context&.valid?
298
295
 
299
296
  {
300
- trace_id: span.context.trace_id.unpack1("H*"),
301
- observation_id: span.context.span_id.unpack1("H*")
297
+ trace_id: span_context.trace_id.unpack1("H*"),
298
+ observation_id: span_context.span_id.unpack1("H*")
302
299
  }
303
300
  end
304
301
 
302
+ # Score sampling is decided purely by the configured sampler on the trace_id hash,
303
+ # matching langfuse-python. Non-hex trace ids and session/dataset-only scores bypass sampling.
304
+ def enqueue_trace_linked_score?(trace_id)
305
+ return true if trace_id.nil?
306
+ return true unless HEX_TRACE_ID_PATTERN.match?(trace_id)
307
+
308
+ sampler = score_sampler
309
+ return true if sampler.nil?
310
+ return true unless sampler.respond_to?(:should_sample?)
311
+
312
+ sample_result = sampler.should_sample?(
313
+ trace_id: [trace_id].pack("H*"),
314
+ parent_context: nil,
315
+ links: [],
316
+ name: "score",
317
+ kind: OpenTelemetry::Trace::SpanKind::INTERNAL,
318
+ attributes: {}
319
+ )
320
+ sample_result.sampled?
321
+ rescue StandardError => e
322
+ logger.warn("Langfuse score sampling fallback for trace_id=#{trace_id}: #{e.message}")
323
+ true
324
+ end
325
+
326
+ # Sampler is pinned at ScoreClient construction to match the "sample_rate requires reset!"
327
+ # contract and to keep each client's sampling scoped to its own config.
328
+ attr_reader :score_sampler
329
+
305
330
  # Send a batch of events to the API
306
331
  #
307
332
  # @param events [Array<Hash>] Array of event hashes
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Langfuse
4
+ # Instrumentation scope name used by module-level Langfuse tracing.
5
+ LANGFUSE_TRACER_NAME = "langfuse-rb"
6
+
7
+ # Conservative allowlist of instrumentation scope prefixes that clearly belong to LLM workflows.
8
+ KNOWN_LLM_INSTRUMENTATION_SCOPE_PREFIXES = [
9
+ LANGFUSE_TRACER_NAME,
10
+ "agent_framework",
11
+ "ai",
12
+ "haystack",
13
+ "langsmith",
14
+ "litellm",
15
+ "openinference",
16
+ "opentelemetry.instrumentation.anthropic",
17
+ "strands-agents",
18
+ "vllm"
19
+ ].freeze
20
+
21
+ # Matched per span in the export path, so avoid allocating the dotted form each call.
22
+ KNOWN_LLM_INSTRUMENTATION_SCOPE_DOTTED_PREFIXES =
23
+ KNOWN_LLM_INSTRUMENTATION_SCOPE_PREFIXES.map { |prefix| "#{prefix}." }.freeze
24
+ private_constant :KNOWN_LLM_INSTRUMENTATION_SCOPE_DOTTED_PREFIXES
25
+
26
+ class << self
27
+ # Return whether the span was created by Langfuse's tracer.
28
+ #
29
+ # @param span [#instrumentation_scope] Span or span data to inspect
30
+ # @return [Boolean]
31
+ def langfuse_span?(span)
32
+ instrumentation_scope_name(span) == LANGFUSE_TRACER_NAME
33
+ end
34
+
35
+ # Return whether the span contains `gen_ai.*` attributes.
36
+ #
37
+ # @param span [#attributes] Span or span data to inspect
38
+ # @return [Boolean]
39
+ def genai_span?(span)
40
+ attributes = span.attributes
41
+ return false unless attributes
42
+
43
+ attributes.keys.any? { |key| key.is_a?(String) && key.start_with?("gen_ai.") }
44
+ end
45
+
46
+ # Return whether the span came from a known LLM instrumentation scope.
47
+ #
48
+ # @param span [#instrumentation_scope] Span or span data to inspect
49
+ # @return [Boolean]
50
+ def known_llm_instrumentor?(span)
51
+ scope_name = instrumentation_scope_name(span)
52
+ return false unless scope_name
53
+
54
+ return true if KNOWN_LLM_INSTRUMENTATION_SCOPE_PREFIXES.include?(scope_name)
55
+
56
+ KNOWN_LLM_INSTRUMENTATION_SCOPE_DOTTED_PREFIXES.any? do |dotted_prefix|
57
+ scope_name.start_with?(dotted_prefix)
58
+ end
59
+ end
60
+
61
+ # Return whether a span should be exported when no custom filter is configured.
62
+ #
63
+ # @param span [#instrumentation_scope, #attributes] Span or span data to inspect
64
+ # @return [Boolean]
65
+ def default_export_span?(span)
66
+ langfuse_span?(span) || genai_span?(span) || known_llm_instrumentor?(span)
67
+ end
68
+
69
+ # Cross-SDK parity keeps the `is_*` names public for compatibility.
70
+ alias is_langfuse_span langfuse_span?
71
+ alias is_genai_span genai_span?
72
+ alias is_known_llm_instrumentor known_llm_instrumentor?
73
+ alias is_default_export_span default_export_span?
74
+
75
+ private
76
+
77
+ def instrumentation_scope_name(span)
78
+ span.instrumentation_scope&.name
79
+ end
80
+ end
81
+ end
@@ -3,22 +3,26 @@
3
3
  require "opentelemetry/sdk"
4
4
 
5
5
  module Langfuse
6
- # Span processor that applies default and propagated trace attributes on new spans.
7
- #
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.
6
+ # Batch span processor that owns Langfuse's enrichment and export filtering.
12
7
  #
13
8
  # @api private
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)
9
+ class SpanProcessor < OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor
10
+ # @param config [Langfuse::Config] SDK configuration used for defaults and filtering
11
+ # @param exporter [#export, #force_flush, #shutdown] Span exporter used by the batch processor
12
+ def initialize(config:, exporter:)
13
+ @logger = config.logger
17
14
  @default_trace_attributes = build_default_trace_attributes(config).freeze
18
- super()
15
+ @should_export_span = config.should_export_span || Langfuse.method(:default_export_span?)
16
+
17
+ super(
18
+ exporter,
19
+ max_queue_size: config.batch_size * 2,
20
+ schedule_delay: schedule_delay_for(config),
21
+ max_export_batch_size: config.batch_size
22
+ )
19
23
  end
20
24
 
21
- # Called when a span starts
25
+ # Apply Langfuse trace defaults and propagated attributes before a span records work.
22
26
  #
23
27
  # @param span [OpenTelemetry::SDK::Trace::Span] The span that started
24
28
  # @param parent_context [OpenTelemetry::Context] The parent context
@@ -30,41 +34,28 @@ module Langfuse
30
34
  apply_attributes(span, propagated_attributes(parent_context))
31
35
  end
32
36
 
33
- # Called when a span ends
37
+ # Drop spans when the export filter rejects them or raises.
34
38
  #
35
39
  # @param span [OpenTelemetry::SDK::Trace::Span] The span that ended
36
40
  # @return [void]
37
41
  def on_finish(span)
38
- # No-op - we don't need to do anything when spans finish
39
- end
42
+ return unless should_export_span?(span)
40
43
 
41
- # Shutdown the processor
42
- #
43
- # @param timeout [Integer, nil] Timeout in seconds (unused for this processor)
44
- # @return [Integer] Always returns 0 (no timeout needed for no-op)
45
- def shutdown(timeout: nil)
46
- # No-op - nothing to clean up
47
- # Return 0 to match OpenTelemetry SDK expectation (it finds max timeout from processors)
48
- _ = timeout # Suppress unused argument warning
49
- 0
50
- end
51
-
52
- # Force flush (no-op for this processor)
53
- #
54
- # @param timeout [Integer, nil] Timeout in seconds (unused for this processor)
55
- # @return [Integer] Always returns 0 (no timeout needed for no-op)
56
- def force_flush(timeout: nil)
57
- # No-op - nothing to flush
58
- # Return 0 to match OpenTelemetry SDK expectation (it finds max timeout from processors)
59
- _ = timeout # Suppress unused argument warning
60
- 0
44
+ super
61
45
  end
62
46
 
63
47
  private
64
48
 
65
- def build_default_trace_attributes(config)
66
- return {} unless config
49
+ # Sync mode relies on explicit `force_flush` calls, so keep the background flush
50
+ # interval long enough that it rarely fires on its own.
51
+ SYNC_SCHEDULE_DELAY_MS = 60_000
52
+ private_constant :SYNC_SCHEDULE_DELAY_MS
53
+
54
+ def schedule_delay_for(config)
55
+ config.tracing_async ? config.flush_interval * 1000 : SYNC_SCHEDULE_DELAY_MS
56
+ end
67
57
 
58
+ def build_default_trace_attributes(config)
68
59
  OtelAttributes.create_trace_attributes(
69
60
  { environment: config.environment, release: config.release }
70
61
  )
@@ -79,5 +70,15 @@ module Langfuse
79
70
  def apply_attributes(span, attributes)
80
71
  attributes.each { |key, value| span.set_attribute(key, value) }
81
72
  end
73
+
74
+ def should_export_span?(span)
75
+ @should_export_span.call(span)
76
+ rescue StandardError => e
77
+ @logger.error(
78
+ "Langfuse tracing dropped span '#{span.name}' because should_export_span raised: " \
79
+ "#{e.class}: #{e.message}"
80
+ )
81
+ false
82
+ end
82
83
  end
83
84
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Langfuse
4
- VERSION = "0.7.0"
4
+ VERSION = "0.8.0"
5
5
  end
data/lib/langfuse.rb CHANGED
@@ -44,6 +44,8 @@ require_relative "langfuse/prompt_cache"
44
44
  require_relative "langfuse/rails_cache_adapter"
45
45
  require_relative "langfuse/cache_warmer"
46
46
  require_relative "langfuse/api_client"
47
+ require_relative "langfuse/span_filter"
48
+ require_relative "langfuse/sampling"
47
49
  require_relative "langfuse/otel_setup"
48
50
  require_relative "langfuse/masking"
49
51
  require_relative "langfuse/otel_attributes"
@@ -91,10 +93,6 @@ module Langfuse
91
93
  # end
92
94
  def configure
93
95
  yield(configuration)
94
-
95
- # Auto-initialize OpenTelemetry
96
- OtelSetup.setup(configuration)
97
-
98
96
  configuration
99
97
  end
100
98
 
@@ -105,6 +103,28 @@ module Langfuse
105
103
  @client ||= Client.new(configuration)
106
104
  end
107
105
 
106
+ # Return Langfuse's internal tracer provider for explicit global OpenTelemetry installation.
107
+ #
108
+ # @return [OpenTelemetry::SDK::Trace::TracerProvider]
109
+ # @raise [ConfigurationError] if tracing is not fully configured
110
+ #
111
+ # @example
112
+ # Langfuse.configure do |config|
113
+ # config.public_key = ENV["LANGFUSE_PUBLIC_KEY"]
114
+ # config.secret_key = ENV["LANGFUSE_SECRET_KEY"]
115
+ # end
116
+ #
117
+ # OpenTelemetry.tracer_provider = Langfuse.tracer_provider
118
+ def tracer_provider
119
+ unless tracing_config_ready?
120
+ raise ConfigurationError,
121
+ "Langfuse tracing is disabled until public_key, secret_key, and base_url are configured."
122
+ end
123
+
124
+ OtelSetup.setup(configuration) unless OtelSetup.initialized?
125
+ OtelSetup.tracer_provider
126
+ end
127
+
108
128
  # Shutdown Langfuse and flush any pending traces and scores
109
129
  #
110
130
  # Call this when shutting down your application to ensure
@@ -323,10 +343,14 @@ module Langfuse
323
343
  OtelSetup.shutdown(timeout: 5) if OtelSetup.initialized?
324
344
  @configuration = nil
325
345
  @client = nil
346
+ @noop_tracer = nil
347
+ @tracing_disabled_warning_emitted = false
326
348
  rescue StandardError
327
349
  # Ignore shutdown errors during reset (e.g., in tests)
328
350
  @configuration = nil
329
351
  @client = nil
352
+ @noop_tracer = nil
353
+ @tracing_disabled_warning_emitted = false
330
354
  end
331
355
 
332
356
  # Creates a new observation (root or child)
@@ -478,7 +502,10 @@ module Langfuse
478
502
  #
479
503
  # @return [OpenTelemetry::SDK::Trace::Tracer] The OTel tracer
480
504
  def otel_tracer
481
- OpenTelemetry.tracer_provider.tracer("langfuse-rb", Langfuse::VERSION)
505
+ return tracer_provider.tracer(LANGFUSE_TRACER_NAME, Langfuse::VERSION) if setup_tracing_if_ready
506
+
507
+ warn_tracing_disabled_once
508
+ noop_tracer
482
509
  end
483
510
 
484
511
  # Creates an OpenTelemetry span (root or child)
@@ -514,6 +541,47 @@ module Langfuse
514
541
  observation_class = OBSERVATION_TYPE_REGISTRY[type_str] || Span
515
542
  observation_class.new(otel_span, otel_tracer, attributes: attributes)
516
543
  end
544
+
545
+ # rubocop:disable Naming/PredicateMethod
546
+ def setup_tracing_if_ready
547
+ return true if OtelSetup.initialized?
548
+ return false unless tracing_config_ready?
549
+
550
+ OtelSetup.setup(configuration)
551
+ true
552
+ end
553
+ # rubocop:enable Naming/PredicateMethod
554
+
555
+ def tracing_config_ready?
556
+ configured?(configuration.public_key) &&
557
+ configured?(configuration.secret_key) &&
558
+ configured?(configuration.base_url)
559
+ end
560
+
561
+ def configured?(value)
562
+ !value.nil? && !value.empty?
563
+ end
564
+
565
+ def warn_tracing_disabled_once
566
+ return if @tracing_disabled_warning_emitted
567
+
568
+ tracing_warning_mutex.synchronize do
569
+ return if @tracing_disabled_warning_emitted
570
+
571
+ configuration.logger.warn(
572
+ "Langfuse tracing is disabled until public_key, secret_key, and base_url are configured."
573
+ )
574
+ @tracing_disabled_warning_emitted = true
575
+ end
576
+ end
577
+
578
+ def tracing_warning_mutex
579
+ @tracing_warning_mutex ||= Mutex.new
580
+ end
581
+
582
+ def noop_tracer
583
+ @noop_tracer ||= OpenTelemetry::Trace::TracerProvider.new.tracer(LANGFUSE_TRACER_NAME, Langfuse::VERSION)
584
+ end
517
585
  end
518
586
  # rubocop:enable Metrics/ClassLength
519
587
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: langfuse-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - SimplePractice
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-04-14 00:00:00.000000000 Z
11
+ date: 2026-04-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -179,7 +179,9 @@ files:
179
179
  - lib/langfuse/prompt_cache.rb
180
180
  - lib/langfuse/propagation.rb
181
181
  - lib/langfuse/rails_cache_adapter.rb
182
+ - lib/langfuse/sampling.rb
182
183
  - lib/langfuse/score_client.rb
184
+ - lib/langfuse/span_filter.rb
183
185
  - lib/langfuse/span_processor.rb
184
186
  - lib/langfuse/stale_while_revalidate.rb
185
187
  - lib/langfuse/text_prompt_client.rb