OpenTelemetry-based observability instrumentation for the LaunchDarkly Ruby SDK with full Rails support.
This plugin automatically instruments LaunchDarkly feature flag evaluations with OpenTelemetry traces and logs, providing visibility into:
- Flag evaluation timing and results
- Evaluation reasons and rule matches
- Context information (user/organization)
- Error tracking for failed evaluations
- Correlation with HTTP requests in Rails applications
Add this line to your application's Gemfile:
gem 'launchdarkly-observability'And then execute:
bundle installOr install it yourself as:
gem install launchdarkly-observabilityThe gem includes everything needed for traces and logs out of the box:
launchdarkly-server-sdk>= 8.0opentelemetry-sdk~> 1.4opentelemetry-exporter-otlp~> 0.28opentelemetry-instrumentation-all~> 0.62opentelemetry-logs-sdk~> 0.1opentelemetry-exporter-otlp-logs~> 0.1
For metrics support (optional):
opentelemetry-metrics-sdk~> 0.1
require 'launchdarkly-server-sdk'
require 'launchdarkly_observability'
# Create observability plugin (SDK key and environment automatically inferred)
observability = LaunchDarklyObservability::Plugin.new
# Initialize LaunchDarkly client with plugin
config = LaunchDarkly::Config.new(plugins: [observability])
client = LaunchDarkly::LDClient.new('your-sdk-key', config)
# Flag evaluations are now automatically instrumented
context = LaunchDarkly::LDContext.create({ key: 'user-123', kind: 'user' })
value = client.variation('my-feature-flag', context, false)Note: The plugin automatically extracts the SDK key from the LaunchDarkly client during initialization. The backend derives both the project and environment from the SDK key for telemetry routing, so you don't need to configure them explicitly.
Create an initializer at config/initializers/launchdarkly.rb:
require 'launchdarkly-server-sdk'
require 'launchdarkly_observability'
# Setup observability plugin (SDK key and environment automatically inferred)
observability = LaunchDarklyObservability::Plugin.new(
service_name: 'my-rails-app',
service_version: '1.0.0'
)
# Initialize LaunchDarkly client using Rails configuration
config = LaunchDarkly::Config.new(plugins: [observability])
Rails.configuration.ld_client = LaunchDarkly::LDClient.new(
ENV['LAUNCHDARKLY_SDK_KEY'],
config
)
# Ensure clean shutdown
at_exit { Rails.configuration.ld_client.close }Use in controllers:
class ApplicationController < ActionController::Base
private
# Helper method for accessing the LaunchDarkly client
def ld_client
Rails.configuration.ld_client
end
def current_ld_context
@current_ld_context ||= LaunchDarkly::LDContext.create({
key: current_user&.id || 'anonymous',
kind: 'user',
email: current_user&.email,
name: current_user&.name
})
end
end
class HomeController < ApplicationController
def index
# This evaluation is automatically traced and correlated with the HTTP request
@show_new_feature = ld_client.variation('new-feature', current_ld_context, false)
end
endLaunchDarklyObservability::Plugin.new(
# All parameters are optional - SDK key and environment are automatically inferred
# Optional: Custom OTLP endpoint (default: LaunchDarkly's endpoint)
otlp_endpoint: 'https://otel.observability.app.launchdarkly.com:4318',
# Optional: Environment override (default: inferred from SDK key)
# Only specify for advanced scenarios like deployment-specific suffixes
environment: 'production-canary',
# Optional: Service identification
service_name: 'my-service',
service_version: '1.0.0',
# Optional: Enable/disable signal types
enable_traces: true, # default: true
enable_logs: true, # default: true
enable_metrics: true, # default: true
# Optional: Custom instrumentation configuration
instrumentations: {
'OpenTelemetry::Instrumentation::Rails' => { enable_recognize_route: true },
'OpenTelemetry::Instrumentation::ActiveRecord' => { db_statement: :include }
}
)Advanced: You can explicitly pass
sdk_keyorproject_idfor testing scenarios, but this is rarely needed since they're automatically extracted from the client.
You can configure via environment variables:
| Variable | Description |
|---|---|
LAUNCHDARKLY_SDK_KEY |
LaunchDarkly SDK key (automatically extracted from client during initialization) |
OTEL_EXPORTER_OTLP_ENDPOINT |
Custom OTLP endpoint |
OTEL_SERVICE_NAME |
Service name (if not specified in plugin options) |
Note: The environment associated with your SDK key is automatically determined by the backend, so you don't need to configure it separately.
This Ruby SDK is designed for compatibility with other LaunchDarkly observability SDKs (Android, Node.js, Python, Go, .NET). Key compatibility features:
- Span name:
"evaluation"(consistent across all SDKs) - Event name:
"feature_flag"(matches Android and Node SDKs) - Provider name:
"LaunchDarkly"(consistent across all SDKs) - Attribute naming: Follows OpenTelemetry semantic conventions
Each flag evaluation creates a span with the following attributes, following OpenTelemetry semantic conventions for feature flags:
| Attribute | Status | Description | Example |
|---|---|---|---|
feature_flag.key |
Release Candidate | Flag key | "my-feature" |
feature_flag.provider.name |
Release Candidate | Provider name | "LaunchDarkly" |
feature_flag.result.value |
Release Candidate | Evaluated value | "true" |
feature_flag.result.variant |
Release Candidate | Variation index | "1" |
feature_flag.result.reason |
Release Candidate | Evaluation reason | "default", "targeting_match", "error" |
feature_flag.context.id |
Release Candidate | Context identifier | "user-123" |
error.type |
Stable | Error type (when applicable) | "flag_not_found" |
error.message |
Development | Error message (when applicable) | "Flag evaluation error: FLAG_NOT_FOUND" |
These custom attributes provide additional LaunchDarkly-specific details:
| Attribute | Description | Example |
|---|---|---|
launchdarkly.context.kind |
Context kind | "user" |
launchdarkly.context.key |
Context key | "user-123" |
launchdarkly.reason.kind |
LaunchDarkly reason kind | "FALLTHROUGH", "RULE_MATCH", "ERROR" |
launchdarkly.reason.rule_index |
Rule index (for RULE_MATCH) | 0 |
launchdarkly.reason.rule_id |
Rule ID (for RULE_MATCH) | "rule-key" |
launchdarkly.reason.prerequisite_key |
Prerequisite key (for PREREQUISITE_FAILED) | "other-flag" |
launchdarkly.reason.in_experiment |
In experiment flag | true |
launchdarkly.reason.error_kind |
LaunchDarkly error kind (for ERROR) | "FLAG_NOT_FOUND" |
launchdarkly.evaluation.duration_ms |
Evaluation time in milliseconds | 0.5 |
launchdarkly.evaluation.method |
SDK method called | "variation", "variation_detail" |
In addition to span attributes, each evaluation adds a "feature_flag" event to the span. This matches the pattern used by other LaunchDarkly observability SDKs (Android, Node.js) and follows OpenTelemetry semantic conventions for feature flag events.
The event contains the core evaluation data:
| Event Attribute | Description | Example |
|---|---|---|
feature_flag.key |
Flag key | "my-feature" |
feature_flag.provider.name |
Provider name | "LaunchDarkly" |
feature_flag.context.id |
Context identifier | "user-123" |
feature_flag.result.value |
Evaluated value | "true" |
feature_flag.result.variant |
Variation index | "1" |
feature_flag.result.reason |
Evaluation reason | "default" |
launchdarkly.reason.in_experiment |
In experiment flag (if applicable) | true |
Why both span attributes and events?
- Span attributes provide detailed context for the entire evaluation span, including timing, method, and LaunchDarkly-specific details
- Span events represent a point-in-time record of the evaluation result, which is the standard OpenTelemetry pattern for feature flag evaluations
- This dual approach matches other LaunchDarkly SDKs and maximizes compatibility with observability backends
When evaluation errors occur, the plugin follows OpenTelemetry error semantic conventions:
error.type: Mapped from LaunchDarkly error kinds to standard values (flag_not_found,type_mismatch,provider_not_ready,general)error.message: Human-readable error descriptionfeature_flag.result.reason: Set to"error"launchdarkly.reason.error_kind: Original LaunchDarkly error kind (FLAG_NOT_FOUND,WRONG_TYPE, etc.)
The span status is also set to ERROR with a descriptive message.
| LaunchDarkly Error | OpenTelemetry error.type |
|---|---|
FLAG_NOT_FOUND |
flag_not_found |
WRONG_TYPE |
type_mismatch |
CLIENT_NOT_READY |
provider_not_ready |
MALFORMED_FLAG |
parse_error |
| Others | general |
When used with Rails, the plugin provides:
- Rack Middleware: Automatically traces HTTP requests and provides context propagation
- Controller Helpers: Convenient methods for custom tracing
- View Helpers: Generate traceparent meta tags for client-side correlation
class MyController < ApplicationController
def index
# Get current trace ID for logging
trace_id = launchdarkly_trace_id
Rails.logger.info "Processing request with trace: #{trace_id}"
# Create custom spans
with_launchdarkly_span('custom-operation', attributes: { 'custom.key' => 'value' }) do |span|
# Your code here
span.set_attribute('result', 'success')
end
end
def create
# Record exceptions
begin
process_something
rescue => e
record_launchdarkly_exception(e)
raise
end
end
end<head>
<%= launchdarkly_traceparent_meta_tag %>
</head>This generates:
<meta name="traceparent" content="00-abc123...-def456...-01">By default, the plugin enables OpenTelemetry auto-instrumentation for common Ruby libraries:
- Rails: Request tracing, route recognition
- ActiveRecord: Database query tracing
- Net::HTTP: Outbound HTTP request tracing
- Rack: Request/response tracing
- Redis: Cache operation tracing
- Sidekiq: Background job tracing
LaunchDarklyObservability::Plugin.new(
instrumentations: {
# Disable specific instrumentations
'OpenTelemetry::Instrumentation::Redis' => { enabled: false },
# Configure instrumentations
'OpenTelemetry::Instrumentation::ActiveRecord' => {
db_statement: :obfuscate, # Mask sensitive data
obfuscation_limit: 2000
},
# Skip certain endpoints
'OpenTelemetry::Instrumentation::Rack' => {
untraced_endpoints: ['/health', '/metrics']
}
}
)The LaunchDarkly Observability plugin provides a clean API matching OpenTelemetry conventions for creating custom spans:
# Simple span creation
LaunchDarklyObservability.in_span('database-query') do |span|
span.set_attribute('db.table', 'users')
span.set_attribute('db.operation', 'select')
# Your code here
results = execute_query
end
# With initial attributes
LaunchDarklyObservability.in_span('api-call', attributes: { 'api.endpoint' => '/users' }) do |span|
response = make_api_call
span.set_attribute('api.status', response.code)
end
# Nested spans
LaunchDarklyObservability.in_span('process-order') do |outer_span|
outer_span.set_attribute('order.id', order_id)
LaunchDarklyObservability.in_span('validate-payment') do |inner_span|
validate_payment(order)
end
LaunchDarklyObservability.in_span('update-inventory') do |inner_span|
update_inventory(order)
end
endThe module-level methods work in any Ruby application:
# Sinatra example
require 'sinatra'
require 'launchdarkly_observability'
# Create a logger that writes to stdout AND exports via OTLP.
# Must be called after LDClient.new so the OTel logger provider is ready.
$logger = LaunchDarklyObservability.logger
$logger.info 'App booted' # stdout + OTLP log record
$logger.info(user: 'alice', action: 'login') # hash keys become OTel attributes
get '/users/:id' do
LaunchDarklyObservability.in_span('fetch-user', attributes: { 'user.id' => params[:id] }) do |span|
user = User.find(params[:id])
span.set_attribute('user.name', user.name)
$logger.info "Fetched user #{user.name}" # correlated with the active span
user.to_json
end
end
# Plain Ruby script
LaunchDarklyObservability.in_span('data-processing') do |span|
files = Dir.glob('data/*.csv')
span.set_attribute('files.count', files.length)
files.each do |file|
LaunchDarklyObservability.in_span('process-file', attributes: { 'file.name' => file }) do |file_span|
process_csv(file)
end
end
endLaunchDarklyObservability.in_span('risky-operation') do |span|
begin
perform_operation
rescue StandardError => e
LaunchDarklyObservability.record_exception(e, attributes: {
'retry_count' => 3,
'operation_id' => operation_id
})
raise
end
end# Get the current trace ID for logging or debugging
trace_id = LaunchDarklyObservability.current_trace_id
logger.info "Processing request: #{trace_id}"In Rails applications, Rails.logger is automatically bridged to the OpenTelemetry
Logs pipeline. Every log entry is exported as an OTLP LogRecord with the active
trace and span IDs attached for correlation.
Rails.logger.info "Processing flag evaluation" # Automatically includes trace_id, span_id
Rails.logger.warn "Slow query detected" # Same correlation, different severityIn non-Rails applications (Sinatra, Grape, plain Ruby), call
LaunchDarklyObservability.logger after the LaunchDarkly client is initialized.
The returned logger writes to stdout (or any IO you pass) and exports every
entry as an OTLP LogRecord with trace/span correlation.
$logger = LaunchDarklyObservability.logger # defaults to $stdout
$logger = LaunchDarklyObservability.logger($stderr) # or any IOTo disable log export while keeping traces, pass enable_logs: false:
plugin = LaunchDarklyObservability::Plugin.new(enable_logs: false)The plugin API matches OpenTelemetry's naming but eliminates boilerplate:
LaunchDarklyObservability.in_span('operation', attributes: { 'key' => 'value' }) do |span|
# Your code
end
LaunchDarklyObservability.record_exception(error)
LaunchDarklyObservability.current_trace_idtracer = OpenTelemetry.tracer_provider.tracer('my-component', '1.0.0')
tracer.in_span('operation', attributes: { 'key' => 'value' }) do |span|
# Your code
end
span = OpenTelemetry::Trace.current_span
span.record_exception(error)
span.status = OpenTelemetry::Trace::Status.error(error.message)
span = OpenTelemetry::Trace.current_span
span.context.hex_trace_id if span&.context&.valid?The plugin API provides the same familiar in_span method name while eliminating boilerplate.
-
Use descriptive span names: Use kebab-case names that describe the operation
LaunchDarklyObservability.in_span('validate-payment') # Good LaunchDarklyObservability.in_span('do_stuff') # Bad
-
Add meaningful attributes: Include relevant context as span attributes
LaunchDarklyObservability.in_span('database-query', attributes: { 'db.table' => 'users', 'db.operation' => 'select', 'db.rows_returned' => results.count })
-
Always re-raise exceptions: After recording an exception, re-raise it unless you're handling it
rescue => e LaunchDarklyObservability.record_exception(e) raise # Important! end
-
Keep spans focused: Create separate spans for distinct operations rather than one large span
# Good - separate spans LaunchDarklyObservability.in_span('fetch-data') { fetch } LaunchDarklyObservability.in_span('process-data') { process } # Bad - one large span LaunchDarklyObservability.in_span('fetch-and-process') do fetch process end
-
Include trace IDs in logs: Use
current_trace_idfor log correlationtrace_id = LaunchDarklyObservability.current_trace_id Rails.logger.info "Starting processing [trace: #{trace_id}]"
-
Verify the OTLP endpoint is accessible:
puts LaunchDarklyObservability.instance&.otlp_endpoint
-
Check if OpenTelemetry is configured:
puts OpenTelemetry.tracer_provider.class # Should be: OpenTelemetry::SDK::Trace::TracerProvider
-
Ensure the plugin is registered:
puts LaunchDarklyObservability.instance&.registered?
Verify the hook is receiving evaluations by checking logs:
# Set environment variable for debug output
ENV['OTEL_LOG_LEVEL'] = 'debug'Ensure the gem is loaded in your Gemfile and the initializer runs before controllers:
# Gemfile
gem 'launchdarkly-observability'When testing, you may want to use an in-memory exporter:
# test/test_helper.rb
require 'opentelemetry/sdk'
class ActiveSupport::TestCase
setup do
@exporter = OpenTelemetry::SDK::Trace::Export::InMemorySpanExporter.new
OpenTelemetry::SDK.configure do |c|
c.add_span_processor(
OpenTelemetry::SDK::Trace::Export::SimpleSpanProcessor.new(@exporter)
)
end
end
teardown do
@exporter.reset
end
def finished_spans
@exporter.finished_spans
end
endInitialize the plugin (alternative to creating Plugin directly).
Returns true if the plugin has been initialized.
Creates a new span and executes the given block within its context. Matches the OpenTelemetry tracer.in_span API.
Parameters:
name(String): The name of the spanattributes(Hash): Optional initial attributes for the span
Yields: span (OpenTelemetry::Trace::Span) -- the created span object
Returns: The result of the block
Records an exception in the current span and sets the span status to error.
Parameters:
exception(Exception): The exception to recordattributes(Hash): Optional additional attributes
Returns the current trace ID in hexadecimal format.
Returns: String (32 hex characters) or nil if no active span
Flushes all pending telemetry data to the configured exporter.
Flushes pending data and stops the plugin.
# SDK key and environment are automatically inferred
plugin = LaunchDarklyObservability::Plugin.new(service_name: 'my-service')
plugin.project_id # => nil (extracted from client during registration)
plugin.otlp_endpoint # => 'https://otel...'
plugin.environment # => nil (inferred from SDK key by backend)
plugin.registered? # => false (true after client initialization)
plugin.flush # Flush pending data
plugin.shutdown # Stop the plugin- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Write tests for your changes
- Run tests (
bundle exec rake test) - Commit your changes (
git commit -am 'Add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
This project is licensed under the Apache 2.0 License - see the LICENSE.txt file for details.