findbug 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +8 -0
- data/LICENSE.txt +21 -0
- data/README.md +375 -0
- data/Rakefile +12 -0
- data/app/controllers/findbug/application_controller.rb +105 -0
- data/app/controllers/findbug/dashboard_controller.rb +93 -0
- data/app/controllers/findbug/errors_controller.rb +129 -0
- data/app/controllers/findbug/performance_controller.rb +80 -0
- data/app/jobs/findbug/alert_job.rb +40 -0
- data/app/jobs/findbug/cleanup_job.rb +132 -0
- data/app/jobs/findbug/persist_job.rb +158 -0
- data/app/models/findbug/error_event.rb +197 -0
- data/app/models/findbug/performance_event.rb +237 -0
- data/app/views/findbug/dashboard/index.html.erb +199 -0
- data/app/views/findbug/errors/index.html.erb +137 -0
- data/app/views/findbug/errors/show.html.erb +185 -0
- data/app/views/findbug/performance/index.html.erb +168 -0
- data/app/views/findbug/performance/show.html.erb +203 -0
- data/app/views/layouts/findbug/application.html.erb +601 -0
- data/lib/findbug/alerts/channels/base.rb +75 -0
- data/lib/findbug/alerts/channels/discord.rb +155 -0
- data/lib/findbug/alerts/channels/email.rb +179 -0
- data/lib/findbug/alerts/channels/slack.rb +149 -0
- data/lib/findbug/alerts/channels/webhook.rb +143 -0
- data/lib/findbug/alerts/dispatcher.rb +126 -0
- data/lib/findbug/alerts/throttler.rb +110 -0
- data/lib/findbug/background_persister.rb +142 -0
- data/lib/findbug/capture/context.rb +301 -0
- data/lib/findbug/capture/exception_handler.rb +141 -0
- data/lib/findbug/capture/exception_subscriber.rb +228 -0
- data/lib/findbug/capture/message_handler.rb +104 -0
- data/lib/findbug/capture/middleware.rb +247 -0
- data/lib/findbug/configuration.rb +381 -0
- data/lib/findbug/engine.rb +109 -0
- data/lib/findbug/performance/instrumentation.rb +336 -0
- data/lib/findbug/performance/transaction.rb +193 -0
- data/lib/findbug/processing/data_scrubber.rb +163 -0
- data/lib/findbug/rails/controller_methods.rb +152 -0
- data/lib/findbug/railtie.rb +222 -0
- data/lib/findbug/storage/circuit_breaker.rb +223 -0
- data/lib/findbug/storage/connection_pool.rb +134 -0
- data/lib/findbug/storage/redis_buffer.rb +285 -0
- data/lib/findbug/tasks/findbug.rake +167 -0
- data/lib/findbug/version.rb +5 -0
- data/lib/findbug.rb +216 -0
- data/lib/generators/findbug/install_generator.rb +67 -0
- data/lib/generators/findbug/templates/POST_INSTALL +41 -0
- data/lib/generators/findbug/templates/create_findbug_error_events.rb +44 -0
- data/lib/generators/findbug/templates/create_findbug_performance_events.rb +47 -0
- data/lib/generators/findbug/templates/initializer.rb +157 -0
- data/sig/findbug.rbs +4 -0
- metadata +251 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/engine"
|
|
4
|
+
|
|
5
|
+
# Calculate the gem root path once at load time
|
|
6
|
+
# __dir__ is lib/findbug, so we go up two levels to get the gem root
|
|
7
|
+
FINDBUG_GEM_ROOT = File.expand_path("../..", __dir__)
|
|
8
|
+
|
|
9
|
+
# Require models (needed for persistence)
|
|
10
|
+
require_relative "../../app/models/findbug/error_event"
|
|
11
|
+
require_relative "../../app/models/findbug/performance_event"
|
|
12
|
+
|
|
13
|
+
# Require controllers
|
|
14
|
+
require_relative "../../app/controllers/findbug/application_controller"
|
|
15
|
+
require_relative "../../app/controllers/findbug/dashboard_controller"
|
|
16
|
+
require_relative "../../app/controllers/findbug/errors_controller"
|
|
17
|
+
require_relative "../../app/controllers/findbug/performance_controller"
|
|
18
|
+
|
|
19
|
+
module Findbug
|
|
20
|
+
# Engine is the main Rails integration point for Findbug.
|
|
21
|
+
#
|
|
22
|
+
# WHAT IS A RAILS ENGINE?
|
|
23
|
+
# =======================
|
|
24
|
+
#
|
|
25
|
+
# An engine is like a mini Rails app that can be mounted inside another app.
|
|
26
|
+
# It has its own:
|
|
27
|
+
# - Controllers
|
|
28
|
+
# - Models
|
|
29
|
+
# - Views
|
|
30
|
+
# - Routes
|
|
31
|
+
# - Assets
|
|
32
|
+
#
|
|
33
|
+
# But it shares the host app's:
|
|
34
|
+
# - Database connection
|
|
35
|
+
# - Session
|
|
36
|
+
# - Application configuration
|
|
37
|
+
#
|
|
38
|
+
# This is how gems like Sidekiq, Resque, and Devise provide web UIs.
|
|
39
|
+
#
|
|
40
|
+
# MOUNTING THE ENGINE
|
|
41
|
+
# ===================
|
|
42
|
+
#
|
|
43
|
+
# The Railtie automatically mounts this engine at config.web_path (default "/findbug").
|
|
44
|
+
#
|
|
45
|
+
# Users can also manually mount:
|
|
46
|
+
#
|
|
47
|
+
# # config/routes.rb
|
|
48
|
+
# mount Findbug::Engine => "/my-findbug"
|
|
49
|
+
#
|
|
50
|
+
# ISOLATION
|
|
51
|
+
# =========
|
|
52
|
+
#
|
|
53
|
+
# We use `isolate_namespace` to prevent our routes/helpers from conflicting
|
|
54
|
+
# with the host app. All our routes are prefixed with `findbug_`.
|
|
55
|
+
#
|
|
56
|
+
class Engine < ::Rails::Engine
|
|
57
|
+
# Isolate our namespace to avoid conflicts with host app
|
|
58
|
+
isolate_namespace Findbug
|
|
59
|
+
|
|
60
|
+
# Engine name for route helpers (findbug.errors_path, etc.)
|
|
61
|
+
engine_name "findbug"
|
|
62
|
+
|
|
63
|
+
# Set the root path for the engine to the gem's root directory
|
|
64
|
+
# This tells Rails where to find app/controllers, app/models, app/views, etc.
|
|
65
|
+
def self.root
|
|
66
|
+
@root ||= Pathname.new(FINDBUG_GEM_ROOT)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Configure the engine
|
|
70
|
+
config.findbug = ActiveSupport::OrderedOptions.new
|
|
71
|
+
|
|
72
|
+
# Add our view paths to ActionController
|
|
73
|
+
initializer "findbug.add_view_paths" do |app|
|
|
74
|
+
views_path = File.join(FINDBUG_GEM_ROOT, "app", "views")
|
|
75
|
+
ActiveSupport.on_load(:action_controller) do
|
|
76
|
+
prepend_view_path views_path
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# NOTE: We intentionally do NOT add session/flash middleware here.
|
|
81
|
+
# Adding middleware to API-mode apps would change their behavior
|
|
82
|
+
# (e.g., showing HTML error pages instead of JSON).
|
|
83
|
+
# The layout handles missing flash gracefully with a rescue block.
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Define routes for the engine
|
|
88
|
+
Findbug::Engine.routes.draw do
|
|
89
|
+
# Dashboard (root)
|
|
90
|
+
root to: "dashboard#index"
|
|
91
|
+
|
|
92
|
+
# Errors
|
|
93
|
+
resources :errors, only: [:index, :show] do
|
|
94
|
+
member do
|
|
95
|
+
post :resolve
|
|
96
|
+
post :ignore
|
|
97
|
+
post :reopen
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Performance
|
|
102
|
+
resources :performance, only: [:index, :show]
|
|
103
|
+
|
|
104
|
+
# Health check (useful for monitoring)
|
|
105
|
+
get "health", to: "dashboard#health"
|
|
106
|
+
|
|
107
|
+
# Stats API (for AJAX updates)
|
|
108
|
+
get "stats", to: "dashboard#stats"
|
|
109
|
+
end
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/notifications"
|
|
4
|
+
|
|
5
|
+
module Findbug
|
|
6
|
+
module Performance
|
|
7
|
+
# Instrumentation subscribes to Rails' ActiveSupport::Notifications.
|
|
8
|
+
#
|
|
9
|
+
# WHAT IS ActiveSupport::Notifications?
|
|
10
|
+
# =====================================
|
|
11
|
+
#
|
|
12
|
+
# Rails has a built-in pub/sub system for internal events. Every time
|
|
13
|
+
# something interesting happens, Rails publishes a notification:
|
|
14
|
+
#
|
|
15
|
+
# - sql.active_record → Database queries
|
|
16
|
+
# - process_action.action_controller → HTTP requests
|
|
17
|
+
# - render_template.action_view → View rendering
|
|
18
|
+
# - cache_read.active_support → Cache operations
|
|
19
|
+
#
|
|
20
|
+
# Any code can subscribe to these events:
|
|
21
|
+
#
|
|
22
|
+
# ActiveSupport::Notifications.subscribe("sql.active_record") do |event|
|
|
23
|
+
# puts "Query took #{event.duration}ms"
|
|
24
|
+
# end
|
|
25
|
+
#
|
|
26
|
+
# This is how Rails' request logs, performance gems, and APM tools work.
|
|
27
|
+
# We subscribe to capture timing data for our dashboard.
|
|
28
|
+
#
|
|
29
|
+
# WHY NOT MIDDLEWARE FOR PERFORMANCE?
|
|
30
|
+
# ====================================
|
|
31
|
+
#
|
|
32
|
+
# Middleware only sees the request start and end. It can't see:
|
|
33
|
+
# - Individual SQL queries
|
|
34
|
+
# - Which view took how long
|
|
35
|
+
# - Cache hits/misses
|
|
36
|
+
#
|
|
37
|
+
# Notifications give us granular visibility into the request lifecycle.
|
|
38
|
+
#
|
|
39
|
+
class Instrumentation
|
|
40
|
+
SUBSCRIPTIONS = [
|
|
41
|
+
"process_action.action_controller",
|
|
42
|
+
"sql.active_record",
|
|
43
|
+
"render_template.action_view",
|
|
44
|
+
"render_partial.action_view",
|
|
45
|
+
"cache_read.active_support",
|
|
46
|
+
"cache_write.active_support"
|
|
47
|
+
].freeze
|
|
48
|
+
|
|
49
|
+
class << self
|
|
50
|
+
# Set up all instrumentation subscriptions
|
|
51
|
+
#
|
|
52
|
+
# Called once during Rails initialization (via Railtie).
|
|
53
|
+
#
|
|
54
|
+
def setup!
|
|
55
|
+
return if @setup_complete
|
|
56
|
+
return unless Findbug.config.performance_enabled
|
|
57
|
+
|
|
58
|
+
subscribe_to_requests
|
|
59
|
+
subscribe_to_queries
|
|
60
|
+
subscribe_to_views
|
|
61
|
+
subscribe_to_cache
|
|
62
|
+
|
|
63
|
+
@setup_complete = true
|
|
64
|
+
Findbug.logger.debug("[Findbug] Performance instrumentation enabled")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Tear down subscriptions (for testing)
|
|
68
|
+
def teardown!
|
|
69
|
+
@subscriptions&.each do |subscriber|
|
|
70
|
+
ActiveSupport::Notifications.unsubscribe(subscriber)
|
|
71
|
+
end
|
|
72
|
+
@subscriptions = []
|
|
73
|
+
@setup_complete = false
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def subscriptions
|
|
79
|
+
@subscriptions ||= []
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Subscribe to HTTP request completion
|
|
83
|
+
#
|
|
84
|
+
# This is the main event - it fires when a request finishes.
|
|
85
|
+
# We use it to aggregate all the data collected during the request.
|
|
86
|
+
#
|
|
87
|
+
def subscribe_to_requests
|
|
88
|
+
subscriber = ActiveSupport::Notifications.subscribe(
|
|
89
|
+
"process_action.action_controller"
|
|
90
|
+
) do |event|
|
|
91
|
+
handle_request_complete(event)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
subscriptions << subscriber
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Subscribe to SQL queries
|
|
98
|
+
#
|
|
99
|
+
# This fires for EVERY database query. We collect them all,
|
|
100
|
+
# then analyze for slow queries and N+1 patterns.
|
|
101
|
+
#
|
|
102
|
+
def subscribe_to_queries
|
|
103
|
+
subscriber = ActiveSupport::Notifications.subscribe(
|
|
104
|
+
"sql.active_record"
|
|
105
|
+
) do |event|
|
|
106
|
+
handle_sql_query(event)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
subscriptions << subscriber
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Subscribe to view rendering
|
|
113
|
+
def subscribe_to_views
|
|
114
|
+
%w[render_template render_partial].each do |event_name|
|
|
115
|
+
subscriber = ActiveSupport::Notifications.subscribe(
|
|
116
|
+
"#{event_name}.action_view"
|
|
117
|
+
) do |event|
|
|
118
|
+
handle_view_render(event)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
subscriptions << subscriber
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Subscribe to cache operations
|
|
126
|
+
def subscribe_to_cache
|
|
127
|
+
%w[cache_read cache_write].each do |event_name|
|
|
128
|
+
subscriber = ActiveSupport::Notifications.subscribe(
|
|
129
|
+
"#{event_name}.active_support"
|
|
130
|
+
) do |event|
|
|
131
|
+
handle_cache_operation(event)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
subscriptions << subscriber
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Handle request completion
|
|
139
|
+
#
|
|
140
|
+
# This is where we assemble all the data and decide whether to capture.
|
|
141
|
+
#
|
|
142
|
+
def handle_request_complete(event)
|
|
143
|
+
return unless should_sample?
|
|
144
|
+
|
|
145
|
+
# Get collected data from thread-local storage
|
|
146
|
+
request_data = current_request_data
|
|
147
|
+
|
|
148
|
+
# Build the performance event
|
|
149
|
+
perf_event = build_performance_event(event, request_data)
|
|
150
|
+
|
|
151
|
+
# Check against thresholds
|
|
152
|
+
return unless meets_threshold?(perf_event)
|
|
153
|
+
|
|
154
|
+
# Push to Redis (async)
|
|
155
|
+
Storage::RedisBuffer.push_performance(perf_event)
|
|
156
|
+
rescue StandardError => e
|
|
157
|
+
Findbug.logger.debug("[Findbug] Performance capture failed: #{e.message}")
|
|
158
|
+
ensure
|
|
159
|
+
clear_request_data
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Handle individual SQL query
|
|
163
|
+
def handle_sql_query(event)
|
|
164
|
+
# Skip schema queries (they're not real app queries)
|
|
165
|
+
return if event.payload[:name] == "SCHEMA"
|
|
166
|
+
return if event.payload[:sql]&.start_with?("SHOW ")
|
|
167
|
+
|
|
168
|
+
# Store in thread-local array
|
|
169
|
+
queries = current_request_data[:queries] ||= []
|
|
170
|
+
|
|
171
|
+
queries << {
|
|
172
|
+
sql: truncate_sql(event.payload[:sql]),
|
|
173
|
+
name: event.payload[:name],
|
|
174
|
+
duration_ms: event.duration,
|
|
175
|
+
cached: event.payload[:cached] || false
|
|
176
|
+
}
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Handle view render
|
|
180
|
+
def handle_view_render(event)
|
|
181
|
+
views = current_request_data[:views] ||= []
|
|
182
|
+
|
|
183
|
+
views << {
|
|
184
|
+
identifier: event.payload[:identifier]&.sub(Rails.root.to_s + "/", ""),
|
|
185
|
+
duration_ms: event.duration,
|
|
186
|
+
layout: event.payload[:layout]
|
|
187
|
+
}
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Handle cache operation
|
|
191
|
+
def handle_cache_operation(event)
|
|
192
|
+
cache_ops = current_request_data[:cache] ||= []
|
|
193
|
+
|
|
194
|
+
cache_ops << {
|
|
195
|
+
operation: event.name.split(".").first, # cache_read or cache_write
|
|
196
|
+
key: truncate_cache_key(event.payload[:key]),
|
|
197
|
+
hit: event.payload[:hit],
|
|
198
|
+
duration_ms: event.duration
|
|
199
|
+
}
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Build the final performance event
|
|
203
|
+
def build_performance_event(event, request_data)
|
|
204
|
+
payload = event.payload
|
|
205
|
+
queries = request_data[:queries] || []
|
|
206
|
+
views = request_data[:views] || []
|
|
207
|
+
|
|
208
|
+
# Calculate aggregates
|
|
209
|
+
db_time = queries.sum { |q| q[:duration_ms] }
|
|
210
|
+
view_time = views.sum { |v| v[:duration_ms] }
|
|
211
|
+
|
|
212
|
+
# Detect N+1 queries
|
|
213
|
+
n_plus_one = detect_n_plus_one(queries)
|
|
214
|
+
|
|
215
|
+
# Find slow queries
|
|
216
|
+
slow_queries = queries.select do |q|
|
|
217
|
+
q[:duration_ms] >= Findbug.config.slow_query_threshold_ms
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
{
|
|
221
|
+
transaction_name: "#{payload[:controller]}##{payload[:action]}",
|
|
222
|
+
request_method: payload[:method],
|
|
223
|
+
request_path: payload[:path],
|
|
224
|
+
format: payload[:format],
|
|
225
|
+
status: payload[:status],
|
|
226
|
+
|
|
227
|
+
duration_ms: event.duration,
|
|
228
|
+
db_time_ms: db_time,
|
|
229
|
+
view_time_ms: view_time,
|
|
230
|
+
|
|
231
|
+
query_count: queries.size,
|
|
232
|
+
slow_queries: slow_queries.first(10), # Limit stored slow queries
|
|
233
|
+
has_n_plus_one: n_plus_one.any?,
|
|
234
|
+
n_plus_one_queries: n_plus_one.first(5),
|
|
235
|
+
|
|
236
|
+
view_count: views.size,
|
|
237
|
+
|
|
238
|
+
context: Capture::Context.to_h,
|
|
239
|
+
captured_at: Time.now.utc.iso8601(3),
|
|
240
|
+
environment: Findbug.config.environment,
|
|
241
|
+
release: Findbug.config.release
|
|
242
|
+
}
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Detect N+1 query patterns
|
|
246
|
+
#
|
|
247
|
+
# WHAT IS N+1?
|
|
248
|
+
# ============
|
|
249
|
+
#
|
|
250
|
+
# The N+1 problem occurs when you:
|
|
251
|
+
# 1. Load a collection (1 query)
|
|
252
|
+
# 2. For each item, run another query (N queries)
|
|
253
|
+
#
|
|
254
|
+
# Example:
|
|
255
|
+
# posts = Post.all # 1 query
|
|
256
|
+
# posts.each do |post|
|
|
257
|
+
# puts post.author.name # N queries!
|
|
258
|
+
# end
|
|
259
|
+
#
|
|
260
|
+
# We detect this by finding similar queries executed multiple times.
|
|
261
|
+
#
|
|
262
|
+
def detect_n_plus_one(queries)
|
|
263
|
+
return [] if queries.size < 3
|
|
264
|
+
|
|
265
|
+
# Normalize queries (remove specific IDs)
|
|
266
|
+
normalized = queries.map do |q|
|
|
267
|
+
{
|
|
268
|
+
pattern: normalize_sql_pattern(q[:sql]),
|
|
269
|
+
original: q[:sql],
|
|
270
|
+
duration_ms: q[:duration_ms]
|
|
271
|
+
}
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Group by pattern and find duplicates
|
|
275
|
+
grouped = normalized.group_by { |q| q[:pattern] }
|
|
276
|
+
|
|
277
|
+
grouped.select { |_, group| group.size >= 3 }.map do |pattern, group|
|
|
278
|
+
{
|
|
279
|
+
pattern: pattern,
|
|
280
|
+
count: group.size,
|
|
281
|
+
total_duration_ms: group.sum { |q| q[:duration_ms] },
|
|
282
|
+
example: group.first[:original]
|
|
283
|
+
}
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Normalize SQL for pattern matching
|
|
288
|
+
def normalize_sql_pattern(sql)
|
|
289
|
+
return "" unless sql
|
|
290
|
+
|
|
291
|
+
sql.gsub(/\d+/, "?")
|
|
292
|
+
.gsub(/'[^']*'/, "?")
|
|
293
|
+
.gsub(/"[^"]*"/, "?")
|
|
294
|
+
.gsub(/\s+/, " ")
|
|
295
|
+
.strip
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Truncate SQL to reasonable length
|
|
299
|
+
def truncate_sql(sql)
|
|
300
|
+
return nil unless sql
|
|
301
|
+
|
|
302
|
+
sql.length > 1000 ? "#{sql[0..997]}..." : sql
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Truncate cache keys
|
|
306
|
+
def truncate_cache_key(key)
|
|
307
|
+
return nil unless key
|
|
308
|
+
|
|
309
|
+
key_s = key.to_s
|
|
310
|
+
key_s.length > 200 ? "#{key_s[0..197]}..." : key_s
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Check if we should sample this request
|
|
314
|
+
def should_sample?
|
|
315
|
+
Findbug.config.should_capture_performance?
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Check if request meets threshold for capture
|
|
319
|
+
def meets_threshold?(event)
|
|
320
|
+
return true if Findbug.config.slow_request_threshold_ms.zero?
|
|
321
|
+
|
|
322
|
+
event[:duration_ms] >= Findbug.config.slow_request_threshold_ms
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# Thread-local storage for request data
|
|
326
|
+
def current_request_data
|
|
327
|
+
Thread.current[:findbug_performance_data] ||= {}
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def clear_request_data
|
|
331
|
+
Thread.current[:findbug_performance_data] = nil
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
end
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Findbug
|
|
4
|
+
module Performance
|
|
5
|
+
# Transaction provides manual performance tracking for custom operations.
|
|
6
|
+
#
|
|
7
|
+
# WHY MANUAL TRANSACTIONS?
|
|
8
|
+
# ========================
|
|
9
|
+
#
|
|
10
|
+
# Automatic instrumentation catches HTTP requests, but what about:
|
|
11
|
+
# - External API calls
|
|
12
|
+
# - Background job processing
|
|
13
|
+
# - Custom business logic
|
|
14
|
+
# - Third-party service calls
|
|
15
|
+
#
|
|
16
|
+
# With transactions, you can track anything:
|
|
17
|
+
#
|
|
18
|
+
# Findbug.track_performance("stripe_charge") do
|
|
19
|
+
# Stripe::Charge.create(...)
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# Findbug.track_performance("pdf_generation") do
|
|
23
|
+
# generate_report_pdf(...)
|
|
24
|
+
# end
|
|
25
|
+
#
|
|
26
|
+
# NESTING
|
|
27
|
+
# =======
|
|
28
|
+
#
|
|
29
|
+
# Transactions can be nested. Child transactions contribute to parent timing:
|
|
30
|
+
#
|
|
31
|
+
# Findbug.track_performance("checkout") do
|
|
32
|
+
# Findbug.track_performance("payment") do
|
|
33
|
+
# process_payment
|
|
34
|
+
# end
|
|
35
|
+
# Findbug.track_performance("fulfillment") do
|
|
36
|
+
# create_shipment
|
|
37
|
+
# end
|
|
38
|
+
# end
|
|
39
|
+
#
|
|
40
|
+
# This creates a tree of timings you can analyze.
|
|
41
|
+
#
|
|
42
|
+
class Transaction
|
|
43
|
+
class << self
|
|
44
|
+
# Track a block's performance
|
|
45
|
+
#
|
|
46
|
+
# @param name [String] name for this transaction
|
|
47
|
+
# @param tags [Hash] optional tags for filtering
|
|
48
|
+
# @yield the block to track
|
|
49
|
+
# @return [Object] the block's return value
|
|
50
|
+
#
|
|
51
|
+
def track(name, tags: {}, &block)
|
|
52
|
+
return yield unless Findbug.enabled?
|
|
53
|
+
return yield unless Findbug.config.performance_enabled
|
|
54
|
+
|
|
55
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
56
|
+
|
|
57
|
+
begin
|
|
58
|
+
# Execute the block
|
|
59
|
+
result = yield
|
|
60
|
+
|
|
61
|
+
# Calculate duration
|
|
62
|
+
duration_ms = calculate_duration(start_time)
|
|
63
|
+
|
|
64
|
+
# Record the transaction
|
|
65
|
+
record_transaction(name, duration_ms, tags, success: true)
|
|
66
|
+
|
|
67
|
+
result
|
|
68
|
+
rescue StandardError => e
|
|
69
|
+
# Calculate duration even on error
|
|
70
|
+
duration_ms = calculate_duration(start_time)
|
|
71
|
+
|
|
72
|
+
# Record as failed
|
|
73
|
+
record_transaction(name, duration_ms, tags, success: false, error: e.class.name)
|
|
74
|
+
|
|
75
|
+
raise
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Start a transaction manually (for cases where block syntax doesn't work)
|
|
80
|
+
#
|
|
81
|
+
# @param name [String] transaction name
|
|
82
|
+
# @return [TransactionSpan] a span object to finish later
|
|
83
|
+
#
|
|
84
|
+
# @example
|
|
85
|
+
# span = Findbug::Performance::Transaction.start("long_operation")
|
|
86
|
+
# # ... do work ...
|
|
87
|
+
# span.finish
|
|
88
|
+
#
|
|
89
|
+
def start(name, tags: {})
|
|
90
|
+
TransactionSpan.new(name, tags)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def calculate_duration(start_time)
|
|
96
|
+
end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
97
|
+
((end_time - start_time) * 1000).round(2)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def record_transaction(name, duration_ms, tags, success:, error: nil)
|
|
101
|
+
# Only sample some transactions
|
|
102
|
+
return unless Findbug.config.should_capture_performance?
|
|
103
|
+
|
|
104
|
+
event = {
|
|
105
|
+
transaction_name: name,
|
|
106
|
+
transaction_type: "custom",
|
|
107
|
+
duration_ms: duration_ms,
|
|
108
|
+
success: success,
|
|
109
|
+
error_class: error,
|
|
110
|
+
tags: tags,
|
|
111
|
+
context: Capture::Context.to_h,
|
|
112
|
+
captured_at: Time.now.utc.iso8601(3),
|
|
113
|
+
environment: Findbug.config.environment,
|
|
114
|
+
release: Findbug.config.release
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
Storage::RedisBuffer.push_performance(event)
|
|
118
|
+
rescue StandardError => e
|
|
119
|
+
Findbug.logger.debug("[Findbug] Transaction recording failed: #{e.message}")
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# TransactionSpan represents an in-progress transaction.
|
|
125
|
+
#
|
|
126
|
+
# Use this when block syntax isn't convenient:
|
|
127
|
+
#
|
|
128
|
+
# span = Findbug::Performance::Transaction.start("my_operation")
|
|
129
|
+
# begin
|
|
130
|
+
# do_work
|
|
131
|
+
# span.finish
|
|
132
|
+
# rescue => e
|
|
133
|
+
# span.finish(error: e)
|
|
134
|
+
# raise
|
|
135
|
+
# end
|
|
136
|
+
#
|
|
137
|
+
class TransactionSpan
|
|
138
|
+
attr_reader :name, :tags, :start_time
|
|
139
|
+
|
|
140
|
+
def initialize(name, tags = {})
|
|
141
|
+
@name = name
|
|
142
|
+
@tags = tags
|
|
143
|
+
@start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
144
|
+
@finished = false
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Finish the transaction
|
|
148
|
+
#
|
|
149
|
+
# @param error [Exception, nil] optional error if the transaction failed
|
|
150
|
+
#
|
|
151
|
+
def finish(error: nil)
|
|
152
|
+
return if @finished
|
|
153
|
+
|
|
154
|
+
@finished = true
|
|
155
|
+
duration_ms = calculate_duration
|
|
156
|
+
|
|
157
|
+
event = {
|
|
158
|
+
transaction_name: name,
|
|
159
|
+
transaction_type: "custom",
|
|
160
|
+
duration_ms: duration_ms,
|
|
161
|
+
success: error.nil?,
|
|
162
|
+
error_class: error&.class&.name,
|
|
163
|
+
tags: tags,
|
|
164
|
+
context: Capture::Context.to_h,
|
|
165
|
+
captured_at: Time.now.utc.iso8601(3),
|
|
166
|
+
environment: Findbug.config.environment,
|
|
167
|
+
release: Findbug.config.release
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
Storage::RedisBuffer.push_performance(event)
|
|
171
|
+
rescue StandardError => e
|
|
172
|
+
Findbug.logger.debug("[Findbug] Span finish failed: #{e.message}")
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Check if already finished
|
|
176
|
+
def finished?
|
|
177
|
+
@finished
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Get current duration (for monitoring in-progress transactions)
|
|
181
|
+
def current_duration_ms
|
|
182
|
+
calculate_duration
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
private
|
|
186
|
+
|
|
187
|
+
def calculate_duration
|
|
188
|
+
end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
189
|
+
((end_time - start_time) * 1000).round(2)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|