miniapm 1.0.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 +43 -0
- data/LICENSE +21 -0
- data/README.md +174 -0
- data/lib/generators/miniapm/install_generator.rb +27 -0
- data/lib/generators/miniapm/templates/README +19 -0
- data/lib/generators/miniapm/templates/initializer.rb +60 -0
- data/lib/miniapm/configuration.rb +176 -0
- data/lib/miniapm/context.rb +138 -0
- data/lib/miniapm/error_event.rb +130 -0
- data/lib/miniapm/exporters/errors.rb +67 -0
- data/lib/miniapm/exporters/otlp.rb +90 -0
- data/lib/miniapm/instrumentations/activejob.rb +271 -0
- data/lib/miniapm/instrumentations/activerecord.rb +123 -0
- data/lib/miniapm/instrumentations/base.rb +61 -0
- data/lib/miniapm/instrumentations/cache.rb +85 -0
- data/lib/miniapm/instrumentations/http/faraday.rb +112 -0
- data/lib/miniapm/instrumentations/http/httparty.rb +84 -0
- data/lib/miniapm/instrumentations/http/net_http.rb +99 -0
- data/lib/miniapm/instrumentations/rails/controller.rb +129 -0
- data/lib/miniapm/instrumentations/rails/railtie.rb +42 -0
- data/lib/miniapm/instrumentations/redis/redis.rb +135 -0
- data/lib/miniapm/instrumentations/redis/redis_client.rb +116 -0
- data/lib/miniapm/instrumentations/registry.rb +90 -0
- data/lib/miniapm/instrumentations/search/elasticsearch.rb +121 -0
- data/lib/miniapm/instrumentations/search/opensearch.rb +120 -0
- data/lib/miniapm/instrumentations/search/searchkick.rb +119 -0
- data/lib/miniapm/instrumentations/sidekiq.rb +185 -0
- data/lib/miniapm/middleware/error_handler.rb +120 -0
- data/lib/miniapm/middleware/rack.rb +103 -0
- data/lib/miniapm/span.rb +289 -0
- data/lib/miniapm/testing.rb +209 -0
- data/lib/miniapm/trace.rb +26 -0
- data/lib/miniapm/transport/batch_sender.rb +345 -0
- data/lib/miniapm/transport/http.rb +45 -0
- data/lib/miniapm/version.rb +5 -0
- data/lib/miniapm.rb +184 -0
- metadata +183 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module MiniAPM
|
|
7
|
+
class ErrorEvent
|
|
8
|
+
attr_reader :exception_class, :message, :backtrace, :fingerprint
|
|
9
|
+
attr_reader :request_id, :user_id, :params, :timestamp
|
|
10
|
+
attr_reader :context
|
|
11
|
+
|
|
12
|
+
def initialize(
|
|
13
|
+
exception_class:,
|
|
14
|
+
message:,
|
|
15
|
+
backtrace:,
|
|
16
|
+
fingerprint: nil,
|
|
17
|
+
request_id: nil,
|
|
18
|
+
user_id: nil,
|
|
19
|
+
params: nil,
|
|
20
|
+
timestamp: nil,
|
|
21
|
+
context: {}
|
|
22
|
+
)
|
|
23
|
+
@exception_class = exception_class
|
|
24
|
+
@message = truncate(message, 10_000)
|
|
25
|
+
@backtrace = backtrace&.first(50) || []
|
|
26
|
+
@fingerprint = fingerprint || generate_fingerprint
|
|
27
|
+
@request_id = request_id
|
|
28
|
+
@user_id = user_id&.to_s
|
|
29
|
+
@params = filter_params(params)
|
|
30
|
+
@timestamp = timestamp || Time.now.utc
|
|
31
|
+
@context = context
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.from_exception(exception, context = {})
|
|
35
|
+
new(
|
|
36
|
+
exception_class: exception.class.name,
|
|
37
|
+
message: exception.message,
|
|
38
|
+
backtrace: exception.backtrace,
|
|
39
|
+
request_id: context[:request_id] || MiniAPM.current_trace_id,
|
|
40
|
+
user_id: context[:user_id],
|
|
41
|
+
params: context[:params],
|
|
42
|
+
context: context.except(:request_id, :user_id, :params)
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def to_h
|
|
47
|
+
{
|
|
48
|
+
exception_class: @exception_class,
|
|
49
|
+
message: @message,
|
|
50
|
+
backtrace: @backtrace,
|
|
51
|
+
fingerprint: @fingerprint,
|
|
52
|
+
request_id: @request_id,
|
|
53
|
+
user_id: @user_id,
|
|
54
|
+
params: @params,
|
|
55
|
+
timestamp: @timestamp.iso8601
|
|
56
|
+
}.compact
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def generate_fingerprint
|
|
62
|
+
# Create a stable fingerprint from exception class, message pattern, and first app backtrace line
|
|
63
|
+
parts = [@exception_class]
|
|
64
|
+
|
|
65
|
+
# Normalize message (remove variable parts like IDs, timestamps)
|
|
66
|
+
# Order matters: replace UUIDs first before numbers break the pattern
|
|
67
|
+
normalized_message = @message.to_s
|
|
68
|
+
.gsub(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i, "UUID") # Replace UUIDs first
|
|
69
|
+
.gsub(/\b\d+\b/, "N") # Replace numbers
|
|
70
|
+
.gsub(/'[^']*'/, "'X'") # Replace quoted strings
|
|
71
|
+
.gsub(/"[^"]*"/, '"X"') # Replace double-quoted strings
|
|
72
|
+
.slice(0, 200)
|
|
73
|
+
|
|
74
|
+
parts << normalized_message
|
|
75
|
+
|
|
76
|
+
# Find first application backtrace line (not gem/stdlib)
|
|
77
|
+
app_line = @backtrace.find do |line|
|
|
78
|
+
!line.include?("/gems/") &&
|
|
79
|
+
!line.include?("/ruby/") &&
|
|
80
|
+
!line.include?("/vendor/") &&
|
|
81
|
+
!line.start_with?("<")
|
|
82
|
+
end
|
|
83
|
+
parts << app_line if app_line
|
|
84
|
+
|
|
85
|
+
Digest::SHA256.hexdigest(parts.join("\n"))[0, 32]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
MAX_FILTER_DEPTH = 10
|
|
89
|
+
|
|
90
|
+
def filter_params(params)
|
|
91
|
+
return nil unless params.is_a?(Hash)
|
|
92
|
+
|
|
93
|
+
filter_keys = MiniAPM.configuration.filter_parameters
|
|
94
|
+
|
|
95
|
+
deep_filter(params, filter_keys, 0)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def deep_filter(hash, filter_keys, depth)
|
|
99
|
+
return { "__truncated__" => "max depth exceeded" } if depth >= MAX_FILTER_DEPTH
|
|
100
|
+
|
|
101
|
+
hash.each_with_object({}) do |(key, value), result|
|
|
102
|
+
if filter_keys.any? { |f| key_matches?(key, f) }
|
|
103
|
+
result[key] = "[FILTERED]"
|
|
104
|
+
elsif value.is_a?(Hash)
|
|
105
|
+
result[key] = deep_filter(value, filter_keys, depth + 1)
|
|
106
|
+
elsif value.is_a?(Array)
|
|
107
|
+
result[key] = value.first(100).map { |v| v.is_a?(Hash) ? deep_filter(v, filter_keys, depth + 1) : v }
|
|
108
|
+
else
|
|
109
|
+
result[key] = value
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def key_matches?(key, filter)
|
|
115
|
+
case filter
|
|
116
|
+
when Regexp
|
|
117
|
+
key.to_s.match?(filter)
|
|
118
|
+
else
|
|
119
|
+
key.to_s.downcase.include?(filter.to_s.downcase)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def truncate(string, max_length)
|
|
124
|
+
return nil if string.nil?
|
|
125
|
+
|
|
126
|
+
string = string.to_s
|
|
127
|
+
string.length > max_length ? string[0, max_length] + "..." : string
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MiniAPM
|
|
4
|
+
module Exporters
|
|
5
|
+
class Errors
|
|
6
|
+
class << self
|
|
7
|
+
# Export a single error (convenience method, uses batch endpoint)
|
|
8
|
+
def export(error_event)
|
|
9
|
+
config = MiniAPM.configuration
|
|
10
|
+
return { success: false, error: "No API key" } unless config.api_key
|
|
11
|
+
|
|
12
|
+
payload = error_event.to_h
|
|
13
|
+
|
|
14
|
+
result = Transport::HTTP.post(
|
|
15
|
+
"#{config.endpoint}/ingest/errors",
|
|
16
|
+
payload,
|
|
17
|
+
headers: auth_headers(config)
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
if result[:success]
|
|
21
|
+
MiniAPM.logger.debug { "MiniAPM: Reported error" }
|
|
22
|
+
else
|
|
23
|
+
MiniAPM.logger.debug { "MiniAPM: Failed to report error: #{result[:status]}" }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
result
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Export multiple errors in a single batch request
|
|
30
|
+
def export_batch(error_events)
|
|
31
|
+
return { success: true } if error_events.empty?
|
|
32
|
+
|
|
33
|
+
config = MiniAPM.configuration
|
|
34
|
+
return { success: false, error: "No API key" } unless config.api_key
|
|
35
|
+
|
|
36
|
+
# Send as a batch array
|
|
37
|
+
payload = { errors: error_events.map(&:to_h) }
|
|
38
|
+
|
|
39
|
+
result = Transport::HTTP.post(
|
|
40
|
+
"#{config.endpoint}/ingest/errors/batch",
|
|
41
|
+
payload,
|
|
42
|
+
headers: auth_headers(config)
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
if result[:success]
|
|
46
|
+
MiniAPM.logger.debug { "MiniAPM: Reported #{error_events.size} error(s)" }
|
|
47
|
+
else
|
|
48
|
+
MiniAPM.logger.warn { "MiniAPM: Failed to report errors: #{result[:status]}" }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
{
|
|
52
|
+
success: result[:success],
|
|
53
|
+
sent: result[:success] ? error_events.size : 0,
|
|
54
|
+
failed: result[:success] ? 0 : error_events.size,
|
|
55
|
+
status: result[:status]
|
|
56
|
+
}
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def auth_headers(config)
|
|
62
|
+
{ "Authorization" => "Bearer #{config.api_key}" }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MiniAPM
|
|
4
|
+
module Exporters
|
|
5
|
+
class OTLP
|
|
6
|
+
class << self
|
|
7
|
+
def export(spans)
|
|
8
|
+
return if spans.empty?
|
|
9
|
+
|
|
10
|
+
config = MiniAPM.configuration
|
|
11
|
+
return unless config.api_key
|
|
12
|
+
|
|
13
|
+
payload = build_otlp_payload(spans, config)
|
|
14
|
+
|
|
15
|
+
result = Transport::HTTP.post(
|
|
16
|
+
"#{config.endpoint}/ingest/v1/traces",
|
|
17
|
+
payload,
|
|
18
|
+
headers: auth_headers(config)
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
if result[:success]
|
|
22
|
+
MiniAPM.logger.debug { "MiniAPM: Exported #{spans.size} spans" }
|
|
23
|
+
else
|
|
24
|
+
MiniAPM.logger.debug { "MiniAPM: Failed to export spans: #{result[:status]}" }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
result
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def build_otlp_payload(spans, config)
|
|
33
|
+
{
|
|
34
|
+
"resourceSpans" => [
|
|
35
|
+
{
|
|
36
|
+
"resource" => {
|
|
37
|
+
"attributes" => resource_attributes(config)
|
|
38
|
+
},
|
|
39
|
+
"scopeSpans" => [
|
|
40
|
+
{
|
|
41
|
+
"scope" => {
|
|
42
|
+
"name" => "miniapm-ruby",
|
|
43
|
+
"version" => MiniAPM::VERSION
|
|
44
|
+
},
|
|
45
|
+
"spans" => spans.map(&:to_otlp)
|
|
46
|
+
}
|
|
47
|
+
]
|
|
48
|
+
}
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def resource_attributes(config)
|
|
54
|
+
attrs = [
|
|
55
|
+
kv("service.name", config.service_name),
|
|
56
|
+
kv("deployment.environment", config.environment)
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
attrs << kv("service.version", config.service_version) if config.service_version
|
|
60
|
+
attrs << kv("host.name", config.host) if config.host
|
|
61
|
+
attrs << kv("telemetry.sdk.name", "miniapm-ruby")
|
|
62
|
+
attrs << kv("telemetry.sdk.version", MiniAPM::VERSION)
|
|
63
|
+
attrs << kv("telemetry.sdk.language", "ruby")
|
|
64
|
+
|
|
65
|
+
if config.rails_version
|
|
66
|
+
attrs << kv("rails.version", config.rails_version)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
if config.ruby_version
|
|
70
|
+
attrs << kv("ruby.version", config.ruby_version)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
if config.git_sha
|
|
74
|
+
attrs << kv("git.sha", config.git_sha)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
attrs
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def kv(key, value)
|
|
81
|
+
{ "key" => key, "value" => { "stringValue" => value.to_s } }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def auth_headers(config)
|
|
85
|
+
{ "Authorization" => "Bearer #{config.api_key}" }
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MiniAPM
|
|
4
|
+
module Instrumentations
|
|
5
|
+
class ActiveJob < Base
|
|
6
|
+
# Keys for trace context in job metadata
|
|
7
|
+
TRACE_ID_KEY = "_miniapm_trace_id"
|
|
8
|
+
PARENT_SPAN_ID_KEY = "_miniapm_parent_span_id"
|
|
9
|
+
SAMPLED_KEY = "_miniapm_sampled"
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
def install!
|
|
13
|
+
return if installed?
|
|
14
|
+
mark_installed!
|
|
15
|
+
|
|
16
|
+
# Install the job extension for trace propagation
|
|
17
|
+
install_job_extension!
|
|
18
|
+
|
|
19
|
+
# Subscribe to notifications for metrics/events
|
|
20
|
+
subscribe("perform.active_job") do |event|
|
|
21
|
+
handle_perform(event)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
subscribe("enqueue.active_job") do |event|
|
|
25
|
+
handle_enqueue(event)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
subscribe("enqueue_at.active_job") do |event|
|
|
29
|
+
handle_enqueue(event)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
subscribe("discard.active_job") do |event|
|
|
33
|
+
handle_discard(event)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
subscribe("retry_stopped.active_job") do |event|
|
|
37
|
+
handle_retry_stopped(event)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def install_job_extension!
|
|
44
|
+
return unless defined?(::ActiveJob::Base)
|
|
45
|
+
|
|
46
|
+
::ActiveJob::Base.include(JobExtension)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def handle_perform(event)
|
|
50
|
+
# Performance tracking is handled by JobExtension#perform
|
|
51
|
+
# This is just for additional metadata
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def handle_enqueue(event)
|
|
55
|
+
return unless MiniAPM.enabled?
|
|
56
|
+
return unless Context.current_trace
|
|
57
|
+
|
|
58
|
+
job = event.payload[:job]
|
|
59
|
+
|
|
60
|
+
span = create_span_from_event(
|
|
61
|
+
event,
|
|
62
|
+
name: "#{job.class.name}.enqueue",
|
|
63
|
+
category: :job,
|
|
64
|
+
attributes: {
|
|
65
|
+
"messaging.system" => queue_adapter_name(job),
|
|
66
|
+
"messaging.destination.name" => job.queue_name,
|
|
67
|
+
"messaging.operation" => "send",
|
|
68
|
+
"job.id" => job.job_id,
|
|
69
|
+
"job.class" => job.class.name
|
|
70
|
+
}
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
record_span(span)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def handle_discard(event)
|
|
77
|
+
return unless MiniAPM.enabled?
|
|
78
|
+
|
|
79
|
+
job = event.payload[:job]
|
|
80
|
+
error = event.payload[:error]
|
|
81
|
+
|
|
82
|
+
if error
|
|
83
|
+
MiniAPM.record_error(error, context: {
|
|
84
|
+
job_class: job.class.name,
|
|
85
|
+
job_id: job.job_id,
|
|
86
|
+
queue: job.queue_name,
|
|
87
|
+
discarded: true
|
|
88
|
+
})
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def handle_retry_stopped(event)
|
|
93
|
+
return unless MiniAPM.enabled?
|
|
94
|
+
|
|
95
|
+
job = event.payload[:job]
|
|
96
|
+
error = event.payload[:error]
|
|
97
|
+
|
|
98
|
+
if error
|
|
99
|
+
MiniAPM.record_error(error, context: {
|
|
100
|
+
job_class: job.class.name,
|
|
101
|
+
job_id: job.job_id,
|
|
102
|
+
queue: job.queue_name,
|
|
103
|
+
retry_stopped: true,
|
|
104
|
+
executions: job.executions
|
|
105
|
+
})
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def queue_adapter_name(job)
|
|
110
|
+
adapter = job.class.queue_adapter
|
|
111
|
+
adapter_class = adapter.is_a?(Class) ? adapter : adapter.class
|
|
112
|
+
|
|
113
|
+
case adapter_class.name
|
|
114
|
+
when /SolidQueue/
|
|
115
|
+
"solid_queue"
|
|
116
|
+
when /Sidekiq/
|
|
117
|
+
"sidekiq"
|
|
118
|
+
when /Async/
|
|
119
|
+
"async"
|
|
120
|
+
when /Inline/
|
|
121
|
+
"inline"
|
|
122
|
+
when /Delayed/
|
|
123
|
+
"delayed_job"
|
|
124
|
+
when /Resque/
|
|
125
|
+
"resque"
|
|
126
|
+
when /Sneakers/
|
|
127
|
+
"sneakers"
|
|
128
|
+
when /Sucker/
|
|
129
|
+
"sucker_punch"
|
|
130
|
+
when /Test/
|
|
131
|
+
"test"
|
|
132
|
+
else
|
|
133
|
+
adapter_class.name.to_s.split("::").last.to_s.gsub(/Adapter$/, "").downcase
|
|
134
|
+
end
|
|
135
|
+
rescue StandardError
|
|
136
|
+
"unknown"
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Extension module included in ActiveJob::Base
|
|
141
|
+
module JobExtension
|
|
142
|
+
extend ActiveSupport::Concern
|
|
143
|
+
|
|
144
|
+
included do
|
|
145
|
+
# Serialize trace context before enqueueing
|
|
146
|
+
before_enqueue do |job|
|
|
147
|
+
if MiniAPM::Context.current_trace
|
|
148
|
+
job.miniapm_trace_id = MiniAPM::Context.current_trace_id
|
|
149
|
+
job.miniapm_parent_span_id = MiniAPM::Context.current_span&.span_id
|
|
150
|
+
job.miniapm_sampled = MiniAPM::Context.current_trace.sampled?
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Wrap perform with tracing
|
|
155
|
+
around_perform do |job, block|
|
|
156
|
+
if MiniAPM.enabled?
|
|
157
|
+
job.perform_with_tracing(&block)
|
|
158
|
+
else
|
|
159
|
+
block.call
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Accessors for trace context stored in job metadata
|
|
165
|
+
def miniapm_trace_id
|
|
166
|
+
@miniapm_trace_id
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def miniapm_trace_id=(value)
|
|
170
|
+
@miniapm_trace_id = value
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def miniapm_parent_span_id
|
|
174
|
+
@miniapm_parent_span_id
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def miniapm_parent_span_id=(value)
|
|
178
|
+
@miniapm_parent_span_id = value
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def miniapm_sampled
|
|
182
|
+
@miniapm_sampled
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def miniapm_sampled=(value)
|
|
186
|
+
@miniapm_sampled = value
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Override serialize to include trace context
|
|
190
|
+
def serialize
|
|
191
|
+
super.merge(
|
|
192
|
+
TRACE_ID_KEY => miniapm_trace_id,
|
|
193
|
+
PARENT_SPAN_ID_KEY => miniapm_parent_span_id,
|
|
194
|
+
SAMPLED_KEY => miniapm_sampled
|
|
195
|
+
).compact
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Override deserialize to restore trace context
|
|
199
|
+
def deserialize(job_data)
|
|
200
|
+
super
|
|
201
|
+
self.miniapm_trace_id = job_data[TRACE_ID_KEY]
|
|
202
|
+
self.miniapm_parent_span_id = job_data[PARENT_SPAN_ID_KEY]
|
|
203
|
+
self.miniapm_sampled = job_data[SAMPLED_KEY]
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def perform_with_tracing
|
|
207
|
+
# Create trace with propagated context or new trace
|
|
208
|
+
trace = MiniAPM::Trace.new(
|
|
209
|
+
trace_id: miniapm_trace_id,
|
|
210
|
+
sampled: miniapm_sampled
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
# Skip tracing if not sampled
|
|
214
|
+
return yield unless trace.sampled?
|
|
215
|
+
|
|
216
|
+
MiniAPM::Context.current_trace = trace
|
|
217
|
+
|
|
218
|
+
span = MiniAPM::Span.new(
|
|
219
|
+
name: "#{self.class.name}.perform",
|
|
220
|
+
category: :job,
|
|
221
|
+
trace_id: trace.trace_id,
|
|
222
|
+
parent_span_id: miniapm_parent_span_id,
|
|
223
|
+
attributes: build_job_attributes
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
MiniAPM::Context.with_span(span) do
|
|
227
|
+
begin
|
|
228
|
+
yield
|
|
229
|
+
span.set_ok
|
|
230
|
+
rescue StandardError => e
|
|
231
|
+
span.record_exception(e)
|
|
232
|
+
MiniAPM.record_error(e, context: {
|
|
233
|
+
job_class: self.class.name,
|
|
234
|
+
job_id: job_id,
|
|
235
|
+
queue: queue_name
|
|
236
|
+
})
|
|
237
|
+
raise
|
|
238
|
+
ensure
|
|
239
|
+
span.finish
|
|
240
|
+
MiniAPM.record_span(span)
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
ensure
|
|
244
|
+
MiniAPM::Context.clear!
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
private
|
|
248
|
+
|
|
249
|
+
def build_job_attributes
|
|
250
|
+
attrs = {
|
|
251
|
+
"messaging.system" => MiniAPM::Instrumentations::ActiveJob.send(:queue_adapter_name, self),
|
|
252
|
+
"messaging.destination.name" => queue_name,
|
|
253
|
+
"messaging.operation" => "process",
|
|
254
|
+
"job.id" => job_id,
|
|
255
|
+
"job.class" => self.class.name,
|
|
256
|
+
"job.queue" => queue_name,
|
|
257
|
+
"job.executions" => executions
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
attrs["job.priority"] = priority if priority
|
|
261
|
+
attrs["job.scheduled_at"] = scheduled_at.iso8601 if scheduled_at
|
|
262
|
+
|
|
263
|
+
attrs
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Auto-install when loaded
|
|
271
|
+
MiniAPM::Instrumentations::ActiveJob.install!
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MiniAPM
|
|
4
|
+
module Instrumentations
|
|
5
|
+
class ActiveRecord < Base
|
|
6
|
+
IGNORED_QUERIES = ["SCHEMA", "CACHE"].freeze
|
|
7
|
+
IGNORED_SQL_PATTERNS = /\A\s*(PRAGMA|EXPLAIN|BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE)/i
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
def install!
|
|
11
|
+
return if installed?
|
|
12
|
+
mark_installed!
|
|
13
|
+
|
|
14
|
+
subscribe("sql.active_record") do |event|
|
|
15
|
+
handle_sql(event)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def handle_sql(event)
|
|
22
|
+
return unless MiniAPM.enabled?
|
|
23
|
+
return unless Context.current_trace
|
|
24
|
+
|
|
25
|
+
payload = event.payload
|
|
26
|
+
|
|
27
|
+
# Skip schema queries and internal AR queries
|
|
28
|
+
return if IGNORED_QUERIES.include?(payload[:name])
|
|
29
|
+
return if payload[:sql]&.match?(IGNORED_SQL_PATTERNS)
|
|
30
|
+
return if payload[:cached]
|
|
31
|
+
|
|
32
|
+
sql = payload[:sql].to_s
|
|
33
|
+
operation = extract_operation(sql)
|
|
34
|
+
table = extract_table(sql)
|
|
35
|
+
|
|
36
|
+
name = [operation, table].compact.join(" ")
|
|
37
|
+
name = operation if name.empty?
|
|
38
|
+
|
|
39
|
+
attributes = {
|
|
40
|
+
"db.system" => adapter_name(payload),
|
|
41
|
+
"db.operation" => operation
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
attributes["db.sql.table"] = table if table
|
|
45
|
+
|
|
46
|
+
# Optionally log SQL (configurable, defaults to off)
|
|
47
|
+
if MiniAPM.configuration.instrumentations.options(:activerecord)[:log_sql]
|
|
48
|
+
attributes["db.statement"] = truncate_sql(sql)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Add database name if available
|
|
52
|
+
db_name = database_name(payload)
|
|
53
|
+
attributes["db.name"] = db_name if db_name
|
|
54
|
+
|
|
55
|
+
# Add connection info
|
|
56
|
+
if payload[:connection_id]
|
|
57
|
+
attributes["db.connection_id"] = payload[:connection_id]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
span = create_span_from_event(
|
|
61
|
+
event,
|
|
62
|
+
name: name,
|
|
63
|
+
category: :db,
|
|
64
|
+
attributes: attributes
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
record_span(span)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def extract_operation(sql)
|
|
71
|
+
sql.strip.split(/\s+/).first&.upcase || "QUERY"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def extract_table(sql)
|
|
75
|
+
# Match FROM/INTO/UPDATE/JOIN/DELETE FROM table patterns
|
|
76
|
+
patterns = [
|
|
77
|
+
/\bFROM\s+[`"']?(\w+)[`"']?/i,
|
|
78
|
+
/\bINTO\s+[`"']?(\w+)[`"']?/i,
|
|
79
|
+
/\bUPDATE\s+[`"']?(\w+)[`"']?/i,
|
|
80
|
+
/\bJOIN\s+[`"']?(\w+)[`"']?/i,
|
|
81
|
+
/\bDELETE\s+FROM\s+[`"']?(\w+)[`"']?/i
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
patterns.each do |pattern|
|
|
85
|
+
match = sql.match(pattern)
|
|
86
|
+
return match[1] if match
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def adapter_name(payload)
|
|
93
|
+
if payload[:connection]
|
|
94
|
+
payload[:connection].adapter_name&.downcase
|
|
95
|
+
elsif payload[:connection_id] && defined?(::ActiveRecord::Base)
|
|
96
|
+
::ActiveRecord::Base.connection.adapter_name.downcase rescue "unknown"
|
|
97
|
+
else
|
|
98
|
+
"unknown"
|
|
99
|
+
end
|
|
100
|
+
rescue StandardError
|
|
101
|
+
"unknown"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def database_name(payload)
|
|
105
|
+
if payload[:connection]
|
|
106
|
+
payload[:connection].current_database rescue nil
|
|
107
|
+
elsif defined?(::ActiveRecord::Base)
|
|
108
|
+
::ActiveRecord::Base.connection.current_database rescue nil
|
|
109
|
+
end
|
|
110
|
+
rescue StandardError
|
|
111
|
+
nil
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def truncate_sql(sql, max_length: 2000)
|
|
115
|
+
sql.length > max_length ? sql[0...max_length] + "..." : sql
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Auto-install when loaded
|
|
123
|
+
MiniAPM::Instrumentations::ActiveRecord.install!
|