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.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +43 -0
  3. data/LICENSE +21 -0
  4. data/README.md +174 -0
  5. data/lib/generators/miniapm/install_generator.rb +27 -0
  6. data/lib/generators/miniapm/templates/README +19 -0
  7. data/lib/generators/miniapm/templates/initializer.rb +60 -0
  8. data/lib/miniapm/configuration.rb +176 -0
  9. data/lib/miniapm/context.rb +138 -0
  10. data/lib/miniapm/error_event.rb +130 -0
  11. data/lib/miniapm/exporters/errors.rb +67 -0
  12. data/lib/miniapm/exporters/otlp.rb +90 -0
  13. data/lib/miniapm/instrumentations/activejob.rb +271 -0
  14. data/lib/miniapm/instrumentations/activerecord.rb +123 -0
  15. data/lib/miniapm/instrumentations/base.rb +61 -0
  16. data/lib/miniapm/instrumentations/cache.rb +85 -0
  17. data/lib/miniapm/instrumentations/http/faraday.rb +112 -0
  18. data/lib/miniapm/instrumentations/http/httparty.rb +84 -0
  19. data/lib/miniapm/instrumentations/http/net_http.rb +99 -0
  20. data/lib/miniapm/instrumentations/rails/controller.rb +129 -0
  21. data/lib/miniapm/instrumentations/rails/railtie.rb +42 -0
  22. data/lib/miniapm/instrumentations/redis/redis.rb +135 -0
  23. data/lib/miniapm/instrumentations/redis/redis_client.rb +116 -0
  24. data/lib/miniapm/instrumentations/registry.rb +90 -0
  25. data/lib/miniapm/instrumentations/search/elasticsearch.rb +121 -0
  26. data/lib/miniapm/instrumentations/search/opensearch.rb +120 -0
  27. data/lib/miniapm/instrumentations/search/searchkick.rb +119 -0
  28. data/lib/miniapm/instrumentations/sidekiq.rb +185 -0
  29. data/lib/miniapm/middleware/error_handler.rb +120 -0
  30. data/lib/miniapm/middleware/rack.rb +103 -0
  31. data/lib/miniapm/span.rb +289 -0
  32. data/lib/miniapm/testing.rb +209 -0
  33. data/lib/miniapm/trace.rb +26 -0
  34. data/lib/miniapm/transport/batch_sender.rb +345 -0
  35. data/lib/miniapm/transport/http.rb +45 -0
  36. data/lib/miniapm/version.rb +5 -0
  37. data/lib/miniapm.rb +184 -0
  38. 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!