bugsnag_performance 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 31cfcfc45215dec768c1e6040c418f63355ea308cf2d8d660ccad2c3f2de3621
4
+ data.tar.gz: e3868f8cd3341f285e0bd868c3db54e640739ed0a755308162c3dac96cf47b76
5
+ SHA512:
6
+ metadata.gz: f394a3a656e3bbd76aa825db67b2e6bd156590d666648ca6484df820fc833f8904e24dce311084bcfc64ff4e064f5bbcd181a92e51a60d27c7ccedf7a029ccd4
7
+ data.tar.gz: 9f8ad547b130279f751169c2305ce57faaf34a566782abca3df7fc307efbab8b14873c6ef0946069a0f49f159d40d8b225969eaf5defa5bb236b402e10afd05d
data/CHANGELOG.md ADDED
@@ -0,0 +1,2 @@
1
+ Changelog
2
+ =========
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2024 BugSnag
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/bugsnag_performance/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "bugsnag_performance"
7
+ spec.version = BugsnagPerformance::VERSION
8
+ spec.authors = ["BugSnag"]
9
+ spec.email = ["notifiers@bugsnag.com"]
10
+
11
+ spec.summary = "BugSnag integration for the Ruby Open Telemetry SDK"
12
+ spec.homepage = "https://www.bugsnag.com"
13
+ spec.license = "MIT"
14
+ spec.required_ruby_version = ">= 3.0"
15
+ spec.require_paths = ["lib"]
16
+
17
+ spec.metadata["homepage_uri"] = spec.homepage
18
+
19
+ github_url = "https://github.com/bugsnag/bugsnag-ruby-performance"
20
+
21
+ spec.metadata["source_code_uri"] = "#{github_url}"
22
+ spec.metadata["bug_tracker_uri"] = "#{github_url}/issues"
23
+ spec.metadata["changelog_uri"] = "#{github_url}/blob/v#{BugsnagPerformance::VERSION}/CHANGELOG.md"
24
+ spec.metadata["documentation_uri"] = "https://docs.bugsnag.com/performance/integration-guides/ruby/"
25
+ spec.metadata["rubygems_mfa_required"] = "true"
26
+
27
+ spec.files = Dir.chdir(__dir__) do
28
+ `git ls-files -z`.split("\x0").reject do |f|
29
+ (File.expand_path(f) == __FILE__) ||
30
+ f.start_with?(*%w[bin/ test/ spec/ features/ .git .github .rspec Gemfile])
31
+ end
32
+ end
33
+
34
+ spec.add_dependency "concurrent-ruby", "~> 1.3"
35
+ spec.add_dependency "opentelemetry-sdk", "~> 1.2"
36
+
37
+ spec.add_development_dependency "rspec", "~> 3.0"
38
+ spec.add_development_dependency "webmock", "~> 3.23"
39
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BugsnagPerformance
4
+ class Configuration
5
+ attr_reader :open_telemetry_configure_block
6
+ attr_reader :logger
7
+
8
+ attr_accessor :api_key
9
+ attr_accessor :app_version
10
+ attr_accessor :release_stage
11
+ attr_accessor :enabled_release_stages
12
+ attr_accessor :use_managed_quota
13
+
14
+ attr_writer :endpoint
15
+
16
+ def initialize(errors_configuration)
17
+ @open_telemetry_configure_block = proc { |c| }
18
+ self.logger = errors_configuration.logger || OpenTelemetry.logger
19
+
20
+ @api_key = fetch(errors_configuration, :api_key, env: "BUGSNAG_PERFORMANCE_API_KEY")
21
+ @app_version = fetch(errors_configuration, :app_version)
22
+ @release_stage = fetch(errors_configuration, :release_stage, env: "BUGSNAG_PERFORMANCE_RELEASE_STAGE", default: "production")
23
+ @use_managed_quota = true
24
+
25
+ @enabled_release_stages = fetch(errors_configuration, :enabled_release_stages, env: "BUGSNAG_PERFORMANCE_ENABLED_RELEASE_STAGES")
26
+
27
+ # transform enabled release stages into an array if we read its value from
28
+ # the environment
29
+ if @enabled_release_stages.is_a?(String)
30
+ @enabled_release_stages = @enabled_release_stages.split(",").map(&:strip)
31
+ end
32
+ end
33
+
34
+ def logger=(logger)
35
+ @logger =
36
+ if logger.is_a?(Internal::LoggerWrapper)
37
+ logger
38
+ else
39
+ Internal::LoggerWrapper.new(logger)
40
+ end
41
+ end
42
+
43
+ def endpoint
44
+ case
45
+ when defined?(@endpoint)
46
+ # if a custom endpoint has been set then use it directly
47
+ @endpoint
48
+ when @api_key.nil?
49
+ # if there's no API key then we can't construct the default URL
50
+ nil
51
+ else
52
+ "https://#{@api_key}.otlp.bugsnag.com/v1/traces"
53
+ end
54
+ end
55
+
56
+ def configure_open_telemetry(&open_telemetry_configure_block)
57
+ @open_telemetry_configure_block = open_telemetry_configure_block
58
+ end
59
+
60
+ private
61
+
62
+ def fetch(
63
+ errors_configuration,
64
+ name,
65
+ env: nil,
66
+ default: nil
67
+ )
68
+ if env
69
+ value = ENV[env]
70
+
71
+ return value unless value.nil?
72
+ end
73
+
74
+ value = errors_configuration.send(name)
75
+ return value unless value.nil?
76
+
77
+ default
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BugsnagPerformance
4
+ class Error < StandardError; end
5
+
6
+ class MissingApiKeyError < Error
7
+ def initialize
8
+ super("No Bugsnag API Key set")
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BugsnagPerformance
4
+ module Internal
5
+ class ConfigurationValidator
6
+ def self.validate(configuration)
7
+ new(configuration).validate
8
+ end
9
+
10
+ def validate
11
+ raise MissingApiKeyError.new if @configuration.api_key.nil?
12
+
13
+ validate_open_telemetry_configure_block
14
+ validate_logger
15
+ validate_api_key
16
+ validate_string(:app_version, optional: true)
17
+ validate_string(:release_stage, optional: true)
18
+ validate_array(:enabled_release_stages, "non-empty strings", optional: true, &method(:valid_string?))
19
+ validate_boolean(:use_managed_quota, optional: false)
20
+ valid_endpoint = validate_endpoint
21
+
22
+ # if the endpoint is invalid then we shouldn't attempt to send traces
23
+ Result.new(@messages, @valid_configuration, send_traces: valid_endpoint)
24
+ end
25
+
26
+ private
27
+
28
+ def initialize(configuration)
29
+ @configuration = configuration
30
+ @valid_configuration = BugsnagPerformance::Configuration.new(BugsnagPerformance::Internal::NilErrorsConfiguration.new)
31
+ @messages = []
32
+ end
33
+
34
+ def validate_open_telemetry_configure_block
35
+ value = @configuration.open_telemetry_configure_block
36
+
37
+ if value.respond_to?(:call) && value.arity == 1
38
+ @valid_configuration.configure_open_telemetry(&value)
39
+ else
40
+ @messages << "configure_open_telemetry requires a callable with an arity of 1"
41
+ end
42
+ end
43
+
44
+ def validate_logger
45
+ value = @configuration.logger
46
+
47
+ if value.is_a?(LoggerWrapper) && value.logger.is_a?(::Logger)
48
+ @valid_configuration.logger = value
49
+ else
50
+ @messages << "logger should be a ::Logger, got #{value.logger.inspect}"
51
+ end
52
+ end
53
+
54
+ def validate_api_key
55
+ value = @configuration.api_key
56
+
57
+ # we always use the provided API key even if it's invalid
58
+ @valid_configuration.api_key = value
59
+
60
+ return if value.is_a?(String) && value =~ /\A[0-9a-f]{32}\z/i
61
+
62
+ @messages << "api_key should be a 32 character hexadecimal string, got #{value.inspect}"
63
+ end
64
+
65
+ def validate_string(name, optional:)
66
+ value = @configuration.send(name)
67
+
68
+ if (value.nil? && optional) || valid_string?(value)
69
+ @valid_configuration.send("#{name}=", value)
70
+ else
71
+ @messages << "#{name} should be a non-empty string, got #{value.inspect}"
72
+ end
73
+ end
74
+
75
+ def validate_boolean(name, optional:)
76
+ value = @configuration.send(name)
77
+
78
+ if (value.nil? && optional) || value == true || value == false
79
+ @valid_configuration.send("#{name}=", value)
80
+ else
81
+ @messages << "#{name} should be a boolean, got #{value.inspect}"
82
+ end
83
+ end
84
+
85
+ def validate_array(name, description, optional:, &block)
86
+ value = @configuration.send(name)
87
+
88
+ if (value.nil? && optional) || value.is_a?(Array) && value.all?(&block)
89
+ @valid_configuration.send("#{name}=", value)
90
+ else
91
+ @messages << "#{name} should be an array of #{description}, got #{value.inspect}"
92
+ end
93
+ end
94
+
95
+ def validate_endpoint
96
+ value = @configuration.endpoint
97
+
98
+ # we always use the provided endpoint even if it's invalid to prevent
99
+ # leaking data to the saas bugsnag instance
100
+ @valid_configuration.endpoint = value
101
+
102
+ if valid_string?(value)
103
+ true
104
+ else
105
+ @messages << "endpoint should be a valid URL, got #{value.inspect}"
106
+
107
+ false
108
+ end
109
+ end
110
+
111
+ def valid_string?(value)
112
+ value.is_a?(String) && !value.empty?
113
+ end
114
+
115
+ class Result
116
+ attr_reader :messages
117
+ attr_reader :configuration
118
+
119
+ def initialize(messages, configuration, send_traces:)
120
+ @messages = messages
121
+ @configuration = configuration.freeze
122
+ @send_traces = send_traces
123
+ end
124
+
125
+ def valid?
126
+ @messages.empty?
127
+ end
128
+
129
+ def send_traces?
130
+ @send_traces
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BugsnagPerformance
4
+ module Internal
5
+ class Delivery
6
+ def initialize(configuration)
7
+ @uri = URI(configuration.endpoint)
8
+ @common_headers = {
9
+ "Bugsnag-Api-Key" => configuration.api_key,
10
+ "Content-Type" => "application/json",
11
+ }.freeze
12
+ end
13
+
14
+ def deliver(headers, body)
15
+ headers = headers.merge(
16
+ @common_headers,
17
+ { "Bugsnag-Sent-At" => Time.now.utc.iso8601(3) },
18
+ )
19
+
20
+ raw_response = OpenTelemetry::Common::Utilities.untraced do
21
+ Net::HTTP.post(@uri, body, headers)
22
+ end
23
+
24
+ Response.new(raw_response)
25
+ end
26
+
27
+ class Response
28
+ SAMPLING_PROBABILITY_HEADER = "Bugsnag-Sampling-Probability"
29
+ RETRYABLE_STATUS_CODES = Set[402, 407, 408, 429]
30
+
31
+ private_constant :SAMPLING_PROBABILITY_HEADER, :RETRYABLE_STATUS_CODES
32
+
33
+ attr_reader :state
34
+ attr_reader :sampling_probability
35
+
36
+ def initialize(raw_response)
37
+ if raw_response.nil?
38
+ @state = :failure_retryable
39
+ @sampling_probability = nil
40
+ else
41
+ @state = response_state_from_status_code(raw_response.code)
42
+ @sampling_probability = parse_sampling_probability(raw_response[SAMPLING_PROBABILITY_HEADER])
43
+ end
44
+ end
45
+
46
+ def successful?
47
+ @state == :success
48
+ end
49
+
50
+ def retryable?
51
+ @state == :failure_retryable
52
+ end
53
+
54
+ private
55
+
56
+ def response_state_from_status_code(raw_status_code)
57
+ case Integer(raw_status_code, exception: false)
58
+ when 200...300
59
+ :success
60
+ when RETRYABLE_STATUS_CODES
61
+ :failure_retryable
62
+ when 400...500
63
+ :failure_discard
64
+ else
65
+ :failure_retryable
66
+ end
67
+ end
68
+
69
+ def parse_sampling_probability(raw_probability)
70
+ parsed = Float(raw_probability, exception: false)
71
+
72
+ if parsed && parsed >= 0.0 && parsed <= 1.0
73
+ parsed
74
+ else
75
+ nil
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BugsnagPerformance
4
+ module Internal
5
+ class LoggerWrapper
6
+ attr_reader :logger
7
+
8
+ def initialize(logger)
9
+ @logger = logger
10
+ end
11
+
12
+ def debug(message)
13
+ @logger.debug("[BugsnagPerformance] #{message}")
14
+ end
15
+
16
+ def info(message)
17
+ @logger.info("[BugsnagPerformance] #{message}")
18
+ end
19
+
20
+ def warn(message)
21
+ @logger.warn("[BugsnagPerformance] #{message}")
22
+ end
23
+
24
+ def error(message)
25
+ @logger.error("[BugsnagPerformance] #{message}")
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BugsnagPerformance
4
+ module Internal
5
+ # this class is used when the bugsnag-ruby (aka "bugsnag errors") gem isn't
6
+ # installed to provide the API we need in BugsnagPerformance::Configuration
7
+ class NilErrorsConfiguration
8
+ attr_accessor :api_key
9
+ attr_accessor :app_version
10
+ attr_accessor :release_stage
11
+ attr_accessor :enabled_release_stages
12
+ attr_accessor :logger
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BugsnagPerformance
4
+ module Internal
5
+ class PayloadEncoder
6
+ # https://github.com/open-telemetry/opentelemetry-proto/blob/484241a016d7b81f20b6e19d00ffbc4a3b864a22/opentelemetry/proto/trace/v1/trace.proto#L150-L176
7
+ SPAN_KIND_UNSPECIFIED = 0
8
+ SPAN_KIND_INTERNAL = 1
9
+ SPAN_KIND_SERVER = 2
10
+ SPAN_KIND_CLIENT = 3
11
+ SPAN_KIND_PRODUCER = 4
12
+ SPAN_KIND_CONSUMER = 5
13
+
14
+ # https://github.com/open-telemetry/opentelemetry-proto/blob/484241a016d7b81f20b6e19d00ffbc4a3b864a22/opentelemetry/proto/trace/v1/trace.proto#L312-L320
15
+ SPAN_STATUS_OK = 0
16
+ SPAN_STATUS_UNSET = 1
17
+ SPAN_STATUS_ERROR = 2
18
+
19
+ private_constant :SPAN_KIND_UNSPECIFIED,
20
+ :SPAN_KIND_INTERNAL,
21
+ :SPAN_KIND_SERVER,
22
+ :SPAN_KIND_CLIENT,
23
+ :SPAN_KIND_PRODUCER,
24
+ :SPAN_KIND_CONSUMER,
25
+ :SPAN_STATUS_OK,
26
+ :SPAN_STATUS_UNSET,
27
+ :SPAN_STATUS_ERROR
28
+
29
+ def initialize(sampler)
30
+ @sampler = sampler
31
+ end
32
+
33
+ def encode(span_data)
34
+ {
35
+ resourceSpans: span_data
36
+ .filter { |span| @sampler.resample_span?(span) }
37
+ .group_by(&:resource)
38
+ .map do |resource, scope_spans|
39
+ {
40
+ resource: {
41
+ attributes: resource.attribute_enumerator.map(&method(:attribute_to_json)),
42
+ },
43
+ scopeSpans: scope_spans
44
+ .group_by(&:instrumentation_scope)
45
+ .map do |scope, spans|
46
+ {
47
+ scope: { name: scope.name, version: scope.version },
48
+ spans: spans.map(&method(:span_to_json)),
49
+ }
50
+ end,
51
+ }
52
+ end
53
+ }
54
+ end
55
+
56
+ private
57
+
58
+ def span_to_json(span)
59
+ {
60
+ name: span.name,
61
+ kind: kind_to_json(span.kind),
62
+ spanId: span.hex_span_id,
63
+ traceId: span.hex_trace_id,
64
+ parentSpanId: span.parent_span_id == OpenTelemetry::Trace::INVALID_SPAN_ID ? nil : span.hex_parent_span_id,
65
+ startTimeUnixNano: span.start_timestamp.to_s,
66
+ endTimeUnixNano: span.end_timestamp.to_s,
67
+ traceState: span.tracestate.to_s,
68
+ droppedAttributesCount: calculate_dropped_count(span.total_recorded_attributes, span.attributes),
69
+ droppedEventsCount: calculate_dropped_count(span.total_recorded_events, span.events),
70
+ droppedLinksCount: calculate_dropped_count(span.total_recorded_links, span.links),
71
+ status: { code: span.status.code, message: span.status.description },
72
+ attributes: span.attributes&.map(&method(:attribute_to_json)),
73
+ events: span.events&.map do |event|
74
+ {
75
+ name: event.name,
76
+ timeUnixNano: event.timestamp.to_s,
77
+ attributes: event.attributes&.map(&method(:attribute_to_json))
78
+ # the OTel SDK doesn't provide dropped_attributes_count for events
79
+ }.tap(&:compact!)
80
+ end,
81
+ links: span.links&.map do |link|
82
+ context = link.span_context
83
+
84
+ {
85
+ traceId: context.hex_trace_id,
86
+ spanId: context.hex_span_id,
87
+ traceState: context.tracestate.to_s,
88
+ attributes: link.attributes&.map(&method(:attribute_to_json))
89
+ # the OTel SDK doesn't provide dropped_attributes_count for links
90
+ }.tap(&:compact!)
91
+ end,
92
+ }.tap(&:compact!)
93
+ end
94
+
95
+ def attribute_to_json(key, value)
96
+ { key: key, value: attribute_value_to_json(value) }
97
+ end
98
+
99
+ def attribute_value_to_json(value)
100
+ case value
101
+ when Integer
102
+ { intValue: value.to_s }
103
+ when Float
104
+ { doubleValue: value }
105
+ when true, false
106
+ { boolValue: value }
107
+ when String
108
+ { stringValue: value }
109
+ when Array
110
+ { arrayValue: value.map(&method(:attribute_value_to_json)) }
111
+ end
112
+ end
113
+
114
+ def kind_to_json(kind)
115
+ case kind
116
+ when :internal
117
+ SPAN_KIND_INTERNAL
118
+ when :server
119
+ SPAN_KIND_SERVER
120
+ when :client
121
+ SPAN_KIND_CLIENT
122
+ when :producer
123
+ SPAN_KIND_PRODUCER
124
+ when :consumer
125
+ SPAN_KIND_CONSUMER
126
+ else
127
+ SPAN_KIND_UNSPECIFIED
128
+ end
129
+ end
130
+
131
+ def calculate_dropped_count(total_items, remaining_items)
132
+ return 0 if remaining_items.nil?
133
+
134
+ dropped_count = total_items - remaining_items.length
135
+ return 0 if dropped_count.negative?
136
+
137
+ dropped_count
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BugsnagPerformance
4
+ module Internal
5
+ class ProbabilityFetcher
6
+ # the time to wait before retrying a failed request
7
+ RETRY_SECONDS = 30
8
+ STALE_PROBABILITY_SECONDS = 60 * 60 * 24 # 24 hours
9
+ HEADERS = { "Bugsnag-Span-Sampling" => "1.0:0" }
10
+ BODY = '{"resourceSpans": []}'
11
+
12
+ private_constant :RETRY_SECONDS, :STALE_PROBABILITY_SECONDS, :HEADERS, :BODY
13
+
14
+ def initialize(logger, delivery, task_scheduler)
15
+ @logger = logger
16
+ @delivery = delivery
17
+ @task_scheduler = task_scheduler
18
+ end
19
+
20
+ def stale_in(seconds)
21
+ @task.schedule(seconds)
22
+ end
23
+
24
+ def on_new_probability(&on_new_probability_callback)
25
+ @task = @task_scheduler.now do |done|
26
+ get_new_probability do |new_probability|
27
+ on_new_probability_callback.call(new_probability)
28
+
29
+ done.call
30
+ end
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def get_new_probability(&block)
37
+ # keep making requests until we get a new probability from the server
38
+ loop do
39
+ begin
40
+ response = @delivery.deliver(HEADERS, BODY)
41
+ rescue => exception
42
+ # do nothing, we'll warn about this shortly...
43
+ end
44
+
45
+ # in theory this should always be present, but it's possible the request
46
+ # fails or there's a bug on the server side causing it not to be returned
47
+ if response && new_probability = response.sampling_probability
48
+ new_probability = Float(new_probability, exception: false)
49
+
50
+ if new_probability && new_probability >= 0.0 && new_probability <= 1.0
51
+ block.call(new_probability)
52
+
53
+ break
54
+ end
55
+ end
56
+
57
+ @logger.warn("Failed to retrieve a probability value from BugSnag. Retrying in 30 seconds.")
58
+ @logger.warn(exception) if exception
59
+
60
+ # wait a bit before trying again
61
+ sleep(RETRY_SECONDS)
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BugsnagPerformance
4
+ module Internal
5
+ class ProbabilityManager
6
+ # the duration (in seconds) that a probability value is considered stale
7
+ # and therefore we need to fetch a new value
8
+ STALE_PROBABILITY_SECONDS = 60 * 60 * 24 # 24 hours
9
+ private_constant :STALE_PROBABILITY_SECONDS
10
+
11
+ def initialize(probability_fetcher)
12
+ @probability_fetcher = probability_fetcher
13
+ @probability = 1.0
14
+ @lock = Mutex.new
15
+
16
+ @probability_fetcher.on_new_probability do |new_probability|
17
+ self.probability = new_probability
18
+ end
19
+ end
20
+
21
+ def probability
22
+ @lock.synchronize do
23
+ @probability
24
+ end
25
+ end
26
+
27
+ def probability=(new_probability)
28
+ @lock.synchronize do
29
+ @probability = new_probability
30
+ @probability_fetcher.stale_in(STALE_PROBABILITY_SECONDS)
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BugsnagPerformance
4
+ module Internal
5
+ class Sampler
6
+ PROBABILITY_SCALE_FACTOR = 18_446_744_073_709_551_615 # (2 ** 64) - 1
7
+
8
+ private_constant :PROBABILITY_SCALE_FACTOR
9
+
10
+ def initialize(probability_manager)
11
+ @probability_manager = probability_manager
12
+ end
13
+
14
+ def should_sample?(trace_id:, parent_context:, links:, name:, kind:, attributes:)
15
+ # NOTE: the probability could change at any time so we _must_ only read
16
+ # it once in this method, otherwise we could use different values
17
+ # for the sampling decision & p value attribute which would result
18
+ # in inconsistent data
19
+ probability = @probability_manager.probability
20
+
21
+ decision =
22
+ if sample_using_probability_and_trace_id?(probability, trace_id)
23
+ OpenTelemetry::SDK::Trace::Samplers::Decision::RECORD_AND_SAMPLE
24
+ else
25
+ OpenTelemetry::SDK::Trace::Samplers::Decision::DROP
26
+ end
27
+
28
+ parent_span_context = OpenTelemetry::Trace.current_span(parent_context).context
29
+
30
+ OpenTelemetry::SDK::Trace::Samplers::Result.new(
31
+ decision: decision,
32
+ tracestate: parent_span_context.tracestate,
33
+ attributes: { "bugsnag.sampling.p" => probability },
34
+ )
35
+ end
36
+
37
+ # @api private
38
+ def resample_span?(span)
39
+ probability = @probability_manager.probability
40
+
41
+ # sample all spans that are missing the p value attribute
42
+ return true if span.attributes.nil? || span.attributes["bugsnag.sampling.p"].nil?
43
+
44
+ # update the p value attribute if it was originally sampled with a larger
45
+ # probability than the current value
46
+ if span.attributes["bugsnag.sampling.p"] > probability
47
+ span.attributes["bugsnag.sampling.p"] = probability
48
+ end
49
+
50
+ sample_using_probability_and_trace_id?(span.attributes["bugsnag.sampling.p"], span.trace_id)
51
+ end
52
+
53
+ private
54
+
55
+ def sample_using_probability_and_trace_id?(probability, trace_id)
56
+ # scale the probability (stored as a float from 0-1) to a u64
57
+ p_value = (probability * PROBABILITY_SCALE_FACTOR).floor
58
+
59
+ # unpack the trace ID as a u64
60
+ r_value = trace_id.unpack1("@8Q>")
61
+
62
+ p_value >= r_value
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BugsnagPerformance
4
+ module Internal
5
+ class SamplingHeaderEncoder
6
+ def encode(spans)
7
+ return "1.0:0" if spans.empty?
8
+
9
+ spans
10
+ .group_by do |span|
11
+ # bail if the atrribute is missing; we'll warn about this later as it
12
+ # means something has gone wrong
13
+ return nil if span.attributes.nil?
14
+
15
+ probability = span.attributes["bugsnag.sampling.p"]
16
+ return nil if probability.nil?
17
+
18
+ probability
19
+ end
20
+ .map { |probability, spans| "#{probability}:#{spans.length}" }
21
+ .join(";")
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BugsnagPerformance
4
+ module Internal
5
+ class SpanExporter
6
+ def initialize(
7
+ logger,
8
+ probability_manager,
9
+ delivery,
10
+ payload_encoder,
11
+ sampling_header_encoder
12
+ )
13
+ @logger = logger
14
+ @probability_manager = probability_manager
15
+ @delivery = delivery
16
+ @payload_encoder = payload_encoder
17
+ @sampling_header_encoder = sampling_header_encoder
18
+ @disabled = false
19
+ end
20
+
21
+ def disable!
22
+ @disabled = true
23
+ end
24
+
25
+ def export(span_data, timeout: nil)
26
+ return OpenTelemetry::SDK::Trace::Export::SUCCESS if @disabled
27
+
28
+ with_timeout(timeout) do
29
+ headers = {}
30
+ sampling_header = @sampling_header_encoder.encode(span_data)
31
+
32
+ if sampling_header.nil?
33
+ @logger.warn("One or more spans are missing the 'bugsnag.sampling.p' attribute. This trace will be sent as 'unmanaged'.")
34
+ else
35
+ headers["Bugsnag-Span-Sampling"] = sampling_header
36
+ end
37
+
38
+ body = JSON.generate(@payload_encoder.encode(span_data))
39
+
40
+ response = @delivery.deliver(headers, body)
41
+
42
+ if response.sampling_probability
43
+ @probability_manager.probability = response.sampling_probability
44
+ end
45
+
46
+ OpenTelemetry::SDK::Trace::Export::SUCCESS
47
+ end
48
+ rescue => exception
49
+ @logger.error("Failed to deliver trace to BugSnag.")
50
+ @logger.error(exception)
51
+
52
+ return OpenTelemetry::SDK::Trace::Export::TIMEOUT if exception.is_a?(Timeout::Error)
53
+
54
+ OpenTelemetry::SDK::Trace::Export::FAILURE
55
+ end
56
+
57
+ def force_flush(timeout: nil)
58
+ OpenTelemetry::SDK::Trace::Export::SUCCESS
59
+ end
60
+
61
+ def shutdown(timeout: nil)
62
+ OpenTelemetry::SDK::Trace::Export::SUCCESS
63
+ end
64
+
65
+ private
66
+
67
+ def with_timeout(timeout, &block)
68
+ if timeout.nil?
69
+ block.call
70
+ else
71
+ Timeout::timeout(timeout) { block.call }
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BugsnagPerformance
4
+ module Internal
5
+ # A wrapper around Concurrent::ScheduledTask that is easier to work with when
6
+ # a task needs to be run repeatedly (a ScheduledTask can only run once)
7
+ class Task
8
+ class UnscheduledTaskError < ::BugsnagPerformance::Error
9
+ def initialize
10
+ super("Task has not been scheduled")
11
+ end
12
+ end
13
+
14
+ def initialize(on_finish)
15
+ @on_finish = on_finish
16
+ @lock = Mutex.new
17
+ @scheduled_task = nil
18
+ end
19
+
20
+ def schedule(delay)
21
+ @lock.synchronize do
22
+ if @scheduled_task && @scheduled_task.pending?
23
+ # if we have a pending task we can reschedule it
24
+ @scheduled_task.reschedule(delay)
25
+ elsif @scheduled_task && @scheduled_task.processing?
26
+ # task is currently running so re-schedule when it finishes and remove
27
+ # any existing reschedules
28
+ @scheduled_task.delete_observers
29
+
30
+ @scheduled_task.add_observer(OnTaskFinish.new(proc do
31
+ self.schedule(delay)
32
+ end))
33
+ else
34
+ # otherwise make a new task; if there's an existing task and it isn't
35
+ # pending or processing then it must have already finished so we don't
36
+ # need to worry about cancelling it
37
+ @scheduled_task = Concurrent::ScheduledTask.execute(delay) { @on_finish.call }
38
+ end
39
+ end
40
+
41
+ nil
42
+ end
43
+
44
+ def wait
45
+ raise UnscheduledTaskError.new if @scheduled_task.nil?
46
+
47
+ @scheduled_task.wait
48
+ end
49
+
50
+ def state
51
+ case @scheduled_task&.state
52
+ when nil then :unscheduled
53
+ when :pending then :pending
54
+ when :processing then :processing
55
+ else :finished
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ class OnTaskFinish
62
+ def initialize(on_finish)
63
+ @on_finish = on_finish
64
+ end
65
+
66
+ def update(...)
67
+ @on_finish.call
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BugsnagPerformance
4
+ module Internal
5
+ class TaskScheduler
6
+ def now(&on_finish)
7
+ Task.new(on_finish).tap do |task|
8
+ task.schedule(0)
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BugsnagPerformance
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+ require "timeout"
6
+ require "net/http"
7
+ require "concurrent-ruby"
8
+ require "opentelemetry-sdk"
9
+
10
+ require_relative "bugsnag_performance/error"
11
+ require_relative "bugsnag_performance/version"
12
+ require_relative "bugsnag_performance/configuration"
13
+
14
+ require_relative "bugsnag_performance/internal/task"
15
+ require_relative "bugsnag_performance/internal/sampler"
16
+ require_relative "bugsnag_performance/internal/delivery"
17
+ require_relative "bugsnag_performance/internal/span_exporter"
18
+ require_relative "bugsnag_performance/internal/logger_wrapper"
19
+ require_relative "bugsnag_performance/internal/task_scheduler"
20
+ require_relative "bugsnag_performance/internal/payload_encoder"
21
+ require_relative "bugsnag_performance/internal/probability_fetcher"
22
+ require_relative "bugsnag_performance/internal/probability_manager"
23
+ require_relative "bugsnag_performance/internal/configuration_validator"
24
+ require_relative "bugsnag_performance/internal/sampling_header_encoder"
25
+ require_relative "bugsnag_performance/internal/nil_errors_configuration"
26
+
27
+ module BugsnagPerformance
28
+ def self.configure(&block)
29
+ unvalidated_configuration = Configuration.new(load_bugsnag_errors_configuration)
30
+
31
+ block.call(unvalidated_configuration) unless block.nil?
32
+
33
+ result = Internal::ConfigurationValidator.validate(unvalidated_configuration)
34
+ configuration = result.configuration
35
+
36
+ log_validation_messages(configuration.logger, result.messages) unless result.valid?
37
+
38
+ delivery = Internal::Delivery.new(configuration)
39
+ task_scheduler = Internal::TaskScheduler.new
40
+ probability_fetcher = Internal::ProbabilityFetcher.new(configuration.logger, delivery, task_scheduler)
41
+ probability_manager = Internal::ProbabilityManager.new(probability_fetcher)
42
+ sampler = Internal::Sampler.new(probability_manager)
43
+
44
+ exporter = Internal::SpanExporter.new(
45
+ configuration.logger,
46
+ probability_manager,
47
+ delivery,
48
+ Internal::PayloadEncoder.new(sampler),
49
+ Internal::SamplingHeaderEncoder.new,
50
+ )
51
+
52
+ if configuration.enabled_release_stages && !configuration.enabled_release_stages.include?(configuration.release_stage)
53
+ configuration.logger.info("Not exporting spans as the current release stage is not in the enabled release stages.")
54
+ exporter.disable!
55
+ end
56
+
57
+ # return the result of the user's configuration block so we don't change
58
+ # any existing behaviour
59
+ return_value = nil
60
+
61
+ OpenTelemetry::SDK.configure do |otel_configurator|
62
+ # call the user's OTel configuration block
63
+ return_value = configuration.open_telemetry_configure_block.call(otel_configurator)
64
+
65
+ # add app version and release stage as the 'service.version' and
66
+ # 'deployment.environment' resource attributes
67
+ if app_version = configuration.app_version
68
+ otel_configurator.service_version = app_version
69
+ end
70
+
71
+ otel_configurator.resource = OpenTelemetry::SDK::Resources::Resource.create(
72
+ OpenTelemetry::SemanticConventions::Resource::DEPLOYMENT_ENVIRONMENT => configuration.release_stage
73
+ )
74
+
75
+ # add batch processor with bugsnag exporter to send payloads
76
+ otel_configurator.add_span_processor(
77
+ OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(exporter)
78
+ )
79
+ end
80
+
81
+ # use our sampler
82
+ OpenTelemetry.tracer_provider.sampler = sampler
83
+
84
+ return_value
85
+ end
86
+
87
+ private
88
+
89
+ def self.load_bugsnag_errors_configuration
90
+ # try to require bugsnag errors and use its configuration
91
+ require "bugsnag"
92
+
93
+ Bugsnag.configuration
94
+ rescue LoadError
95
+ # bugsnag errors is not installed
96
+ Internal::NilErrorsConfiguration.new
97
+ end
98
+
99
+ def self.log_validation_messages(logger, messages)
100
+ if messages.length == 1
101
+ logger.warn("Invalid configuration. #{messages.first}")
102
+ else
103
+ logger.warn(
104
+ <<~MESSAGE
105
+ Invalid configuration:
106
+ - #{messages.join("\n - ")}
107
+ MESSAGE
108
+ )
109
+ end
110
+ end
111
+ end
metadata ADDED
@@ -0,0 +1,124 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: bugsnag_performance
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - BugSnag
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2024-08-21 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: concurrent-ruby
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.3'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.3'
27
+ - !ruby/object:Gem::Dependency
28
+ name: opentelemetry-sdk
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.2'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.2'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: webmock
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '3.23'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '3.23'
69
+ description:
70
+ email:
71
+ - notifiers@bugsnag.com
72
+ executables: []
73
+ extensions: []
74
+ extra_rdoc_files: []
75
+ files:
76
+ - CHANGELOG.md
77
+ - LICENSE.txt
78
+ - bugsnag-performance.gemspec
79
+ - lib/bugsnag_performance.rb
80
+ - lib/bugsnag_performance/configuration.rb
81
+ - lib/bugsnag_performance/error.rb
82
+ - lib/bugsnag_performance/internal/configuration_validator.rb
83
+ - lib/bugsnag_performance/internal/delivery.rb
84
+ - lib/bugsnag_performance/internal/logger_wrapper.rb
85
+ - lib/bugsnag_performance/internal/nil_errors_configuration.rb
86
+ - lib/bugsnag_performance/internal/payload_encoder.rb
87
+ - lib/bugsnag_performance/internal/probability_fetcher.rb
88
+ - lib/bugsnag_performance/internal/probability_manager.rb
89
+ - lib/bugsnag_performance/internal/sampler.rb
90
+ - lib/bugsnag_performance/internal/sampling_header_encoder.rb
91
+ - lib/bugsnag_performance/internal/span_exporter.rb
92
+ - lib/bugsnag_performance/internal/task.rb
93
+ - lib/bugsnag_performance/internal/task_scheduler.rb
94
+ - lib/bugsnag_performance/version.rb
95
+ homepage: https://www.bugsnag.com
96
+ licenses:
97
+ - MIT
98
+ metadata:
99
+ homepage_uri: https://www.bugsnag.com
100
+ source_code_uri: https://github.com/bugsnag/bugsnag-ruby-performance
101
+ bug_tracker_uri: https://github.com/bugsnag/bugsnag-ruby-performance/issues
102
+ changelog_uri: https://github.com/bugsnag/bugsnag-ruby-performance/blob/v0.1.0/CHANGELOG.md
103
+ documentation_uri: https://docs.bugsnag.com/performance/integration-guides/ruby/
104
+ rubygems_mfa_required: 'true'
105
+ post_install_message:
106
+ rdoc_options: []
107
+ require_paths:
108
+ - lib
109
+ required_ruby_version: !ruby/object:Gem::Requirement
110
+ requirements:
111
+ - - ">="
112
+ - !ruby/object:Gem::Version
113
+ version: '3.0'
114
+ required_rubygems_version: !ruby/object:Gem::Requirement
115
+ requirements:
116
+ - - ">="
117
+ - !ruby/object:Gem::Version
118
+ version: '0'
119
+ requirements: []
120
+ rubygems_version: 3.4.10
121
+ signing_key:
122
+ specification_version: 4
123
+ summary: BugSnag integration for the Ruby Open Telemetry SDK
124
+ test_files: []