otel_beacon 0.1.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,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OtelBeacon
4
+ module Context
5
+ class << self
6
+ def current
7
+ Thread.current[:otel_beacon_context] ||= default_context
8
+ end
9
+
10
+ def reset!
11
+ Thread.current[:otel_beacon_context] = default_context
12
+ end
13
+
14
+ def user
15
+ current[:user]
16
+ end
17
+
18
+ def tags
19
+ current[:tags]
20
+ end
21
+
22
+ def extra
23
+ current[:extra]
24
+ end
25
+
26
+ def breadcrumbs
27
+ current[:breadcrumbs]
28
+ end
29
+
30
+ def contexts
31
+ current[:contexts]
32
+ end
33
+
34
+ def fingerprint
35
+ current[:fingerprint]
36
+ end
37
+
38
+ def fingerprint=(value)
39
+ current[:fingerprint] = value
40
+ end
41
+
42
+ def push_scope
43
+ stack << deep_dup(current)
44
+ end
45
+
46
+ def pop_scope
47
+ if stack.any?
48
+ Thread.current[:otel_beacon_context] = stack.pop
49
+ else
50
+ reset!
51
+ end
52
+ end
53
+
54
+ def stack
55
+ Thread.current[:otel_beacon_scope_stack] ||= []
56
+ end
57
+
58
+ private
59
+
60
+ def default_context
61
+ { user: {}, tags: {}, extra: {}, breadcrumbs: [], contexts: {}, fingerprint: nil }
62
+ end
63
+
64
+ def deep_dup(hash)
65
+ hash.transform_values do |v|
66
+ case v
67
+ when Hash then v.dup
68
+ when Array then v.dup
69
+ else v
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OtelBeacon
4
+ module Integrations
5
+ class SidekiqMiddleware
6
+ def call(worker, job, queue)
7
+ OtelBeacon.reset_context!
8
+ OtelBeacon.set_tags(
9
+ job_class: worker.class.name,
10
+ queue: queue,
11
+ job_id: job["jid"]
12
+ )
13
+ OtelBeacon.set_context(:sidekiq,
14
+ queue: queue,
15
+ retry_count: job["retry_count"] || 0,
16
+ created_at: job["created_at"],
17
+ enqueued_at: job["enqueued_at"])
18
+ OtelBeacon.add_breadcrumb(:job, "#{worker.class.name} started", queue: queue, jid: job["jid"])
19
+
20
+ yield
21
+ rescue StandardError => e
22
+ OtelBeacon.capture_exception(e, extra: {
23
+ job_class: worker.class.name,
24
+ job_id: job["jid"],
25
+ queue: queue,
26
+ args: filtered_args(job["args"])
27
+ })
28
+ raise
29
+ ensure
30
+ OtelBeacon.flush_context_to_span
31
+ end
32
+
33
+ private
34
+
35
+ def filtered_args(args)
36
+ return [] unless args.is_a?(Array)
37
+
38
+ args.first(5).map do |arg|
39
+ case arg
40
+ when Hash then OtelBeacon::Sanitizer.sanitize_params(arg)
41
+ when String then arg.length > 100 ? "#{arg[0..100]}..." : arg
42
+ else arg.inspect[0..100]
43
+ end
44
+ end
45
+ rescue StandardError
46
+ [ "[unable to serialize]" ]
47
+ end
48
+ end
49
+
50
+ module Sidekiq
51
+ def self.setup
52
+ return unless defined?(::Sidekiq)
53
+
54
+ ::Sidekiq.configure_server do |config|
55
+ config.server_middleware do |chain|
56
+ chain.add OtelBeacon::Integrations::SidekiqMiddleware
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OtelBeacon
4
+ class LogSubscriber < ActiveSupport::LogSubscriber
5
+ def capture_exception(event)
6
+ error do
7
+ exception = event.payload[:exception]
8
+ "Captured exception: #{exception.class} - #{exception.message}"
9
+ end
10
+ end
11
+
12
+ def capture_message(event)
13
+ info do
14
+ "Captured message: #{event.payload[:message]} (level: #{event.payload[:level]})"
15
+ end
16
+ end
17
+
18
+ def add_breadcrumb(event)
19
+ debug do
20
+ "Added breadcrumb: #{event.payload[:category]} - #{event.payload[:message]}"
21
+ end
22
+ end
23
+
24
+ def context_error(event)
25
+ error do
26
+ "Context error: #{event.payload[:error]}"
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OtelBeacon
4
+ module Rails
5
+ module ControllerMethods
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ before_action :otel_beacon_setup_context
10
+ around_action :otel_beacon_capture_breadcrumbs
11
+ rescue_from StandardError, with: :otel_beacon_capture_and_reraise
12
+ end
13
+
14
+ private
15
+
16
+ def otel_beacon_setup_context
17
+ OtelBeacon.reset_context!
18
+
19
+ user = OtelBeacon.config.resolve_current_user(self)
20
+ if user
21
+ attrs = OtelBeacon.config.extract_user_attributes(user)
22
+ OtelBeacon.set_user(**attrs) if attrs.any?
23
+ end
24
+
25
+ OtelBeacon.set_tags(
26
+ controller: controller_name,
27
+ action: action_name,
28
+ environment: ::Rails.env.to_s
29
+ )
30
+
31
+ OtelBeacon::RuntimeContext.set_device_context(request)
32
+ OtelBeacon::RuntimeContext.set_browser_context(request)
33
+ end
34
+
35
+ def otel_beacon_capture_breadcrumbs
36
+ OtelBeacon.add_breadcrumb(:controller, "#{controller_name}##{action_name}", params: otel_beacon_filtered_params)
37
+ yield
38
+ ensure
39
+ OtelBeacon.flush_context_to_span
40
+ end
41
+
42
+ def otel_beacon_capture_and_reraise(exception)
43
+ OtelBeacon.capture_exception(
44
+ exception,
45
+ request: request,
46
+ params: params,
47
+ extra: { controller: controller_name, action: action_name, format: request.format.to_s }
48
+ )
49
+ raise exception
50
+ end
51
+
52
+ def otel_beacon_filtered_params
53
+ params.to_unsafe_h.except(:controller, :action, :format)
54
+ rescue StandardError
55
+ {}
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OtelBeacon
4
+ module Rails
5
+ module JobMethods
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ around_perform :otel_beacon_capture_job
10
+ end
11
+
12
+ private
13
+
14
+ def otel_beacon_capture_job
15
+ OtelBeacon.reset_context!
16
+ OtelBeacon.set_tags(
17
+ job_class: self.class.name,
18
+ queue: queue_name || "default",
19
+ job_id: job_id
20
+ )
21
+ OtelBeacon.add_breadcrumb(:job, "#{self.class.name} started", queue: queue_name, job_id: job_id)
22
+
23
+ yield
24
+ rescue StandardError => e
25
+ OtelBeacon.capture_exception(e, extra: {
26
+ job_class: self.class.name,
27
+ job_id: job_id,
28
+ queue: queue_name,
29
+ arguments: filtered_arguments
30
+ })
31
+ raise
32
+ ensure
33
+ OtelBeacon.flush_context_to_span
34
+ end
35
+
36
+ def filtered_arguments
37
+ arguments.first(5).map do |arg|
38
+ case arg
39
+ when Hash then OtelBeacon::Sanitizer.sanitize_params(arg)
40
+ when String then arg.length > 100 ? "#{arg[0..100]}..." : arg
41
+ else arg.inspect[0..100]
42
+ end
43
+ end
44
+ rescue StandardError
45
+ [ "[unable to serialize]" ]
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+ require "delegate"
5
+
6
+ module OtelBeacon
7
+ module Rails
8
+ class Logger < SimpleDelegator
9
+ SEVERITY_MAP = {
10
+ ::Logger::DEBUG => "DEBUG",
11
+ ::Logger::INFO => "INFO",
12
+ ::Logger::WARN => "WARN",
13
+ ::Logger::ERROR => "ERROR",
14
+ ::Logger::FATAL => "FATAL",
15
+ ::Logger::UNKNOWN => "UNKNOWN"
16
+ }.freeze
17
+
18
+ def initialize(original_logger)
19
+ super(original_logger)
20
+ @original_logger = original_logger
21
+ @otel_logger = nil
22
+ end
23
+
24
+ def add(severity, message = nil, progname = nil)
25
+ message = yield if block_given?
26
+ message ||= progname
27
+ result = @original_logger.add(severity, message, progname)
28
+ emit_to_otel(severity, message) if message
29
+ result
30
+ end
31
+
32
+ def level
33
+ @original_logger.level
34
+ end
35
+
36
+ def level=(value)
37
+ @original_logger.level = value
38
+ end
39
+
40
+ def debug(message = nil)
41
+ message = yield if block_given?
42
+ @original_logger.debug(message)
43
+ emit_to_otel(::Logger::DEBUG, message) if message
44
+ end
45
+
46
+ def info(message = nil)
47
+ message = yield if block_given?
48
+ @original_logger.info(message)
49
+ emit_to_otel(::Logger::INFO, message) if message
50
+ end
51
+
52
+ def warn(message = nil)
53
+ message = yield if block_given?
54
+ @original_logger.warn(message)
55
+ emit_to_otel(::Logger::WARN, message) if message
56
+ end
57
+
58
+ def error(message = nil)
59
+ message = yield if block_given?
60
+ @original_logger.error(message)
61
+ emit_to_otel(::Logger::ERROR, message) if message
62
+ end
63
+
64
+ def fatal(message = nil)
65
+ message = yield if block_given?
66
+ @original_logger.fatal(message)
67
+ emit_to_otel(::Logger::FATAL, message) if message
68
+ end
69
+
70
+ private
71
+
72
+ def emit_to_otel(severity, message)
73
+ return unless otel_configured?
74
+
75
+ attributes = {
76
+ "log.source" => "rails",
77
+ "service.name" => OtelBeacon.config.service_name
78
+ }
79
+
80
+ current_span = OpenTelemetry::Trace.current_span
81
+ if current_span.context.valid?
82
+ attributes["trace_id"] = current_span.context.hex_trace_id
83
+ attributes["span_id"] = current_span.context.hex_span_id
84
+ end
85
+
86
+ otel_logger.on_emit(
87
+ severity_text: SEVERITY_MAP[severity] || "INFO",
88
+ severity_number: severity_to_number(severity),
89
+ body: message.to_s,
90
+ attributes: attributes,
91
+ context: OpenTelemetry::Context.current
92
+ )
93
+ rescue StandardError => e
94
+ @original_logger&.debug("OtelBeacon log emit failed: #{e.message}")
95
+ end
96
+
97
+ def otel_configured?
98
+ OpenTelemetry.logger_provider&.respond_to?(:logger)
99
+ end
100
+
101
+ def otel_logger
102
+ @otel_logger ||= OpenTelemetry.logger_provider.logger(
103
+ name: OtelBeacon.config.service_name,
104
+ version: OtelBeacon::VERSION
105
+ )
106
+ end
107
+
108
+ def severity_to_number(severity)
109
+ case severity
110
+ when ::Logger::DEBUG then 5
111
+ when ::Logger::INFO then 9
112
+ when ::Logger::WARN then 13
113
+ when ::Logger::ERROR then 17
114
+ when ::Logger::FATAL then 21
115
+ else 0
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OtelBeacon
4
+ module Rails
5
+ module MailerMethods
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ around_action :otel_beacon_capture_mailer
10
+ rescue_from StandardError, with: :otel_beacon_mailer_exception
11
+ end
12
+
13
+ private
14
+
15
+ def otel_beacon_capture_mailer
16
+ OtelBeacon.reset_context!
17
+ OtelBeacon.set_tags(
18
+ mailer_class: self.class.name,
19
+ mailer_action: action_name
20
+ )
21
+ OtelBeacon.add_breadcrumb(:mailer, "#{self.class.name}##{action_name}")
22
+
23
+ yield
24
+ ensure
25
+ OtelBeacon.flush_context_to_span
26
+ end
27
+
28
+ def otel_beacon_mailer_exception(exception)
29
+ OtelBeacon.capture_exception(exception, extra: {
30
+ mailer_class: self.class.name,
31
+ mailer_action: action_name
32
+ })
33
+ raise exception
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OtelBeacon
4
+ module Rails
5
+ class Railtie < ::Rails::Railtie
6
+ config.otel_beacon = ActiveSupport::OrderedOptions.new
7
+
8
+ initializer "otel_beacon.config", before: :run_prepare_callbacks do |app|
9
+ app.config.otel_beacon.each do |key, value|
10
+ OtelBeacon.public_send("#{key}=", value)
11
+ end
12
+ end
13
+
14
+ initializer "otel_beacon.app_executor", before: :run_prepare_callbacks do |_app|
15
+ OtelBeacon.on_thread_error = ->(exception) { ::Rails.error.report(exception, handled: false) }
16
+ end
17
+
18
+ initializer "otel_beacon.logger", before: :run_prepare_callbacks do
19
+ ActiveSupport.on_load(:otel_beacon) do
20
+ self.logger = ::Rails.logger
21
+ end
22
+
23
+ OtelBeacon::LogSubscriber.attach_to :otel_beacon
24
+ end
25
+
26
+ initializer "otel_beacon.environment", before: :run_prepare_callbacks do
27
+ OtelBeacon.environment = ::Rails.env.to_s
28
+ end
29
+
30
+ initializer "otel_beacon.integrations", after: :load_config_initializers do
31
+ OtelBeacon.set_runtime_context
32
+
33
+ ActiveSupport.on_load(:action_controller) do
34
+ include OtelBeacon::Rails::ControllerMethods
35
+ end
36
+
37
+ ActiveSupport.on_load(:active_job) do
38
+ include OtelBeacon::Rails::JobMethods
39
+ end
40
+
41
+ ActiveSupport.on_load(:action_mailer) do
42
+ include OtelBeacon::Rails::MailerMethods
43
+ end
44
+
45
+ if defined?(::Sidekiq)
46
+ require_relative "../integrations/sidekiq"
47
+ OtelBeacon::Integrations::Sidekiq.setup
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OtelBeacon
4
+ module RuntimeContext
5
+ class << self
6
+ def set_defaults
7
+ set_runtime_context
8
+ set_os_context
9
+ set_app_context
10
+ end
11
+
12
+ def set_runtime_context
13
+ OtelBeacon.set_context(:runtime,
14
+ name: "ruby",
15
+ version: RUBY_VERSION,
16
+ platform: RUBY_PLATFORM,
17
+ engine: RUBY_ENGINE,
18
+ engine_version: RUBY_ENGINE_VERSION)
19
+ end
20
+
21
+ def set_os_context
22
+ OtelBeacon.set_context(:os,
23
+ name: os_name,
24
+ version: os_version,
25
+ kernel_version: `uname -r`.strip)
26
+ rescue StandardError
27
+ OtelBeacon.set_context(:os, name: RUBY_PLATFORM)
28
+ end
29
+
30
+ def set_app_context
31
+ ctx = {
32
+ name: OtelBeacon.config.service_name,
33
+ version: OtelBeacon.config.service_version,
34
+ environment: OtelBeacon.config.environment.to_s
35
+ }
36
+ ctx[:rails_version] = ::Rails.version if defined?(::Rails)
37
+ OtelBeacon.set_context(:app, **ctx)
38
+ end
39
+
40
+ def set_device_context(request)
41
+ return unless request
42
+
43
+ OtelBeacon.set_context(:device,
44
+ user_agent: request.user_agent,
45
+ ip_address: request.remote_ip)
46
+ rescue StandardError
47
+ end
48
+
49
+ def set_browser_context(request)
50
+ return unless request
51
+
52
+ ua = request.user_agent.to_s
53
+ OtelBeacon.set_context(:browser,
54
+ user_agent: ua,
55
+ name: parse_browser_name(ua),
56
+ version: parse_browser_version(ua))
57
+ rescue StandardError
58
+ end
59
+
60
+ private
61
+
62
+ def os_name
63
+ case RUBY_PLATFORM
64
+ when /darwin/ then "macOS"
65
+ when /linux/ then "Linux"
66
+ when /win|mingw/ then "Windows"
67
+ when /freebsd/ then "FreeBSD"
68
+ else RUBY_PLATFORM
69
+ end
70
+ end
71
+
72
+ def os_version
73
+ case RUBY_PLATFORM
74
+ when /darwin/
75
+ `sw_vers -productVersion`.strip
76
+ when /linux/
77
+ File.read("/etc/os-release").match(/VERSION_ID="?([^"\n]+)"?/)&.[](1) || "unknown"
78
+ else
79
+ "unknown"
80
+ end
81
+ rescue StandardError
82
+ "unknown"
83
+ end
84
+
85
+ def parse_browser_name(ua)
86
+ case ua
87
+ when /Chrome/i then "Chrome"
88
+ when /Firefox/i then "Firefox"
89
+ when /Safari/i then "Safari"
90
+ when /Edge/i then "Edge"
91
+ when /MSIE|Trident/i then "Internet Explorer"
92
+ else "Unknown"
93
+ end
94
+ end
95
+
96
+ def parse_browser_version(ua)
97
+ match = ua.match(%r{(?:Chrome|Firefox|Safari|Edge|MSIE|rv:)[/ ]?([\d.]+)}i)
98
+ match ? match[1] : "unknown"
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OtelBeacon
4
+ module Sanitizer
5
+ class << self
6
+ def sanitize_params(params, depth = 0)
7
+ return {} if params.nil?
8
+ return "[TRUNCATED]" if depth > 5
9
+
10
+ case params
11
+ when Hash
12
+ params.each_with_object({}) do |(k, v), h|
13
+ h[k] = sensitive_field?(k) ? "[FILTERED]" : sanitize_params(v, depth + 1)
14
+ end
15
+ when Array
16
+ params.first(100).map { |v| sanitize_params(v, depth + 1) }
17
+ when String
18
+ params.length > 1000 ? "#{params[0..1000]}...[TRUNCATED]" : params
19
+ else
20
+ params
21
+ end
22
+ end
23
+
24
+ def extract_params(params)
25
+ raw = begin
26
+ params.to_unsafe_h
27
+ rescue StandardError
28
+ begin
29
+ params.to_h
30
+ rescue StandardError
31
+ {}
32
+ end
33
+ end
34
+ sanitize_params(raw)
35
+ end
36
+
37
+ def truncate_string(string, max_length: 500)
38
+ return string if string.length <= max_length
39
+
40
+ "#{string[0...max_length]}..."
41
+ end
42
+
43
+ private
44
+
45
+ def sensitive_field?(key)
46
+ key.to_s.downcase.match?(OtelBeacon.config.sanitize_pattern)
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OtelBeacon
4
+ class Scope
5
+ def set_user(id: nil, email: nil, username: nil, ip_address: nil, **extra)
6
+ Context.current[:user] = { id: id, email: email, username: username, ip_address: ip_address }.compact.merge(extra)
7
+ end
8
+
9
+ def set_tags(**tags)
10
+ Context.tags.merge!(tags.transform_values(&:to_s))
11
+ end
12
+
13
+ def set_tag(key, value)
14
+ Context.tags[key.to_sym] = value.to_s
15
+ end
16
+
17
+ def set_extra(**extra)
18
+ Context.extra.merge!(extra)
19
+ end
20
+
21
+ def set_context(name, **data)
22
+ Context.contexts[name.to_sym] = data
23
+ end
24
+
25
+ def set_fingerprint(*fingerprint)
26
+ Context.fingerprint = fingerprint.flatten
27
+ end
28
+
29
+ def add_breadcrumb(category, message, level: :info, **data)
30
+ Client.add_breadcrumb(category, message, level: level, **data)
31
+ end
32
+
33
+ def clear
34
+ Context.reset!
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OtelBeacon
4
+ VERSION = "0.1.0"
5
+ end