diff --git a/CHANGELOG.md b/CHANGELOG.md index e5a2a4f..59ca3a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- `Config#valid?` and `Langfuse.configured?` — non-raising client-config validity predicates. Note: these reflect client configuration validity, not tracing readiness. + +### Changed +- Tracing readiness now also validates `batch_size` (positive Integer) and `flush_interval` (positive number); invalid values disable tracing with a one-time specific warning instead of crashing span-processor setup. `Langfuse.tracer_provider` errors and the tracing-disabled warning now name the specific invalid setting. + +### Fixed +- `Config#validate!` raises `ConfigurationError` instead of leaking `ArgumentError`/`NoMethodError` for non-numeric numeric fields and non-string credentials; string/bogus `cache_stale_ttl` values no longer silently pass validation. + ## [0.10.1] - 2026-05-05 ### Changed diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index b6a2107..a8f5556 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -93,6 +93,33 @@ config = Langfuse.configuration puts config.cache_ttl # => 60 ``` +### `Langfuse.configured?` + +Returns whether the global configuration is valid without raising. + +**Signature:** + +```ruby +Langfuse.configured? # => Boolean +``` + +**Returns:** `true` if configuration passes full validation, `false` otherwise + +**Example:** + +```ruby +Langfuse.configured? # => false + +Langfuse.configure do |config| + config.public_key = ENV["LANGFUSE_PUBLIC_KEY"] + config.secret_key = ENV["LANGFUSE_SECRET_KEY"] +end + +Langfuse.configured? # => true +``` + +`configured?` reflects client configuration validity (credentials, cache, and client settings), not tracing readiness. Tracing settings (`batch_size`, `flush_interval`, `should_export_span`) are validated separately and lazily — if they are invalid, tracing degrades to no-op spans with a one-time warning instead of raising, and `configured?` can still return `true`. + ### `Langfuse.reset!` Reset configuration, caches, and client instance. Primarily for testing. @@ -121,7 +148,7 @@ Return Langfuse's internal tracer provider so you can explicitly install it as t Langfuse.tracer_provider # => OpenTelemetry::SDK::Trace::TracerProvider ``` -**Raises:** `ConfigurationError` if `public_key`, `secret_key`, or `base_url` are not configured +**Raises:** `ConfigurationError` for missing credentials, a non-callable `should_export_span`, or an invalid `batch_size`/`flush_interval`; the message names the offending setting `Langfuse.configure` does not call this for you. This is the explicit global-install seam. If you also want another OpenTelemetry backend or custom propagation, that remains application-owned setup. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 4d06e41..91349af 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -614,16 +614,46 @@ Langfuse.client # => Raises Langfuse::ConfigurationError: "public_key is required" ``` +To check validity without raising, use `Config#valid?` or the module-level `Langfuse.configured?`: + +```ruby +Langfuse.configured? # => false (before configure) + +Langfuse.configure do |config| + config.public_key = ENV["LANGFUSE_PUBLIC_KEY"] + config.secret_key = ENV["LANGFUSE_SECRET_KEY"] +end + +Langfuse.configured? # => true + +# Or directly on a config object: +config = Langfuse::Config.new +config.valid? # => false (no keys set) +``` + +Note: `configured?` and `valid?` reflect client configuration validity, not tracing readiness. Tracing settings (`batch_size`, `flush_interval`, `should_export_span`) are validated separately when tracing is set up — invalid values disable tracing with a one-time warning, while `configured?` can still return `true`. + Validation rules: -- `public_key` must be present -- `secret_key` must be present +- `public_key` must be a non-empty String +- `secret_key` must be a non-empty String +- `base_url` must be a non-empty String +- `timeout`, `cache_max_size`, `cache_lock_timeout`, and `cache_refresh_threads` must be positive numbers +- `cache_ttl` must be a non-negative number +- `cache_stale_ttl` must be a non-negative number or `:indefinite` - `cache_backend` must be `:memory`, `:rails`, or `:auto` - If `:rails` is selected, or `:auto` resolves to `:rails`, Rails and `Rails.cache` must be available - `prompt_cache_observer` must respond to `#call` (if set) - `should_export_span` must respond to `#call` (if set) - `mask` must respond to `#call` (if set) +Tracing readiness (checked lazily when tracing starts, and by `Langfuse.tracer_provider`): + +- `public_key`, `secret_key`, and `base_url` must be non-empty Strings +- `should_export_span` must respond to `#call` (if set) +- `batch_size` must be a positive Integer +- `flush_interval` must be a positive number + ## Accessing Current Configuration ```ruby diff --git a/docs/ERROR_HANDLING.md b/docs/ERROR_HANDLING.md index 82130ad..b7e1019 100644 --- a/docs/ERROR_HANDLING.md +++ b/docs/ERROR_HANDLING.md @@ -34,6 +34,9 @@ Langfuse.configure do |config| # Oops, forgot to set keys end +Langfuse.configured? +# => false + Langfuse.client # => Langfuse::ConfigurationError: public_key is required ``` @@ -45,8 +48,13 @@ Langfuse.configure do |config| config.public_key = ENV['LANGFUSE_PUBLIC_KEY'] config.secret_key = ENV['LANGFUSE_SECRET_KEY'] end + +Langfuse.configured? +# => true ``` +Tracing misconfiguration never raises from `Langfuse.observe` or `Langfuse.start_observation` — invalid tracing settings (missing credentials, bad `batch_size`/`flush_interval`, non-callable `should_export_span`) degrade to no-op spans with a one-time warning naming the offending setting. Only `Langfuse.tracer_provider` and `Client.new` raise `ConfigurationError`. + **Validation checklist:** - `public_key` present and starts with `pk-lf-` - `secret_key` present and starts with `sk-lf-` diff --git a/lib/langfuse.rb b/lib/langfuse.rb index 5e5e28f..6af6199 100644 --- a/lib/langfuse.rb +++ b/lib/langfuse.rb @@ -101,6 +101,15 @@ def configure configuration end + # Returns whether the global configuration is valid + # + # @note Reflects client configuration validity, not tracing readiness — + # tracing settings are validated separately and lazily by {.tracer_provider}. + # @return [Boolean] +true+ if the global configuration is valid, +false+ otherwise + def configured? + configuration.valid? + end + # Returns the global singleton client # # @return [Client] the global client instance @@ -111,7 +120,8 @@ def client # Return Langfuse's internal tracer provider for explicit global OpenTelemetry installation. # # @return [OpenTelemetry::SDK::Trace::TracerProvider] - # @raise [ConfigurationError] if tracing is not fully configured + # @raise [ConfigurationError] if tracing configuration is invalid; the message + # names the offending tracing setting # # @example # Langfuse.configure do |config| @@ -121,13 +131,11 @@ def client # # OpenTelemetry.tracer_provider = Langfuse.tracer_provider def tracer_provider - unless tracing_config_ready? - raise ConfigurationError, - "Langfuse tracing is disabled until public_key, secret_key, and base_url are configured." - end - + configuration.validate_tracing! OtelSetup.setup(configuration) unless OtelSetup.initialized? OtelSetup.tracer_provider + rescue ConfigurationError => e + raise ConfigurationError, tracing_disabled_message(e.message) end # Shutdown Langfuse and flush any pending traces and scores @@ -509,7 +517,6 @@ def valid_observation_type?(type) def otel_tracer return tracer_provider.tracer(LANGFUSE_TRACER_NAME, Langfuse::VERSION) if setup_tracing_if_ready - warn_tracing_disabled_once noop_tracer end @@ -547,39 +554,33 @@ def wrap_otel_span(otel_span, type_str, otel_tracer, attributes: nil) observation_class.new(otel_span, otel_tracer, attributes: attributes) end - # rubocop:disable Naming/PredicateMethod def setup_tracing_if_ready return true if OtelSetup.initialized? - return false unless tracing_config_ready? + # OtelSetup.setup validates tracing config before touching provider state OtelSetup.setup(configuration) true - end - # rubocop:enable Naming/PredicateMethod - - def tracing_config_ready? - configured?(configuration.public_key) && - configured?(configuration.secret_key) && - configured?(configuration.base_url) - end - - def configured?(value) - !value.nil? && !value.empty? + rescue ConfigurationError => e + warn_tracing_disabled_once(e.message) + false end - def warn_tracing_disabled_once + def warn_tracing_disabled_once(detail) return if @tracing_disabled_warning_emitted tracing_warning_mutex.synchronize do return if @tracing_disabled_warning_emitted - configuration.logger.warn( - "Langfuse tracing is disabled until public_key, secret_key, and base_url are configured." - ) + # logger is unvalidated and this path fires precisely when config is known-bad + configuration.logger&.warn(tracing_disabled_message(detail)) @tracing_disabled_warning_emitted = true end end + def tracing_disabled_message(detail) + "Langfuse tracing is disabled: #{detail}" + end + def tracing_warning_mutex @tracing_warning_mutex ||= Mutex.new end diff --git a/lib/langfuse/config.rb b/lib/langfuse/config.rb index 3d98c15..59d9455 100644 --- a/lib/langfuse/config.rb +++ b/lib/langfuse/config.rb @@ -176,29 +176,46 @@ def initialize # # @raise [ConfigurationError] if configuration is invalid # @return [void] - # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity def validate! - raise ConfigurationError, "public_key is required" if public_key.nil? || public_key.empty? - raise ConfigurationError, "secret_key is required" if secret_key.nil? || secret_key.empty? - raise ConfigurationError, "base_url cannot be empty" if base_url.nil? || base_url.empty? - raise ConfigurationError, "timeout must be positive" if timeout.nil? || timeout <= 0 - raise ConfigurationError, "cache_ttl must be non-negative" if cache_ttl.nil? || cache_ttl.negative? - raise ConfigurationError, "cache_max_size must be positive" if cache_max_size.nil? || cache_max_size <= 0 - - if cache_lock_timeout.nil? || cache_lock_timeout <= 0 - raise ConfigurationError, - "cache_lock_timeout must be positive" - end - + validate_connection_settings! + validate_positive_number!("timeout", timeout) + validate_non_negative_number!("cache_ttl", cache_ttl) + validate_positive_number!("cache_max_size", cache_max_size) + validate_positive_number!("cache_lock_timeout", cache_lock_timeout) validate_swr_config! - validate_cache_backend! validate_prompt_cache_observer! validate_sample_rate! validate_should_export_span! validate_mask! end - # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + + # Check whether the configuration is valid without raising. + # + # @return [Boolean] +true+ if valid, +false+ otherwise + def valid? + validate! + + true + rescue Langfuse::ConfigurationError + false + end + + # Validate only the settings tracing setup and export consume. + # + # Deliberately narrower than {#validate!}: invalid cache/client settings + # must never disable tracing (parity with the Python and JS SDKs), and + # tracing-only settings like batch_size never fail client validation. + # + # @api private + # @raise [ConfigurationError] with a message naming the offending tracing setting + # @return [void] + def validate_tracing! + validate_connection_settings! + validate_should_export_span! + validate_batch_size! + validate_flush_interval! + end # Normalize stale_ttl value # @@ -244,6 +261,48 @@ def initialize_tracing_defaults @mask = nil end + # The keys and URL identifying the Langfuse backend — required by both + # client validation and tracing readiness. + def validate_connection_settings! + validate_required_string!("public_key", public_key) + validate_required_string!("secret_key", secret_key) + validate_required_string!("base_url", base_url, message: "base_url cannot be empty") + end + + # Type checks precede comparisons so bad types raise ConfigurationError + # instead of leaking ArgumentError/NoMethodError through #valid?. + def validate_required_string!(name, value, message: "#{name} is required") + return if value.is_a?(String) && !value.empty? + + raise ConfigurationError, message + end + + def validate_positive_number!(name, value) + return if value.is_a?(Numeric) && value.positive? + + raise ConfigurationError, "#{name} must be positive" + end + + def validate_non_negative_number!(name, value) + return if value.is_a?(Numeric) && !value.negative? + + raise ConfigurationError, "#{name} must be non-negative" + end + + # SpanProcessor computes batch_size * 2 and flush_interval * 1000, so both + # must be type-checked before tracing setup to avoid crashes mid-export. + def validate_batch_size! + return if batch_size.is_a?(Integer) && batch_size.positive? + + raise ConfigurationError, "batch_size must be a positive Integer" + end + + def validate_flush_interval! + return if flush_interval.is_a?(Numeric) && flush_interval.positive? + + raise ConfigurationError, "flush_interval must be a positive number" + end + def validate_cache_backend! valid_backends = %i[memory rails auto] return if valid_backends.include?(cache_backend) @@ -277,17 +336,16 @@ def validate_swr_stale_ttl! "cache_stale_ttl must be non-negative or :indefinite" end - # Validate numeric values are non-negative - return unless cache_stale_ttl.is_a?(Integer) && cache_stale_ttl.negative? + # Accept :indefinite or any non-negative numeric value; reject everything else + return if cache_stale_ttl == :indefinite + return if cache_stale_ttl.is_a?(Numeric) && !cache_stale_ttl.negative? raise ConfigurationError, "cache_stale_ttl must be non-negative or :indefinite" end def validate_refresh_threads! - return unless cache_refresh_threads.nil? || cache_refresh_threads <= 0 - - raise ConfigurationError, "cache_refresh_threads must be positive" + validate_positive_number!("cache_refresh_threads", cache_refresh_threads) end def validate_sample_rate! diff --git a/lib/langfuse/otel_setup.rb b/lib/langfuse/otel_setup.rb index e4bfaa1..906bab1 100644 --- a/lib/langfuse/otel_setup.rb +++ b/lib/langfuse/otel_setup.rb @@ -31,7 +31,7 @@ class << self # @param config [Langfuse::Config] The Langfuse configuration # @return [OpenTelemetry::SDK::Trace::TracerProvider] def setup(config) - validate_tracing_config!(config) + config.validate_tracing! return existing_provider_for(config) if initialized? candidate_provider = nil @@ -85,9 +85,9 @@ def initialized? def existing_provider_for(config) snapshot = tracing_config_snapshot(config) if @config_snapshot == snapshot - config.logger.debug("Langfuse tracing already initialized; reusing existing tracer provider") + config.logger&.debug("Langfuse tracing already initialized; reusing existing tracer provider") else - config.logger.warn( + config.logger&.warn( "Langfuse tracing is already initialized. Changes to #{TRACING_CONFIG_FIELDS.join(', ')} " \ "require Langfuse.reset! before they take effect." ) @@ -146,16 +146,7 @@ def build_exporter(config) def log_initialized(config) mode = config.tracing_async ? "async" : "sync" - config.logger.info("Langfuse tracing initialized with OpenTelemetry (#{mode} mode)") - end - - def validate_tracing_config!(config) - raise ConfigurationError, "public_key is required" if blank?(config.public_key) - raise ConfigurationError, "secret_key is required" if blank?(config.secret_key) - raise ConfigurationError, "base_url cannot be empty" if blank?(config.base_url) - return if config.should_export_span.nil? || config.should_export_span.respond_to?(:call) - - raise ConfigurationError, "should_export_span must respond to #call" + config.logger&.info("Langfuse tracing initialized with OpenTelemetry (#{mode} mode)") end def tracing_config_snapshot(config) @@ -166,10 +157,6 @@ def setup_mutex @setup_mutex ||= Mutex.new end - def blank?(value) - value.nil? || value.empty? - end - def build_headers(public_key, secret_key) credentials = "#{public_key}:#{secret_key}" encoded = Base64.strict_encode64(credentials) diff --git a/spec/langfuse/config_spec.rb b/spec/langfuse/config_spec.rb index 40b89cf..ca02623 100644 --- a/spec/langfuse/config_spec.rb +++ b/spec/langfuse/config_spec.rb @@ -209,6 +209,32 @@ "timeout must be positive" ) end + + it "raises ConfigurationError instead of ArgumentError when timeout is a String" do + config.timeout = "5" + expect { config.validate! }.to raise_error( + Langfuse::ConfigurationError, + "timeout must be positive" + ) + end + end + + context "when fields have invalid types" do + it "raises ConfigurationError when cache_ttl is a Symbol" do + config.cache_ttl = :sixty + expect { config.validate! }.to raise_error( + Langfuse::ConfigurationError, + "cache_ttl must be non-negative" + ) + end + + it "raises ConfigurationError when public_key is not a String" do + config.public_key = 123 + expect { config.validate! }.to raise_error( + Langfuse::ConfigurationError, + "public_key is required" + ) + end end context "when should_export_span is invalid" do @@ -386,6 +412,19 @@ config.cache_stale_ttl = :indefinite expect { config.validate! }.not_to raise_error end + + it "raises ConfigurationError when a String" do + config.cache_stale_ttl = "abc" + expect { config.validate! }.to raise_error( + Langfuse::ConfigurationError, + /cache_stale_ttl/ + ) + end + + it "allows non-negative Float values" do + config.cache_stale_ttl = 30.5 + expect { config.validate! }.not_to raise_error + end end context "when cache_refresh_threads is invalid" do @@ -455,6 +494,138 @@ end end + describe "#valid?" do + let(:config) do + described_class.new do |c| + c.public_key = "pk_test" + c.secret_key = "sk_test" + end + end + + it "returns true when configuration is valid" do + expect(config.valid?).to be true + end + + it "returns false without raising when configuration is invalid" do + config.public_key = nil + + expect(config.valid?).to be false + end + + it "returns false without raising when timeout is a String" do + config.timeout = "5" + + expect(config.valid?).to be false + end + + it "returns false without raising when cache_refresh_threads is a Symbol" do + config.cache_refresh_threads = :many + + expect(config.valid?).to be false + end + + it "stays true when batch_size is invalid (tracing-only setting)" do + config.batch_size = nil + + expect(config.valid?).to be true + end + end + + describe "#validate_tracing!" do + let(:config) do + described_class.new do |c| + c.public_key = "pk_test" + c.secret_key = "sk_test" + end + end + + it "passes with valid credentials and default tracing settings" do + expect { config.validate_tracing! }.not_to raise_error + end + + it "raises when public_key is missing" do + config.public_key = nil + expect { config.validate_tracing! }.to raise_error( + Langfuse::ConfigurationError, + "public_key is required" + ) + end + + it "raises when secret_key is empty" do + config.secret_key = "" + expect { config.validate_tracing! }.to raise_error( + Langfuse::ConfigurationError, + "secret_key is required" + ) + end + + it "raises when base_url is empty" do + config.base_url = "" + expect { config.validate_tracing! }.to raise_error( + Langfuse::ConfigurationError, + "base_url cannot be empty" + ) + end + + it "raises when should_export_span is not callable" do + config.should_export_span = "not callable" + expect { config.validate_tracing! }.to raise_error( + Langfuse::ConfigurationError, + "should_export_span must respond to #call" + ) + end + + it "raises when batch_size is nil" do + config.batch_size = nil + expect { config.validate_tracing! }.to raise_error( + Langfuse::ConfigurationError, + "batch_size must be a positive Integer" + ) + end + + it "raises when batch_size is a String" do + config.batch_size = "50" + expect { config.validate_tracing! }.to raise_error( + Langfuse::ConfigurationError, + "batch_size must be a positive Integer" + ) + end + + it "raises when batch_size is zero" do + config.batch_size = 0 + expect { config.validate_tracing! }.to raise_error( + Langfuse::ConfigurationError, + "batch_size must be a positive Integer" + ) + end + + it "raises when flush_interval is nil" do + config.flush_interval = nil + expect { config.validate_tracing! }.to raise_error( + Langfuse::ConfigurationError, + "flush_interval must be a positive number" + ) + end + + it "raises when flush_interval is negative" do + config.flush_interval = -1 + expect { config.validate_tracing! }.to raise_error( + Langfuse::ConfigurationError, + "flush_interval must be a positive number" + ) + end + + it "does not raise for an invalid cache_backend (tracing is orthogonal)" do + config.cache_backend = :bogus + expect { config.validate_tracing! }.not_to raise_error + end + + it "does not raise for an invalid timeout (tracing is orthogonal)" do + config.timeout = "5" + expect { config.validate_tracing! }.not_to raise_error + end + end + describe "attribute setters" do let(:config) { described_class.new } diff --git a/spec/langfuse/otel_setup_spec.rb b/spec/langfuse/otel_setup_spec.rb index 7f76798..ea27396 100644 --- a/spec/langfuse/otel_setup_spec.rb +++ b/spec/langfuse/otel_setup_spec.rb @@ -79,6 +79,12 @@ expect(described_class.setup(config)).to equal(existing_provider) end + it "tolerates a nil logger during setup" do + config.logger = nil + + expect { described_class.setup(config) }.not_to raise_error + end + it "validates should_export_span in setup" do config.should_export_span = "bad" @@ -183,7 +189,23 @@ expect { Langfuse.tracer_provider }.to raise_error( Langfuse::ConfigurationError, - /Langfuse tracing is disabled/ + /Langfuse tracing is disabled: public_key is required/ + ) + end + + it "raises from Langfuse.tracer_provider when batch_size is invalid" do + Langfuse.reset! + Langfuse.configure do |c| + c.public_key = config.public_key + c.secret_key = config.secret_key + c.base_url = config.base_url + c.batch_size = nil + c.logger = logger + end + + expect { Langfuse.tracer_provider }.to raise_error( + Langfuse::ConfigurationError, + /batch_size/ ) end diff --git a/spec/langfuse_spec.rb b/spec/langfuse_spec.rb index 423de02..c035bea 100644 --- a/spec/langfuse_spec.rb +++ b/spec/langfuse_spec.rb @@ -56,6 +56,18 @@ end end + describe ".configured?" do + it "returns true when configuration is valid" do + expect(described_class.configured?).to be true + end + + it "returns false when configuration is invalid" do + described_class.reset! + + expect(described_class.configured?).to be false + end + end + describe ".client" do before do described_class.configure do |config| @@ -456,7 +468,7 @@ config.logger = logger end - expect(logger).to receive(:warn).once.with(/Langfuse tracing is disabled/) + expect(logger).to receive(:warn).once.with(/Langfuse tracing is disabled: public_key is required/) first = described_class.observe("first") second = described_class.observe("second") @@ -465,6 +477,48 @@ expect(second.otel_span.recording?).to be(false) end + it "warns once and uses a no-op tracer when batch_size is invalid" do + described_class.reset! + logger = instance_double(Logger, warn: nil) + described_class.configure do |config| + config.public_key = "pk_test" + config.secret_key = "sk_test" + config.batch_size = nil + config.logger = logger + end + + expect(logger).to receive(:warn).once.with(/batch_size must be a positive Integer/) + + observation = described_class.observe("test") + + expect(observation.otel_span.recording?).to be(false) + end + + it "degrades to a no-op tracer without crashing when logger is nil" do + described_class.reset! + described_class.configure do |config| + config.public_key = nil + config.logger = nil + end + + observation = described_class.observe("test") + + expect(observation.otel_span.recording?).to be(false) + end + + it "keeps tracing enabled when only client config is invalid" do + described_class.reset! + described_class.configure do |config| + config.public_key = "pk_test" + config.secret_key = "sk_test" + config.cache_backend = :bogus + end + + observation = described_class.observe("x") + + expect(observation.otel_span.recording?).to be(true) + end + it "creates and returns observation without block" do observation = described_class.observe("test", input: { data: "test" }) expect(observation).to be_a(Langfuse::Span)