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,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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LaunchDarklyObservability
4
+ VERSION = '0.2.0' # x-release-please-version
5
+ 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