launchdarkly-observability 0.2.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.
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'launchdarkly-server-sdk'
5
+
6
+ module LaunchDarklyObservability
7
+ # Evaluation hook that instruments LaunchDarkly flag evaluations with OpenTelemetry spans and events.
8
+ #
9
+ # This hook creates spans for each flag evaluation, capturing:
10
+ # - Flag key and provider
11
+ # - Context information
12
+ # - Evaluation result (value, variation index)
13
+ # - Any errors
14
+ #
15
+ # Additionally, a "feature_flag" event is added to the span with evaluation results,
16
+ # following the OpenTelemetry semantic conventions for feature flags and matching
17
+ # the pattern used by other LaunchDarkly observability SDKs (Android, Node).
18
+ #
19
+ # @example The hook is automatically registered when using the Plugin
20
+ # plugin = LaunchDarklyObservability::Plugin.new(project_id: 'my-project')
21
+ # config = LaunchDarkly::Config.new(plugins: [plugin])
22
+ # client = LaunchDarkly::LDClient.new('sdk-key', config)
23
+ #
24
+ # # Flag evaluations are now automatically traced
25
+ # client.variation('my-flag', context, false)
26
+ #
27
+ class Hook
28
+ include LaunchDarkly::Interfaces::Hooks::Hook
29
+
30
+ TRACER_NAME = 'launchdarkly-ruby'
31
+ SPAN_NAME = 'evaluation'
32
+ FEATURE_FLAG_EVENT_NAME = 'feature_flag'
33
+
34
+ # @return [LaunchDarkly::Interfaces::Hooks::Metadata]
35
+ def metadata
36
+ LaunchDarkly::Interfaces::Hooks::Metadata.new('launchdarkly-observability-hook')
37
+ end
38
+
39
+ # Called before flag evaluation.
40
+ # Creates an OpenTelemetry span and captures initial context information.
41
+ #
42
+ # @param series_context [LaunchDarkly::Interfaces::Hooks::EvaluationSeriesContext]
43
+ # @param data [Hash] Data passed between hook stages
44
+ # @return [Hash] Updated data hash with span information
45
+ def before_evaluation(series_context, data)
46
+ return data unless opentelemetry_available?
47
+
48
+ tracer = OpenTelemetry.tracer_provider.tracer(TRACER_NAME, LaunchDarklyObservability::VERSION)
49
+
50
+ span = tracer.start_span(SPAN_NAME, attributes: build_before_attributes(series_context))
51
+
52
+ data.merge(__ld_observability_span: span)
53
+ rescue StandardError => e
54
+ warn "[LaunchDarklyObservability] Error in before_evaluation: #{e.message}"
55
+ data
56
+ end
57
+
58
+ # Called after flag evaluation.
59
+ # Completes the span with evaluation results and a "feature_flag" event.
60
+ #
61
+ # @param series_context [LaunchDarkly::Interfaces::Hooks::EvaluationSeriesContext]
62
+ # @param data [Hash] Data passed between hook stages
63
+ # @param detail [LaunchDarkly::EvaluationDetail] The evaluation result
64
+ # @return [Hash] Updated data hash
65
+ def after_evaluation(series_context, data, detail)
66
+ span = data[:__ld_observability_span]
67
+ return data unless span
68
+
69
+ add_result_attributes(span, detail)
70
+ handle_evaluation_error(span, detail)
71
+ add_feature_flag_event(span, series_context, detail)
72
+
73
+ span.finish
74
+
75
+ data
76
+ rescue StandardError => e
77
+ warn "[LaunchDarklyObservability] Error in after_evaluation: #{e.message}"
78
+ span&.finish
79
+ data
80
+ end
81
+
82
+ private
83
+
84
+ def opentelemetry_available?
85
+ defined?(OpenTelemetry) && OpenTelemetry.tracer_provider
86
+ end
87
+
88
+ def build_before_attributes(series_context)
89
+ attrs = {
90
+ FEATURE_FLAG_KEY => series_context.key,
91
+ FEATURE_FLAG_PROVIDER_NAME => 'LaunchDarkly'
92
+ }
93
+
94
+ context = series_context.context
95
+ if context
96
+ attrs[FEATURE_FLAG_CONTEXT_ID] = context.fully_qualified_key
97
+ end
98
+
99
+ attrs
100
+ end
101
+
102
+ def serialize_value(value)
103
+ case value
104
+ when String, Numeric, TrueClass, FalseClass, NilClass
105
+ value.to_s
106
+ when Hash, Array
107
+ value.to_json
108
+ else
109
+ value.to_s
110
+ end
111
+ end
112
+
113
+ def add_result_attributes(span, detail)
114
+ if detail.variation_index
115
+ span.set_attribute(FEATURE_FLAG_RESULT_VARIANT, detail.variation_index.to_s)
116
+ span.set_attribute(FEATURE_FLAG_RESULT_VARIATION_INDEX, detail.variation_index.to_s)
117
+ end
118
+
119
+ span.set_attribute(FEATURE_FLAG_RESULT_VALUE, serialize_value(detail.value))
120
+ end
121
+
122
+ # Adds a "feature_flag" event with evaluation results.
123
+ # Attribute names match the ClickHouse schema and other LaunchDarkly SDKs.
124
+ def add_feature_flag_event(span, series_context, detail)
125
+ event_attributes = {
126
+ FEATURE_FLAG_KEY => series_context.key,
127
+ FEATURE_FLAG_PROVIDER_NAME => 'LaunchDarkly'
128
+ }
129
+
130
+ context = series_context.context
131
+ if context
132
+ event_attributes[FEATURE_FLAG_CONTEXT_ID] = context.fully_qualified_key
133
+ end
134
+
135
+ if detail.variation_index
136
+ event_attributes[FEATURE_FLAG_RESULT_VARIANT] = detail.variation_index.to_s
137
+ event_attributes[FEATURE_FLAG_RESULT_VARIATION_INDEX] = detail.variation_index.to_s
138
+ end
139
+
140
+ event_attributes[FEATURE_FLAG_RESULT_VALUE] = serialize_value(detail.value)
141
+
142
+ reason = detail.reason
143
+ if reason.respond_to?(:kind)
144
+ event_attributes[FEATURE_FLAG_RESULT_REASON_KIND] = reason.kind.to_s
145
+
146
+ add_reason_event_details(event_attributes, reason)
147
+ end
148
+
149
+ span.add_event(FEATURE_FLAG_EVENT_NAME, attributes: event_attributes)
150
+ end
151
+
152
+ def add_reason_event_details(event_attributes, reason)
153
+ if reason.respond_to?(:in_experiment) && !reason.in_experiment.nil?
154
+ event_attributes[FEATURE_FLAG_RESULT_REASON_IN_EXPERIMENT] = reason.in_experiment
155
+ end
156
+
157
+ case reason.kind
158
+ when :RULE_MATCH
159
+ if reason.respond_to?(:rule_index) && reason.rule_index
160
+ event_attributes[FEATURE_FLAG_RESULT_REASON_RULE_INDEX] = reason.rule_index
161
+ end
162
+ if reason.respond_to?(:rule_id) && reason.rule_id
163
+ event_attributes[FEATURE_FLAG_RESULT_REASON_RULE_ID] = reason.rule_id
164
+ end
165
+ when :ERROR
166
+ if reason.respond_to?(:error_kind) && reason.error_kind
167
+ event_attributes[FEATURE_FLAG_RESULT_REASON_ERROR_KIND] = reason.error_kind.to_s
168
+ end
169
+ end
170
+ end
171
+
172
+ def handle_evaluation_error(span, detail)
173
+ reason = detail.reason
174
+ return unless reason.respond_to?(:kind) && reason.kind == :ERROR
175
+
176
+ error_kind = reason.respond_to?(:error_kind) ? reason.error_kind.to_s : 'general'
177
+
178
+ error_type = case error_kind.upcase
179
+ when 'FLAG_NOT_FOUND'
180
+ 'flag_not_found'
181
+ when 'MALFORMED_FLAG'
182
+ 'parse_error'
183
+ when 'USER_NOT_SPECIFIED', 'CLIENT_NOT_READY'
184
+ 'provider_not_ready'
185
+ when 'WRONG_TYPE'
186
+ 'type_mismatch'
187
+ else
188
+ 'general'
189
+ end
190
+
191
+ span.set_attribute(ERROR_TYPE, error_type)
192
+
193
+ error_message = "Flag evaluation error: #{error_kind}"
194
+ span.set_attribute(ERROR_MESSAGE, error_message)
195
+
196
+ span.status = OpenTelemetry::Trace::Status.error(error_message)
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LaunchDarklyObservability
4
+ # Rack middleware for request tracing
5
+ #
6
+ # This middleware wraps incoming HTTP requests in OpenTelemetry spans,
7
+ # providing context for flag evaluations that occur during request processing.
8
+ #
9
+ # Works with any Rack-compatible framework (Rails, Sinatra, Grape, Hanami, etc.).
10
+ # In Rails, the Railtie inserts this middleware automatically. For other frameworks,
11
+ # add it manually to your middleware stack:
12
+ #
13
+ # @example Sinatra
14
+ # use LaunchDarklyObservability::Middleware
15
+ #
16
+ # @example Rack::Builder
17
+ # use LaunchDarklyObservability::Middleware
18
+ # run MyApp
19
+ #
20
+ class Middleware
21
+ # Header for observability request context (session/request ID propagation)
22
+ OBSERVABILITY_REQUEST_HEADER = 'HTTP_X_HIGHLIGHT_REQUEST'
23
+
24
+ # Baggage keys for context propagation
25
+ SESSION_BAGGAGE_KEY = 'launchdarkly.session_id'
26
+ REQUEST_BAGGAGE_KEY = 'launchdarkly.request_id'
27
+
28
+ def initialize(app)
29
+ @app = app
30
+ end
31
+
32
+ # Process the request with tracing context
33
+ #
34
+ # @param env [Hash] Rack environment
35
+ # @return [Array] Rack response tuple
36
+ def call(env)
37
+ return @app.call(env) unless tracing_available?
38
+
39
+ request = Rack::Request.new(env)
40
+ session_id, request_id = extract_observability_context(env)
41
+ ctx = set_baggage_context(session_id, request_id)
42
+ app_error = nil
43
+
44
+ OpenTelemetry::Context.with_current(ctx) do
45
+ tracer.in_span(span_name(request), attributes: request_attributes(request)) do |span|
46
+ span.set_attribute(SESSION_BAGGAGE_KEY, session_id) if session_id
47
+ span.set_attribute(REQUEST_BAGGAGE_KEY, request_id) if request_id
48
+
49
+ begin
50
+ status, headers, body = @app.call(env)
51
+ rescue StandardError => e
52
+ app_error = e
53
+ span.record_exception(e, attributes: SourceContext.exception_attributes(e))
54
+ span.status = OpenTelemetry::Trace::Status.error(e.message)
55
+ raise
56
+ end
57
+
58
+ span.set_attribute('http.status_code', status)
59
+ span.status = OpenTelemetry::Trace::Status.error("HTTP #{status}") if status >= 500
60
+ [status, headers, body]
61
+ end
62
+ end
63
+ rescue StandardError => e
64
+ raise if app_error
65
+
66
+ warn "[LaunchDarklyObservability] Middleware error: #{e.message}"
67
+ @app.call(env)
68
+ end
69
+
70
+ private
71
+
72
+ def tracing_available?
73
+ defined?(OpenTelemetry) && OpenTelemetry.tracer_provider
74
+ end
75
+
76
+ def tracer
77
+ @tracer ||= OpenTelemetry.tracer_provider.tracer(
78
+ 'launchdarkly-ruby',
79
+ LaunchDarklyObservability::VERSION
80
+ )
81
+ end
82
+
83
+ def span_name(request)
84
+ "#{request.request_method} #{request.path}"
85
+ end
86
+
87
+ def request_attributes(request)
88
+ {
89
+ 'http.method' => request.request_method,
90
+ 'http.url' => request.url,
91
+ 'http.target' => request.path,
92
+ 'http.host' => request.host,
93
+ 'http.scheme' => request.scheme,
94
+ 'http.user_agent' => request.user_agent
95
+ }.compact
96
+ end
97
+
98
+ def extract_observability_context(env)
99
+ header_value = env[OBSERVABILITY_REQUEST_HEADER]
100
+ return [nil, nil] unless header_value
101
+
102
+ parts = header_value.to_s.split('/')
103
+ session_id = parts[0]&.then { |s| s.empty? ? nil : s }
104
+ request_id = parts[1]&.then { |s| s.empty? ? nil : s }
105
+
106
+ [session_id, request_id]
107
+ end
108
+
109
+ def set_baggage_context(session_id, request_id)
110
+ ctx = OpenTelemetry::Context.current
111
+ ctx = OpenTelemetry::Baggage.set_value(SESSION_BAGGAGE_KEY, session_id, context: ctx) if session_id
112
+ ctx = OpenTelemetry::Baggage.set_value(REQUEST_BAGGAGE_KEY, request_id, context: ctx) if request_id
113
+ ctx
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,272 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'opentelemetry/sdk'
4
+ require 'opentelemetry/exporter/otlp'
5
+ require 'opentelemetry/instrumentation/all'
6
+ require 'opentelemetry/semantic_conventions'
7
+
8
+ module LaunchDarklyObservability
9
+ # Configures OpenTelemetry SDK with appropriate providers and exporters
10
+ # for traces, logs, and metrics.
11
+ #
12
+ # This class handles the setup of:
13
+ # - Tracer provider with OTLP exporter and batch processing
14
+ # - Logger provider with OTLP log exporter (included by default)
15
+ # - Meter provider with OTLP metrics exporter (if available)
16
+ # - Auto-instrumentation for Rails, ActiveRecord, Net::HTTP, etc.
17
+ #
18
+ class OpenTelemetryConfig
19
+ # Default batch processor configuration
20
+ BATCH_SCHEDULE_DELAY_MS = 1000
21
+ BATCH_MAX_EXPORT_SIZE = 128
22
+ BATCH_MAX_QUEUE_SIZE = 1024
23
+
24
+ # Metrics export interval
25
+ METRICS_EXPORT_INTERVAL_MS = 60_000
26
+
27
+ # @return [String] The LaunchDarkly project ID
28
+ attr_reader :project_id
29
+
30
+ # @return [String] The OTLP endpoint
31
+ attr_reader :otlp_endpoint
32
+
33
+ # @return [String] The deployment environment
34
+ attr_reader :environment
35
+
36
+ # @return [OpenTelemetry::SDK::Logs::LoggerProvider, nil] The logger provider (nil if logs disabled or setup failed)
37
+ attr_reader :logger_provider
38
+
39
+ # Initialize OpenTelemetry configuration
40
+ #
41
+ # @param project_id [String] LaunchDarkly project ID
42
+ # @param otlp_endpoint [String] OTLP collector endpoint
43
+ # @param environment [String, nil] Deployment environment name (optional - inferred from SDK key if not provided)
44
+ # @param sdk_metadata [LaunchDarkly::Interfaces::Plugins::SdkMetadata, nil]
45
+ # @param options [Hash] Additional options
46
+ def initialize(project_id:, otlp_endpoint:, environment: nil, sdk_metadata: nil, **options)
47
+ @project_id = project_id
48
+ @otlp_endpoint = otlp_endpoint
49
+ @environment = environment
50
+ @sdk_metadata = sdk_metadata
51
+ @options = options
52
+ @configured = false
53
+ @logger_provider = nil
54
+ @meter_provider = nil
55
+ end
56
+
57
+ # Configure OpenTelemetry SDK
58
+ #
59
+ # Sets up tracer provider with OTLP exporter, logger provider
60
+ # for OTLP log export, and optionally meter provider if available.
61
+ def configure
62
+ return if @configured
63
+
64
+ configure_traces if @options.fetch(:enable_traces, true)
65
+ configure_logs if @options.fetch(:enable_logs, true)
66
+ configure_metrics if @options.fetch(:enable_metrics, true)
67
+
68
+ setup_shutdown_hook
69
+
70
+ @configured = true
71
+ end
72
+
73
+ # Flush all pending telemetry data
74
+ def flush
75
+ OpenTelemetry.tracer_provider&.force_flush
76
+ @logger_provider&.force_flush
77
+ @meter_provider&.force_flush
78
+ rescue StandardError => e
79
+ warn "[LaunchDarklyObservability] Error flushing telemetry: #{e.message}"
80
+ end
81
+
82
+ # Shutdown all providers
83
+ def shutdown
84
+ OpenTelemetry.tracer_provider&.shutdown
85
+ @logger_provider&.shutdown
86
+ @meter_provider&.shutdown
87
+ rescue StandardError => e
88
+ warn "[LaunchDarklyObservability] Error shutting down telemetry: #{e.message}"
89
+ end
90
+
91
+ private
92
+
93
+ # Configure OpenTelemetry traces with OTLP exporter
94
+ def configure_traces
95
+ OpenTelemetry::SDK.configure do |c|
96
+ c.resource = create_resource
97
+ c.add_span_processor(create_batch_span_processor)
98
+
99
+ # Enable auto-instrumentation
100
+ configure_instrumentations(c)
101
+ end
102
+ end
103
+
104
+ # Configure auto-instrumentations with sensible defaults.
105
+ # User-provided instrumentation config is merged on top of defaults,
106
+ # so users only need to specify the instrumentations they want to override.
107
+ def configure_instrumentations(config)
108
+ defaults = {
109
+ 'OpenTelemetry::Instrumentation::Rails' => { enable_recognize_route: true },
110
+ 'OpenTelemetry::Instrumentation::ActiveRecord' => { db_statement: :include },
111
+ 'OpenTelemetry::Instrumentation::Net::HTTP' => { untraced_hosts: [] },
112
+ 'OpenTelemetry::Instrumentation::Rack' => { untraced_endpoints: ['/health', '/healthz', '/ready'] }
113
+ }
114
+
115
+ user_config = @options.fetch(:instrumentations, {})
116
+ config.use_all(defaults.merge(user_config))
117
+ rescue StandardError => e
118
+ warn "[LaunchDarklyObservability] Error configuring instrumentations: #{e.message}"
119
+ end
120
+
121
+ # Configure OpenTelemetry logs with OTLP exporter.
122
+ # The log gems are runtime dependencies, so require should always succeed.
123
+ # If anything goes wrong, we warn once and leave traces unaffected.
124
+ def configure_logs
125
+ require 'opentelemetry-logs-sdk'
126
+ require 'opentelemetry-exporter-otlp-logs'
127
+
128
+ @logger_provider = OpenTelemetry::SDK::Logs::LoggerProvider.new(resource: create_resource)
129
+
130
+ logs_processor = OpenTelemetry::SDK::Logs::Export::BatchLogRecordProcessor.new(
131
+ create_logs_exporter,
132
+ schedule_delay: BATCH_SCHEDULE_DELAY_MS
133
+ )
134
+
135
+ @logger_provider.add_log_record_processor(logs_processor)
136
+
137
+ OpenTelemetry.logger_provider = @logger_provider if OpenTelemetry.respond_to?(:logger_provider=)
138
+ rescue LoadError => e
139
+ warn "[LaunchDarklyObservability] Log gems not available, skipping log configuration: #{e.message}"
140
+ rescue StandardError => e
141
+ warn "[LaunchDarklyObservability] Error configuring logs: #{e.message}"
142
+ end
143
+
144
+ # Configure OpenTelemetry metrics with OTLP exporter
145
+ def configure_metrics
146
+ # Check if metrics SDK is available
147
+ return unless metrics_sdk_available?
148
+
149
+ require 'opentelemetry-metrics-sdk'
150
+ require 'opentelemetry/exporter/otlp/metrics'
151
+
152
+ metric_reader = OpenTelemetry::SDK::Metrics::Export::PeriodicMetricReader.new(
153
+ create_metrics_exporter,
154
+ export_interval_millis: METRICS_EXPORT_INTERVAL_MS
155
+ )
156
+
157
+ @meter_provider = OpenTelemetry::SDK::Metrics::MeterProvider.new(
158
+ resource: create_resource,
159
+ metric_readers: [metric_reader]
160
+ )
161
+
162
+ # Set global meter provider if the method exists
163
+ OpenTelemetry.meter_provider = @meter_provider if OpenTelemetry.respond_to?(:meter_provider=)
164
+ rescue LoadError
165
+ # Metrics SDK not available, skip metrics configuration
166
+ nil
167
+ rescue StandardError => e
168
+ warn "[LaunchDarklyObservability] Error configuring metrics: #{e.message}"
169
+ end
170
+
171
+ # Create OpenTelemetry resource with LaunchDarkly attributes
172
+ def create_resource
173
+ attrs = {
174
+ PROJECT_ID_ATTRIBUTE => @project_id,
175
+ SDK_NAME_ATTRIBUTE => 'opentelemetry',
176
+ SDK_VERSION_ATTRIBUTE => OpenTelemetry::SDK::VERSION,
177
+ SDK_LANGUAGE_ATTRIBUTE => 'ruby',
178
+ DISTRO_NAME_ATTRIBUTE => 'launchdarkly-observability-ruby',
179
+ DISTRO_VERSION_ATTRIBUTE => LaunchDarklyObservability::VERSION
180
+ }
181
+
182
+ # Only set deployment.environment if explicitly provided
183
+ # Otherwise, backend infers it from the SDK key
184
+ if @environment && !@environment.empty?
185
+ attrs[OpenTelemetry::SemanticConventions::Resource::DEPLOYMENT_ENVIRONMENT] = @environment
186
+ end
187
+
188
+ # Add service name
189
+ service_name = @options[:service_name] || infer_service_name
190
+ attrs[OpenTelemetry::SemanticConventions::Resource::SERVICE_NAME] = service_name if service_name
191
+
192
+ # Add service version
193
+ service_version = @options[:service_version]
194
+ attrs[OpenTelemetry::SemanticConventions::Resource::SERVICE_VERSION] = service_version if service_version
195
+
196
+ # Add SDK metadata if available
197
+ if @sdk_metadata
198
+ attrs['launchdarkly.sdk.name'] = @sdk_metadata.name if @sdk_metadata.respond_to?(:name)
199
+ attrs['launchdarkly.sdk.version'] = @sdk_metadata.version if @sdk_metadata.respond_to?(:version)
200
+ end
201
+
202
+ OpenTelemetry::SDK::Resources::Resource.create(attrs)
203
+ end
204
+
205
+ # Create batch span processor with OTLP exporter
206
+ def create_batch_span_processor
207
+ OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(
208
+ create_trace_exporter,
209
+ schedule_delay: BATCH_SCHEDULE_DELAY_MS,
210
+ max_export_batch_size: BATCH_MAX_EXPORT_SIZE,
211
+ max_queue_size: BATCH_MAX_QUEUE_SIZE
212
+ )
213
+ end
214
+
215
+ # Create OTLP trace exporter
216
+ def create_trace_exporter
217
+ OpenTelemetry::Exporter::OTLP::Exporter.new(
218
+ endpoint: "#{@otlp_endpoint}/v1/traces",
219
+ compression: 'gzip'
220
+ )
221
+ end
222
+
223
+ # Create OTLP logs exporter
224
+ def create_logs_exporter
225
+ OpenTelemetry::Exporter::OTLP::Logs::LogsExporter.new(
226
+ endpoint: "#{@otlp_endpoint}/v1/logs",
227
+ compression: 'gzip'
228
+ )
229
+ end
230
+
231
+ # Create OTLP metrics exporter
232
+ def create_metrics_exporter
233
+ OpenTelemetry::Exporter::OTLP::Metrics::MetricsExporter.new(
234
+ endpoint: "#{@otlp_endpoint}/v1/metrics",
235
+ compression: 'gzip'
236
+ )
237
+ end
238
+
239
+ # Infer service name from Rails or environment
240
+ def infer_service_name
241
+ if defined?(::Rails) && ::Rails.respond_to?(:application)
242
+ app_class = ::Rails.application.class
243
+ if app_class.respond_to?(:module_parent_name)
244
+ app_class.module_parent_name.underscore
245
+ else
246
+ app_class.parent_name&.underscore
247
+ end
248
+ else
249
+ ENV.fetch('OTEL_SERVICE_NAME', nil)
250
+ end
251
+ end
252
+
253
+ # Check if metrics SDK gem is available
254
+ def metrics_sdk_available?
255
+ Gem::Specification.find_by_name('opentelemetry-metrics-sdk')
256
+ true
257
+ rescue Gem::MissingSpecError
258
+ false
259
+ end
260
+
261
+ # Setup graceful exit hook to flush pending telemetry data.
262
+ #
263
+ # Only flushes (not shuts down) because frameworks like Sinatra start
264
+ # their servers inside at_exit handlers. Since at_exit runs in LIFO
265
+ # order, a shutdown registered after the framework's handler would
266
+ # execute BEFORE the server starts, stopping the TracerProvider and
267
+ # causing all spans to be non-recording for the server's lifetime.
268
+ def setup_shutdown_hook
269
+ at_exit { flush }
270
+ end
271
+ end
272
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LaunchDarklyObservability
4
+ # A Logger that forwards messages to the OpenTelemetry Logs pipeline.
5
+ #
6
+ # When used as a broadcast target (Rails), pass only the logger_provider.
7
+ # When used standalone (Sinatra, plain Ruby), pass `io:` to also write
8
+ # to a local destination such as $stdout.
9
+ #
10
+ # @example Standalone usage (non-Rails)
11
+ # logger = LaunchDarklyObservability.logger # writes to $stdout + OTel
12
+ #
13
+ # @example Manually attaching (the Railtie does this automatically)
14
+ # bridge = LaunchDarklyObservability::OtelLogBridge.new(logger_provider)
15
+ # Rails.logger.broadcast_to(bridge) # Rails >= 7.1
16
+ #
17
+ class OtelLogBridge < ::Logger
18
+ # OpenTelemetry severity numbers (base value per level).
19
+ # See: https://opentelemetry.io/docs/specs/otel/logs/data-model/#severity-fields
20
+ SEVERITY_NUMBER = {
21
+ ::Logger::DEBUG => 5,
22
+ ::Logger::INFO => 9,
23
+ ::Logger::WARN => 13,
24
+ ::Logger::ERROR => 17,
25
+ ::Logger::FATAL => 21,
26
+ ::Logger::UNKNOWN => 0
27
+ }.freeze
28
+
29
+ SEVERITY_TEXT = {
30
+ ::Logger::DEBUG => 'DEBUG',
31
+ ::Logger::INFO => 'INFO',
32
+ ::Logger::WARN => 'WARN',
33
+ ::Logger::ERROR => 'ERROR',
34
+ ::Logger::FATAL => 'FATAL',
35
+ ::Logger::UNKNOWN => 'UNKNOWN'
36
+ }.freeze
37
+
38
+ # @param logger_provider [OpenTelemetry::SDK::Logs::LoggerProvider]
39
+ # @param io [IO, nil] Optional IO for local output (e.g. $stdout).
40
+ # When nil the bridge only emits to OTel (suitable for broadcast).
41
+ def initialize(logger_provider, io: nil)
42
+ super(File::NULL)
43
+ @otel_logger = logger_provider.logger(
44
+ name: 'launchdarkly-observability-ruby',
45
+ version: LaunchDarklyObservability::VERSION
46
+ )
47
+ @local_logger = io ? ::Logger.new(io) : nil
48
+ end
49
+
50
+ # Propagate level changes to the local logger so filtering stays in sync.
51
+ def level=(severity)
52
+ super
53
+ @local_logger&.level = severity
54
+ end
55
+
56
+ # Propagate formatter changes to the local logger.
57
+ def formatter=(formatter)
58
+ super
59
+ @local_logger&.formatter = formatter
60
+ end
61
+
62
+ # Core method that debug/info/warn/error/fatal all delegate to.
63
+ def add(severity, message = nil, progname = nil)
64
+ severity ||= ::Logger::UNKNOWN
65
+ return true if severity < level
66
+
67
+ if message.nil?
68
+ if block_given?
69
+ message = yield
70
+ else
71
+ message = progname
72
+ progname = nil
73
+ end
74
+ end
75
+
76
+ return true if message.nil?
77
+
78
+ attributes = {}
79
+ if message.is_a?(Hash)
80
+ attributes = message.each_with_object({}) { |(k, v), h| h[k.to_s] = v.to_s }
81
+ body = message.inspect
82
+ else
83
+ body = message.to_s
84
+ end
85
+
86
+ begin
87
+ @otel_logger.on_emit(
88
+ body: body,
89
+ severity_number: SEVERITY_NUMBER.fetch(severity, 0),
90
+ severity_text: SEVERITY_TEXT.fetch(severity, 'UNKNOWN'),
91
+ timestamp: Time.now,
92
+ context: OpenTelemetry::Context.current,
93
+ attributes: attributes
94
+ )
95
+ rescue StandardError
96
+ # OTel export failures must not suppress local IO output.
97
+ end
98
+
99
+ begin
100
+ @local_logger&.add(severity, message, progname)
101
+ rescue StandardError
102
+ # Local IO failures must not propagate.
103
+ end
104
+
105
+ true
106
+ end
107
+ end
108
+ end