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,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'launchdarkly-server-sdk'
|
|
4
|
+
|
|
5
|
+
module LaunchDarklyObservability
|
|
6
|
+
# LaunchDarkly SDK Plugin that provides observability instrumentation.
|
|
7
|
+
#
|
|
8
|
+
# This plugin integrates with the LaunchDarkly Ruby SDK to automatically
|
|
9
|
+
# instrument flag evaluations with OpenTelemetry traces, logs, and metrics.
|
|
10
|
+
#
|
|
11
|
+
# @example Basic usage (SDK key and environment automatically extracted)
|
|
12
|
+
# plugin = LaunchDarklyObservability::Plugin.new
|
|
13
|
+
# config = LaunchDarkly::Config.new(plugins: [plugin])
|
|
14
|
+
# client = LaunchDarkly::LDClient.new(ENV['LAUNCHDARKLY_SDK_KEY'], config)
|
|
15
|
+
#
|
|
16
|
+
class Plugin
|
|
17
|
+
include LaunchDarkly::Interfaces::Plugins::Plugin
|
|
18
|
+
|
|
19
|
+
# @return [String] The LaunchDarkly project ID
|
|
20
|
+
attr_reader :project_id
|
|
21
|
+
|
|
22
|
+
# @return [String] The OTLP endpoint URL
|
|
23
|
+
attr_reader :otlp_endpoint
|
|
24
|
+
|
|
25
|
+
# @return [String] The deployment environment
|
|
26
|
+
attr_reader :environment
|
|
27
|
+
|
|
28
|
+
# @return [Hash] Additional options
|
|
29
|
+
attr_reader :options
|
|
30
|
+
|
|
31
|
+
# Initialize a new observability plugin
|
|
32
|
+
#
|
|
33
|
+
# @param project_id [String, nil] LaunchDarkly project ID for routing telemetry.
|
|
34
|
+
# If not provided, the SDK key from the client will be used automatically.
|
|
35
|
+
# @param sdk_key [String, nil] LaunchDarkly SDK key (optional - will be extracted from client if not provided).
|
|
36
|
+
# The backend will derive the project and environment from the SDK key.
|
|
37
|
+
# @param otlp_endpoint [String] OTLP collector endpoint (default: LaunchDarkly's endpoint)
|
|
38
|
+
# @param environment [String, nil] Deployment environment name (optional - inferred from SDK key by default).
|
|
39
|
+
# Only specify this for advanced scenarios like deployment-specific suffixes (e.g., 'production-canary').
|
|
40
|
+
# @param options [Hash] Additional configuration options
|
|
41
|
+
# @option options [String] :service_name Service name for resource attributes
|
|
42
|
+
# @option options [String] :service_version Service version for resource attributes
|
|
43
|
+
# @option options [Hash] :instrumentations Configuration for OpenTelemetry auto-instrumentations
|
|
44
|
+
# @option options [Boolean] :enable_traces Enable trace instrumentation (default: true)
|
|
45
|
+
# @option options [Boolean] :enable_logs Enable log instrumentation (default: true)
|
|
46
|
+
# @option options [Boolean] :enable_metrics Enable metrics instrumentation (default: true)
|
|
47
|
+
def initialize(project_id: nil, sdk_key: nil, otlp_endpoint: DEFAULT_ENDPOINT, environment: nil, **options)
|
|
48
|
+
@project_id = project_id || sdk_key
|
|
49
|
+
@otlp_endpoint = otlp_endpoint
|
|
50
|
+
@environment = environment&.to_s
|
|
51
|
+
@options = default_options.merge(options)
|
|
52
|
+
@hook = Hook.new
|
|
53
|
+
@otel_config = nil
|
|
54
|
+
@registered = false
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Returns metadata about this plugin
|
|
58
|
+
#
|
|
59
|
+
# @return [LaunchDarkly::Interfaces::Plugins::PluginMetadata]
|
|
60
|
+
def metadata
|
|
61
|
+
LaunchDarkly::Interfaces::Plugins::PluginMetadata.new('launchdarkly-observability')
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Returns the hooks provided by this plugin
|
|
65
|
+
#
|
|
66
|
+
# @param _environment_metadata [LaunchDarkly::Interfaces::Plugins::EnvironmentMetadata]
|
|
67
|
+
# @return [Array<LaunchDarkly::Interfaces::Hooks::Hook>]
|
|
68
|
+
def get_hooks(_environment_metadata)
|
|
69
|
+
[@hook]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Register the plugin with the LaunchDarkly client
|
|
73
|
+
#
|
|
74
|
+
# This method is called during SDK initialization. It sets up the
|
|
75
|
+
# OpenTelemetry SDK with appropriate providers and exporters.
|
|
76
|
+
#
|
|
77
|
+
# @param _client [LaunchDarkly::LDClient] The LaunchDarkly client instance
|
|
78
|
+
# @param environment_metadata [LaunchDarkly::Interfaces::Plugins::EnvironmentMetadata]
|
|
79
|
+
def register(_client, environment_metadata)
|
|
80
|
+
return if @registered
|
|
81
|
+
|
|
82
|
+
# Use provided project_id, or extract SDK key from the client
|
|
83
|
+
project_id = @project_id || environment_metadata&.sdk_key
|
|
84
|
+
|
|
85
|
+
if project_id.nil? || project_id.empty?
|
|
86
|
+
raise ArgumentError, 'Unable to determine project_id: no project_id or sdk_key provided, and client SDK key is unavailable'
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
@otel_config = OpenTelemetryConfig.new(
|
|
90
|
+
project_id: project_id,
|
|
91
|
+
otlp_endpoint: @otlp_endpoint,
|
|
92
|
+
environment: @environment,
|
|
93
|
+
sdk_metadata: environment_metadata&.sdk,
|
|
94
|
+
**@options
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
@otel_config.configure
|
|
98
|
+
|
|
99
|
+
@registered = true
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Check if the plugin has been registered
|
|
103
|
+
#
|
|
104
|
+
# @return [Boolean]
|
|
105
|
+
def registered?
|
|
106
|
+
@registered
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Flush all pending telemetry data
|
|
110
|
+
def flush
|
|
111
|
+
@otel_config&.flush
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Shutdown the plugin and flush remaining data
|
|
115
|
+
def shutdown
|
|
116
|
+
@otel_config&.shutdown
|
|
117
|
+
@registered = false
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
def default_options
|
|
123
|
+
{
|
|
124
|
+
enable_traces: true,
|
|
125
|
+
enable_logs: true,
|
|
126
|
+
enable_metrics: true,
|
|
127
|
+
service_name: nil,
|
|
128
|
+
service_version: nil,
|
|
129
|
+
instrumentations: {}
|
|
130
|
+
}
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'middleware'
|
|
4
|
+
|
|
5
|
+
module LaunchDarklyObservability
|
|
6
|
+
if defined?(::Rails::Railtie)
|
|
7
|
+
# Rails Railtie for automatic integration
|
|
8
|
+
#
|
|
9
|
+
# This Railtie automatically:
|
|
10
|
+
# - Inserts the LaunchDarkly middleware into the Rails middleware stack
|
|
11
|
+
# - Bridges Rails.logger to the OpenTelemetry Logs pipeline (if logger provider is available)
|
|
12
|
+
# - Provides helper methods for controllers and views
|
|
13
|
+
#
|
|
14
|
+
# @example The Railtie is automatically loaded when Rails is detected
|
|
15
|
+
# # In config/initializers/launchdarkly.rb
|
|
16
|
+
# LaunchDarklyObservability.init(project_id: ENV['LD_PROJECT_ID'])
|
|
17
|
+
#
|
|
18
|
+
class Railtie < ::Rails::Railtie
|
|
19
|
+
initializer 'launchdarkly_observability.configure_rails' do |app|
|
|
20
|
+
app.middleware.insert_before(0, LaunchDarklyObservability::Middleware)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
config.after_initialize do
|
|
24
|
+
if defined?(ActionController::Base)
|
|
25
|
+
ActionController::Base.include(LaunchDarklyObservability::ControllerHelpers)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
if defined?(ActionController::API)
|
|
29
|
+
ActionController::API.include(LaunchDarklyObservability::ControllerHelpers)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
attach_otel_log_bridge
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
class << self
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def attach_otel_log_bridge
|
|
39
|
+
return unless otel_logger_provider_available?
|
|
40
|
+
|
|
41
|
+
bridge = LaunchDarklyObservability::OtelLogBridge.new(OpenTelemetry.logger_provider)
|
|
42
|
+
|
|
43
|
+
if ::Rails.logger.respond_to?(:broadcast_to)
|
|
44
|
+
::Rails.logger.broadcast_to(bridge)
|
|
45
|
+
elsif defined?(ActiveSupport::Logger) && ActiveSupport::Logger.respond_to?(:broadcast)
|
|
46
|
+
::Rails.logger.extend(ActiveSupport::Logger.broadcast(bridge))
|
|
47
|
+
end
|
|
48
|
+
rescue StandardError => e
|
|
49
|
+
warn "[LaunchDarklyObservability] Could not attach log bridge to Rails.logger: #{e.message}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def otel_logger_provider_available?
|
|
53
|
+
LaunchDarklyObservability.send(:otel_logger_provider_available?)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Controller helper methods for Rails
|
|
59
|
+
#
|
|
60
|
+
# These helpers provide convenient access to observability features
|
|
61
|
+
# within Rails controllers.
|
|
62
|
+
#
|
|
63
|
+
module ControllerHelpers
|
|
64
|
+
extend ActiveSupport::Concern
|
|
65
|
+
|
|
66
|
+
included do
|
|
67
|
+
helper_method :launchdarkly_trace_id if respond_to?(:helper_method)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @return [String, nil] The current OpenTelemetry trace ID
|
|
71
|
+
def launchdarkly_trace_id
|
|
72
|
+
return nil unless defined?(OpenTelemetry)
|
|
73
|
+
|
|
74
|
+
span = OpenTelemetry::Trace.current_span
|
|
75
|
+
return nil unless span&.context&.valid?
|
|
76
|
+
|
|
77
|
+
span.context.hex_trace_id
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# @param name [String] The span name
|
|
81
|
+
# @param attributes [Hash] Span attributes
|
|
82
|
+
# @yield [span] Block to execute within the span
|
|
83
|
+
# @return The result of the block
|
|
84
|
+
def with_launchdarkly_span(name, attributes: {}, &block)
|
|
85
|
+
return yield unless defined?(OpenTelemetry) && OpenTelemetry.tracer_provider
|
|
86
|
+
|
|
87
|
+
tracer = OpenTelemetry.tracer_provider.tracer(
|
|
88
|
+
'launchdarkly-ruby-rails',
|
|
89
|
+
LaunchDarklyObservability::VERSION
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
tracer.in_span(name, attributes: attributes, &block)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# @param exception [Exception] The exception to record
|
|
96
|
+
# @param attributes [Hash] Additional attributes
|
|
97
|
+
def record_launchdarkly_exception(exception, attributes: {})
|
|
98
|
+
return unless defined?(OpenTelemetry)
|
|
99
|
+
|
|
100
|
+
span = OpenTelemetry::Trace.current_span
|
|
101
|
+
return unless span
|
|
102
|
+
|
|
103
|
+
span.record_exception(exception, attributes: SourceContext.exception_attributes(exception).merge(attributes))
|
|
104
|
+
span.status = OpenTelemetry::Trace::Status.error(exception.message)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# View helpers for Rails
|
|
109
|
+
#
|
|
110
|
+
# These helpers can be used in views to inject tracing context
|
|
111
|
+
# into the rendered HTML for client-side correlation.
|
|
112
|
+
#
|
|
113
|
+
module ViewHelpers
|
|
114
|
+
# @return [String] HTML meta tag with traceparent value
|
|
115
|
+
def launchdarkly_traceparent_meta_tag
|
|
116
|
+
traceparent = launchdarkly_traceparent
|
|
117
|
+
return '' unless traceparent
|
|
118
|
+
|
|
119
|
+
tag.meta(name: 'traceparent', content: traceparent)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# @return [String, nil] The traceparent header value
|
|
123
|
+
def launchdarkly_traceparent
|
|
124
|
+
return nil unless defined?(OpenTelemetry)
|
|
125
|
+
|
|
126
|
+
span = OpenTelemetry::Trace.current_span
|
|
127
|
+
return nil unless span&.context&.valid?
|
|
128
|
+
|
|
129
|
+
trace_id = span.context.hex_trace_id
|
|
130
|
+
span_id = span.context.hex_span_id
|
|
131
|
+
trace_flags = span.context.trace_flags.sampled? ? '01' : '00'
|
|
132
|
+
|
|
133
|
+
"00-#{trace_id}-#{span_id}-#{trace_flags}"
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
if defined?(ActionView::Base)
|
|
138
|
+
ActionView::Base.include(ViewHelpers)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module LaunchDarklyObservability
|
|
6
|
+
module SourceContext
|
|
7
|
+
CONTEXT_LINES = 4
|
|
8
|
+
MAX_FRAMES = 20
|
|
9
|
+
MAX_LINE_LENGTH = 1000
|
|
10
|
+
BACKTRACE_LINE_PATTERN = /^(.+):(\d+)(?::in [`'](.+?)')?$/
|
|
11
|
+
|
|
12
|
+
@file_cache = {}
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def build_structured_stacktrace(exception)
|
|
17
|
+
return nil unless exception
|
|
18
|
+
|
|
19
|
+
backtrace = exception.backtrace
|
|
20
|
+
return nil unless backtrace&.any?
|
|
21
|
+
|
|
22
|
+
error_message = begin
|
|
23
|
+
exception.full_message(highlight: false, order: :top).to_s.lines.first&.chomp
|
|
24
|
+
rescue StandardError
|
|
25
|
+
exception.message
|
|
26
|
+
end
|
|
27
|
+
frames = backtrace.first(MAX_FRAMES).filter_map do |backtrace_line|
|
|
28
|
+
build_frame(backtrace_line, error_message)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
frames.empty? ? nil : frames
|
|
32
|
+
rescue StandardError
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Build a Hash of span attributes for an exception's structured stacktrace.
|
|
37
|
+
# Returns an empty hash when no stacktrace can be built, so the result is
|
|
38
|
+
# safe to merge directly into an attributes hash.
|
|
39
|
+
#
|
|
40
|
+
# @param exception [Exception]
|
|
41
|
+
# @return [Hash]
|
|
42
|
+
def exception_attributes(exception)
|
|
43
|
+
stacktrace = build_structured_stacktrace(exception)
|
|
44
|
+
return {} unless stacktrace
|
|
45
|
+
|
|
46
|
+
{ 'exception.structured_stacktrace' => stacktrace.to_json }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def read_source_context(file_name, line_number)
|
|
50
|
+
return nil unless file_name && line_number
|
|
51
|
+
return nil unless File.exist?(file_name) && File.readable?(file_name)
|
|
52
|
+
|
|
53
|
+
source_lines = cached_source_lines(file_name)
|
|
54
|
+
return nil unless source_lines
|
|
55
|
+
return nil if line_number <= 0 || line_number > source_lines.length
|
|
56
|
+
|
|
57
|
+
target_index = line_number - 1
|
|
58
|
+
before_start = [target_index - CONTEXT_LINES, 0].max
|
|
59
|
+
before_lines = source_lines[before_start...target_index] || []
|
|
60
|
+
after_end = [target_index + CONTEXT_LINES, source_lines.length - 1].min
|
|
61
|
+
after_lines = source_lines[(target_index + 1)..after_end] || []
|
|
62
|
+
|
|
63
|
+
{
|
|
64
|
+
lineContent: source_lines[target_index],
|
|
65
|
+
linesBefore: before_lines.empty? ? nil : before_lines.join("\n"),
|
|
66
|
+
linesAfter: after_lines.empty? ? nil : after_lines.join("\n")
|
|
67
|
+
}
|
|
68
|
+
rescue StandardError
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def build_frame(backtrace_line, error_message)
|
|
73
|
+
matches = BACKTRACE_LINE_PATTERN.match(backtrace_line)
|
|
74
|
+
return nil unless matches
|
|
75
|
+
|
|
76
|
+
file_name = matches[1]
|
|
77
|
+
line_number = matches[2].to_i
|
|
78
|
+
function_name = matches[3]
|
|
79
|
+
|
|
80
|
+
frame = {
|
|
81
|
+
fileName: file_name,
|
|
82
|
+
lineNumber: line_number,
|
|
83
|
+
error: error_message
|
|
84
|
+
}
|
|
85
|
+
frame[:functionName] = function_name if function_name
|
|
86
|
+
|
|
87
|
+
source_context = read_source_context(file_name, line_number)
|
|
88
|
+
if source_context
|
|
89
|
+
frame[:lineContent] = source_context[:lineContent]
|
|
90
|
+
frame[:linesBefore] = source_context[:linesBefore] if source_context[:linesBefore]
|
|
91
|
+
frame[:linesAfter] = source_context[:linesAfter] if source_context[:linesAfter]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
frame
|
|
95
|
+
end
|
|
96
|
+
private_class_method :build_frame
|
|
97
|
+
|
|
98
|
+
def cached_source_lines(file_name)
|
|
99
|
+
return @file_cache[file_name] if @file_cache.key?(file_name)
|
|
100
|
+
|
|
101
|
+
lines = File.readlines(file_name, chomp: true).map do |line|
|
|
102
|
+
line.length > MAX_LINE_LENGTH ? line[0...MAX_LINE_LENGTH] : line
|
|
103
|
+
end
|
|
104
|
+
@file_cache[file_name] = lines
|
|
105
|
+
lines
|
|
106
|
+
rescue StandardError
|
|
107
|
+
@file_cache[file_name] = nil
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
private_class_method :cached_source_lines
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,181 @@
|
|
|
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
|
+
require_relative 'launchdarkly_observability/version'
|
|
9
|
+
require_relative 'launchdarkly_observability/hook'
|
|
10
|
+
require_relative 'launchdarkly_observability/opentelemetry_config'
|
|
11
|
+
require_relative 'launchdarkly_observability/plugin'
|
|
12
|
+
require_relative 'launchdarkly_observability/source_context'
|
|
13
|
+
|
|
14
|
+
require_relative 'launchdarkly_observability/middleware'
|
|
15
|
+
require_relative 'launchdarkly_observability/otel_log_bridge'
|
|
16
|
+
require_relative 'launchdarkly_observability/rails'
|
|
17
|
+
|
|
18
|
+
module LaunchDarklyObservability
|
|
19
|
+
# Default OTLP endpoint for LaunchDarkly Observability
|
|
20
|
+
DEFAULT_ENDPOINT = 'https://otel.observability.app.launchdarkly.com:4318'
|
|
21
|
+
|
|
22
|
+
# Resource attribute keys
|
|
23
|
+
PROJECT_ID_ATTRIBUTE = 'launchdarkly.project_id'
|
|
24
|
+
SDK_NAME_ATTRIBUTE = 'telemetry.sdk.name'
|
|
25
|
+
SDK_VERSION_ATTRIBUTE = 'telemetry.sdk.version'
|
|
26
|
+
SDK_LANGUAGE_ATTRIBUTE = 'telemetry.sdk.language'
|
|
27
|
+
DISTRO_NAME_ATTRIBUTE = 'telemetry.distro.name'
|
|
28
|
+
DISTRO_VERSION_ATTRIBUTE = 'telemetry.distro.version'
|
|
29
|
+
|
|
30
|
+
# OpenTelemetry semantic convention attribute keys for feature flags
|
|
31
|
+
# See: https://opentelemetry.io/docs/specs/semconv/feature-flags/feature-flags-events/
|
|
32
|
+
FEATURE_FLAG_KEY = 'feature_flag.key'
|
|
33
|
+
FEATURE_FLAG_PROVIDER_NAME = 'feature_flag.provider.name'
|
|
34
|
+
FEATURE_FLAG_CONTEXT_ID = 'feature_flag.context.id'
|
|
35
|
+
FEATURE_FLAG_SET_ID = 'feature_flag.set.id'
|
|
36
|
+
FEATURE_FLAG_RESULT_VALUE = 'feature_flag.result.value'
|
|
37
|
+
FEATURE_FLAG_RESULT_VARIANT = 'feature_flag.result.variant'
|
|
38
|
+
FEATURE_FLAG_RESULT_VARIATION_INDEX = 'feature_flag.result.variationIndex'
|
|
39
|
+
FEATURE_FLAG_RESULT_REASON_KIND = 'feature_flag.result.reason.kind'
|
|
40
|
+
FEATURE_FLAG_RESULT_REASON_IN_EXPERIMENT = 'feature_flag.result.reason.inExperiment'
|
|
41
|
+
FEATURE_FLAG_RESULT_REASON_ERROR_KIND = 'feature_flag.result.reason.errorKind'
|
|
42
|
+
FEATURE_FLAG_RESULT_REASON_RULE_ID = 'feature_flag.result.reason.ruleId'
|
|
43
|
+
FEATURE_FLAG_RESULT_REASON_RULE_INDEX = 'feature_flag.result.reason.ruleIndex'
|
|
44
|
+
ERROR_TYPE = 'error.type'
|
|
45
|
+
ERROR_MESSAGE = 'error.message'
|
|
46
|
+
|
|
47
|
+
class << self
|
|
48
|
+
# @return [Plugin, nil] The current plugin instance
|
|
49
|
+
attr_reader :instance
|
|
50
|
+
|
|
51
|
+
# Initialize the observability plugin
|
|
52
|
+
#
|
|
53
|
+
# @param project_id [String, nil] LaunchDarkly project ID (optional - SDK key will be extracted from client if not provided)
|
|
54
|
+
# @param sdk_key [String, nil] LaunchDarkly SDK key (optional - will be extracted from client if not provided)
|
|
55
|
+
# @param options [Hash] Additional configuration options
|
|
56
|
+
# @option options [String] :otlp_endpoint Custom OTLP endpoint URL
|
|
57
|
+
# @option options [String] :environment Deployment environment (optional - inferred from SDK key by default)
|
|
58
|
+
# @option options [String] :service_name Service name for traces
|
|
59
|
+
# @option options [String] :service_version Service version
|
|
60
|
+
# @option options [Hash] :instrumentations Configuration for auto-instrumentations
|
|
61
|
+
# @return [Plugin] The initialized plugin
|
|
62
|
+
def init(project_id: nil, sdk_key: nil, **options)
|
|
63
|
+
@instance = Plugin.new(project_id: project_id, sdk_key: sdk_key, **options)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Check if the plugin has been initialized
|
|
67
|
+
#
|
|
68
|
+
# @return [Boolean] true if initialized
|
|
69
|
+
def initialized?
|
|
70
|
+
!@instance.nil?
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Create a custom span for manual instrumentation
|
|
74
|
+
#
|
|
75
|
+
# This method matches the OpenTelemetry API naming convention for consistency.
|
|
76
|
+
#
|
|
77
|
+
# @param name [String] The span name
|
|
78
|
+
# @param attributes [Hash] Optional span attributes
|
|
79
|
+
# @yield [span] Block to execute within the span context
|
|
80
|
+
# @return The result of the block
|
|
81
|
+
#
|
|
82
|
+
# @example Create a custom span
|
|
83
|
+
# LaunchDarklyObservability.in_span('database-query') do |span|
|
|
84
|
+
# span.set_attribute('db.table', 'users')
|
|
85
|
+
# perform_query
|
|
86
|
+
# end
|
|
87
|
+
def in_span(name, attributes: {})
|
|
88
|
+
unless defined?(OpenTelemetry) && OpenTelemetry.tracer_provider
|
|
89
|
+
return yield if block_given?
|
|
90
|
+
return
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
tracer = OpenTelemetry.tracer_provider.tracer(
|
|
94
|
+
'launchdarkly-observability',
|
|
95
|
+
LaunchDarklyObservability::VERSION
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
tracer.in_span(name, attributes: attributes) do |span|
|
|
99
|
+
yield(span) if block_given?
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Record an exception in the current span
|
|
104
|
+
#
|
|
105
|
+
# @param exception [Exception] The exception to record
|
|
106
|
+
# @param attributes [Hash] Additional attributes
|
|
107
|
+
#
|
|
108
|
+
# @example Record an exception
|
|
109
|
+
# begin
|
|
110
|
+
# risky_operation
|
|
111
|
+
# rescue => e
|
|
112
|
+
# LaunchDarklyObservability.record_exception(e, foo: 'bar')
|
|
113
|
+
# raise
|
|
114
|
+
# end
|
|
115
|
+
def record_exception(exception, attributes: {})
|
|
116
|
+
return unless defined?(OpenTelemetry)
|
|
117
|
+
|
|
118
|
+
span = OpenTelemetry::Trace.current_span
|
|
119
|
+
return unless span
|
|
120
|
+
|
|
121
|
+
span.record_exception(exception, attributes: SourceContext.exception_attributes(exception).merge(attributes))
|
|
122
|
+
span.status = OpenTelemetry::Trace::Status.error(exception.message)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Create a Logger that writes to both a local IO and the OTel Logs pipeline.
|
|
126
|
+
#
|
|
127
|
+
# Use this in non-Rails applications (Sinatra, Grape, plain Ruby) to get
|
|
128
|
+
# log export with trace correlation out of the box. Must be called after
|
|
129
|
+
# the Plugin has been registered (i.e. after LDClient.new).
|
|
130
|
+
#
|
|
131
|
+
# @param output [IO] Local IO destination (default: $stdout)
|
|
132
|
+
# @return [OtelLogBridge, Logger] An OTel-bridged logger, or a plain
|
|
133
|
+
# Logger if the OTel logger provider is not yet available.
|
|
134
|
+
#
|
|
135
|
+
# @example Sinatra
|
|
136
|
+
# $logger = LaunchDarklyObservability.logger
|
|
137
|
+
# $logger.info 'This goes to stdout AND is exported as an OTLP log record'
|
|
138
|
+
def logger(output = $stdout)
|
|
139
|
+
if otel_logger_provider_available?
|
|
140
|
+
OtelLogBridge.new(OpenTelemetry.logger_provider, io: output)
|
|
141
|
+
else
|
|
142
|
+
::Logger.new(output)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Get the current trace ID
|
|
147
|
+
#
|
|
148
|
+
# @return [String, nil] The current trace ID in hex format
|
|
149
|
+
#
|
|
150
|
+
# @example Get trace ID for logging
|
|
151
|
+
# trace_id = LaunchDarklyObservability.current_trace_id
|
|
152
|
+
# logger.info "Processing request: #{trace_id}"
|
|
153
|
+
def current_trace_id
|
|
154
|
+
return nil unless defined?(OpenTelemetry)
|
|
155
|
+
|
|
156
|
+
span = OpenTelemetry::Trace.current_span
|
|
157
|
+
return nil unless span&.context&.valid?
|
|
158
|
+
|
|
159
|
+
span.context.hex_trace_id
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Flush all pending telemetry data
|
|
163
|
+
def flush
|
|
164
|
+
@instance&.flush
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Shutdown the plugin and flush remaining data
|
|
168
|
+
def shutdown
|
|
169
|
+
@instance&.shutdown
|
|
170
|
+
@instance = nil
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
private
|
|
174
|
+
|
|
175
|
+
def otel_logger_provider_available?
|
|
176
|
+
defined?(OpenTelemetry::SDK::Logs::LoggerProvider) &&
|
|
177
|
+
OpenTelemetry.respond_to?(:logger_provider) &&
|
|
178
|
+
OpenTelemetry.logger_provider.is_a?(OpenTelemetry::SDK::Logs::LoggerProvider)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|