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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +31 -0
- data/LICENSE.txt +190 -0
- data/README.md +685 -0
- data/lib/launchdarkly_observability/hook.rb +199 -0
- data/lib/launchdarkly_observability/middleware.rb +116 -0
- data/lib/launchdarkly_observability/opentelemetry_config.rb +272 -0
- data/lib/launchdarkly_observability/otel_log_bridge.rb +108 -0
- data/lib/launchdarkly_observability/plugin.rb +133 -0
- data/lib/launchdarkly_observability/rails.rb +141 -0
- data/lib/launchdarkly_observability/source_context.rb +112 -0
- data/lib/launchdarkly_observability/version.rb +5 -0
- data/lib/launchdarkly_observability.rb +181 -0
- metadata +200 -0
|
@@ -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
|