Skip to content
Merged
17 changes: 17 additions & 0 deletions e2e/ruby/sinatra/demo/Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ PATH
launchdarkly-observability (0.1.0)
launchdarkly-server-sdk (>= 8.0)
opentelemetry-exporter-otlp (~> 0.28)
opentelemetry-exporter-otlp-logs (~> 0.1)
opentelemetry-instrumentation-all (~> 0.62)
opentelemetry-logs-sdk (~> 0.1)
opentelemetry-sdk (~> 1.4)
opentelemetry-semantic_conventions (~> 1.10)

Expand Down Expand Up @@ -68,6 +70,15 @@ GEM
opentelemetry-common (~> 0.20)
opentelemetry-sdk (~> 1.10)
opentelemetry-semantic_conventions
opentelemetry-exporter-otlp-logs (0.2.2)
google-protobuf (>= 3.18)
googleapis-common-protos-types (~> 1.3)
opentelemetry-api (~> 1.1)
opentelemetry-common (~> 0.20)
opentelemetry-logs-api (~> 0.1)
opentelemetry-logs-sdk (~> 0.1)
opentelemetry-sdk
opentelemetry-semantic_conventions
opentelemetry-helpers-mysql (0.4.0)
opentelemetry-api (~> 1.7)
opentelemetry-common (~> 0.21)
Expand Down Expand Up @@ -222,6 +233,12 @@ GEM
opentelemetry-helpers-sql-processor
opentelemetry-instrumentation-base (~> 0.25)
opentelemetry-semantic_conventions (>= 1.8.0)
opentelemetry-logs-api (0.2.0)
opentelemetry-api (~> 1.0)
opentelemetry-logs-sdk (0.4.0)
opentelemetry-api (~> 1.2)
opentelemetry-logs-api (~> 0.1)
opentelemetry-sdk (~> 1.3)
opentelemetry-registry (0.4.0)
opentelemetry-api (~> 1.1)
opentelemetry-sdk (1.10.0)
Expand Down
6 changes: 3 additions & 3 deletions e2e/ruby/sinatra/demo/app.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@

use LaunchDarklyObservability::Middleware

$logger = Logger.new($stdout)

plugin_opts = {
service_name: 'launchdarkly-sinatra-demo',
service_version: '1.0.0'
Expand All @@ -17,12 +15,14 @@
observability_plugin = LaunchDarklyObservability::Plugin.new(**plugin_opts)

sdk_key = ENV.fetch('LAUNCHDARKLY_SDK_KEY') do
$logger.warn '[LaunchDarkly] LAUNCHDARKLY_SDK_KEY not set, client will not connect'
warn '[LaunchDarkly] LAUNCHDARKLY_SDK_KEY not set, client will not connect'
nil
end
config = LaunchDarkly::Config.new(plugins: [observability_plugin])
$ld_client = LaunchDarkly::LDClient.new(sdk_key, config)

$logger = LaunchDarklyObservability.logger

if ENV['DEBUG'] == 'true'
debug_exporter = Class.new do
def export(spans, timeout: nil)
Expand Down
20 changes: 19 additions & 1 deletion sdk/@launchdarkly/observability-ruby/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -382,10 +382,18 @@ The module-level methods work in any Ruby application:
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
Expand Down Expand Up @@ -429,7 +437,7 @@ logger.info "Processing request: #{trace_id}"

### Logging with Trace Context

In Rails applications, `Rails.logger` is automatically bridged to the OpenTelemetry
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.

Expand All @@ -438,6 +446,16 @@ Rails.logger.info "Processing flag evaluation" # Automatically includes trace_i
Rails.logger.warn "Slow query detected" # Same correlation, different severity
```

In **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.

```ruby
$logger = LaunchDarklyObservability.logger # defaults to $stdout
$logger = LaunchDarklyObservability.logger($stderr) # or any IO
```

To disable log export while keeping traces, pass `enable_logs: false`:

```ruby
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,16 +118,31 @@ def record_exception(exception, attributes: {})
span = OpenTelemetry::Trace.current_span
return unless span

extra_attributes = {}
structured_stacktrace = SourceContext.build_structured_stacktrace(exception)
if structured_stacktrace
extra_attributes['exception.structured_stacktrace'] = structured_stacktrace.to_json
end

span.record_exception(exception, attributes: extra_attributes.merge(attributes))
span.record_exception(exception, attributes: SourceContext.exception_attributes(exception).merge(attributes))
span.status = OpenTelemetry::Trace::Status.error(exception.message)
end

# Create a Logger that writes to both a local IO and the OTel Logs pipeline.
#
# Use this in non-Rails applications (Sinatra, Grape, plain Ruby) to get
# log export with trace correlation out of the box. Must be called after
# the Plugin has been registered (i.e. after LDClient.new).
#
# @param output [IO] Local IO destination (default: $stdout)
# @return [OtelLogBridge, Logger] An OTel-bridged logger, or a plain
# Logger if the OTel logger provider is not yet available.
#
# @example Sinatra
# $logger = LaunchDarklyObservability.logger
# $logger.info 'This goes to stdout AND is exported as an OTLP log record'
def logger(output = $stdout)
if otel_logger_provider_available?
OtelLogBridge.new(OpenTelemetry.logger_provider, io: output)
else
::Logger.new(output)
end
end

# Get the current trace ID
#
# @return [String, nil] The current trace ID in hex format
Expand All @@ -154,5 +169,13 @@ def shutdown
@instance&.shutdown
@instance = nil
end

private

def otel_logger_provider_available?
defined?(OpenTelemetry::SDK::Logs::LoggerProvider) &&
OpenTelemetry.respond_to?(:logger_provider) &&
OpenTelemetry.logger_provider.is_a?(OpenTelemetry::SDK::Logs::LoggerProvider)
end
Comment thread
cursor[bot] marked this conversation as resolved.
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ def call(env)
status, headers, body = @app.call(env)
rescue StandardError => e
app_error = e
span.record_exception(e)
span.record_exception(e, attributes: SourceContext.exception_attributes(e))
span.status = OpenTelemetry::Trace::Status.error(e.message)
raise
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@
module LaunchDarklyObservability
# A Logger that forwards messages to the OpenTelemetry Logs pipeline.
#
# Designed to be broadcast-attached to Rails.logger so every Rails log
# entry is automatically emitted as an OTLP LogRecord with trace/span
# correlation from the current OpenTelemetry context.
# When used as a broadcast target (Rails), pass only the logger_provider.
# When used standalone (Sinatra, plain Ruby), pass `io:` to also write
# to a local destination such as $stdout.
#
# @example Standalone usage (non-Rails)
# logger = LaunchDarklyObservability.logger # writes to $stdout + OTel
#
# @example Manually attaching (the Railtie does this automatically)
# bridge = LaunchDarklyObservability::OtelLogBridge.new(logger_provider)
Expand Down Expand Up @@ -33,12 +36,27 @@ class OtelLogBridge < ::Logger
}.freeze

# @param logger_provider [OpenTelemetry::SDK::Logs::LoggerProvider]
def initialize(logger_provider)
# @param io [IO, nil] Optional IO for local output (e.g. $stdout).
# When nil the bridge only emits to OTel (suitable for broadcast).
def initialize(logger_provider, io: nil)
super(File::NULL)
@otel_logger = logger_provider.logger(
name: 'launchdarkly-observability-ruby',
version: LaunchDarklyObservability::VERSION
)
@local_logger = io ? ::Logger.new(io) : nil
end

# Propagate level changes to the local logger so filtering stays in sync.
def level=(severity)
super
@local_logger&.level = severity
end

# Propagate formatter changes to the local logger.
def formatter=(formatter)
super
@local_logger&.formatter = formatter
end

# Core method that debug/info/warn/error/fatal all delegate to.
Expand All @@ -51,6 +69,7 @@ def add(severity, message = nil, progname = nil)
message = yield
else
message = progname
progname = nil
end
end

Expand All @@ -64,17 +83,25 @@ def add(severity, message = nil, progname = nil)
body = message.to_s
end

@otel_logger.on_emit(
body: body,
severity_number: SEVERITY_NUMBER.fetch(severity, 0),
severity_text: SEVERITY_TEXT.fetch(severity, 'UNKNOWN'),
timestamp: Time.now,
context: OpenTelemetry::Context.current,
attributes: attributes
)
begin
@otel_logger.on_emit(
body: body,
severity_number: SEVERITY_NUMBER.fetch(severity, 0),
severity_text: SEVERITY_TEXT.fetch(severity, 'UNKNOWN'),
timestamp: Time.now,
context: OpenTelemetry::Context.current,
attributes: attributes
)
rescue StandardError
# OTel export failures must not suppress local IO output.
end

begin
@local_logger&.add(severity, message, progname)
rescue StandardError
# Local IO failures must not propagate.
end

Comment thread
cursor[bot] marked this conversation as resolved.
true
rescue StandardError
true
end
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,7 @@ def attach_otel_log_bridge
end

def otel_logger_provider_available?
defined?(OpenTelemetry::SDK::Logs::LoggerProvider) &&
OpenTelemetry.respond_to?(:logger_provider) &&
OpenTelemetry.logger_provider.is_a?(OpenTelemetry::SDK::Logs::LoggerProvider)
LaunchDarklyObservability.send(:otel_logger_provider_available?)
end
end
end
Expand Down Expand Up @@ -104,7 +102,7 @@ def record_launchdarkly_exception(exception, attributes: {})
span = OpenTelemetry::Trace.current_span
return unless span

span.record_exception(exception, attributes: attributes)
span.record_exception(exception, attributes: SourceContext.exception_attributes(exception).merge(attributes))
span.status = OpenTelemetry::Trace::Status.error(exception.message)
end
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,19 @@ def build_structured_stacktrace(exception)
nil
end

# Build a Hash of span attributes for an exception's structured stacktrace.
# Returns an empty hash when no stacktrace can be built, so the result is
# safe to merge directly into an attributes hash.
#
# @param exception [Exception]
# @return [Hash]
def exception_attributes(exception)
stacktrace = build_structured_stacktrace(exception)
return {} unless stacktrace

{ 'exception.structured_stacktrace' => stacktrace.to_json }
end

def read_source_context(file_name, line_number)
return nil unless file_name && line_number
return nil unless File.exist?(file_name) && File.readable?(file_name)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,4 +136,46 @@ def test_record_exception_without_span
spans = @exporter.finished_spans
assert_equal 0, spans.length
end

# --- .logger tests ---

def test_logger_returns_otel_bridge_when_provider_available
require 'opentelemetry-logs-sdk'

resource = OpenTelemetry::SDK::Resources::Resource.create({})
provider = OpenTelemetry::SDK::Logs::LoggerProvider.new(resource: resource)
OpenTelemetry.logger_provider = provider

logger = LaunchDarklyObservability.logger(StringIO.new)
assert_instance_of LaunchDarklyObservability::OtelLogBridge, logger
ensure
OpenTelemetry.logger_provider = nil if OpenTelemetry.respond_to?(:logger_provider=)
end

def test_logger_returns_plain_logger_when_provider_unavailable
original = OpenTelemetry.logger_provider if OpenTelemetry.respond_to?(:logger_provider)
OpenTelemetry.logger_provider = nil if OpenTelemetry.respond_to?(:logger_provider=)

logger = LaunchDarklyObservability.logger(StringIO.new)
assert_instance_of ::Logger, logger
refute_instance_of LaunchDarklyObservability::OtelLogBridge, logger
ensure
OpenTelemetry.logger_provider = original if OpenTelemetry.respond_to?(:logger_provider=)
end

def test_logger_writes_to_provided_io
require 'opentelemetry-logs-sdk'

resource = OpenTelemetry::SDK::Resources::Resource.create({})
provider = OpenTelemetry::SDK::Logs::LoggerProvider.new(resource: resource)
OpenTelemetry.logger_provider = provider

io = StringIO.new
logger = LaunchDarklyObservability.logger(io)
logger.info 'hello from test'

assert_match(/hello from test/, io.string)
ensure
OpenTelemetry.logger_provider = nil if OpenTelemetry.respond_to?(:logger_provider=)
end
end
Loading
Loading