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.
Files changed (54) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +8 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +375 -0
  6. data/Rakefile +12 -0
  7. data/app/controllers/findbug/application_controller.rb +105 -0
  8. data/app/controllers/findbug/dashboard_controller.rb +93 -0
  9. data/app/controllers/findbug/errors_controller.rb +129 -0
  10. data/app/controllers/findbug/performance_controller.rb +80 -0
  11. data/app/jobs/findbug/alert_job.rb +40 -0
  12. data/app/jobs/findbug/cleanup_job.rb +132 -0
  13. data/app/jobs/findbug/persist_job.rb +158 -0
  14. data/app/models/findbug/error_event.rb +197 -0
  15. data/app/models/findbug/performance_event.rb +237 -0
  16. data/app/views/findbug/dashboard/index.html.erb +199 -0
  17. data/app/views/findbug/errors/index.html.erb +137 -0
  18. data/app/views/findbug/errors/show.html.erb +185 -0
  19. data/app/views/findbug/performance/index.html.erb +168 -0
  20. data/app/views/findbug/performance/show.html.erb +203 -0
  21. data/app/views/layouts/findbug/application.html.erb +601 -0
  22. data/lib/findbug/alerts/channels/base.rb +75 -0
  23. data/lib/findbug/alerts/channels/discord.rb +155 -0
  24. data/lib/findbug/alerts/channels/email.rb +179 -0
  25. data/lib/findbug/alerts/channels/slack.rb +149 -0
  26. data/lib/findbug/alerts/channels/webhook.rb +143 -0
  27. data/lib/findbug/alerts/dispatcher.rb +126 -0
  28. data/lib/findbug/alerts/throttler.rb +110 -0
  29. data/lib/findbug/background_persister.rb +142 -0
  30. data/lib/findbug/capture/context.rb +301 -0
  31. data/lib/findbug/capture/exception_handler.rb +141 -0
  32. data/lib/findbug/capture/exception_subscriber.rb +228 -0
  33. data/lib/findbug/capture/message_handler.rb +104 -0
  34. data/lib/findbug/capture/middleware.rb +247 -0
  35. data/lib/findbug/configuration.rb +381 -0
  36. data/lib/findbug/engine.rb +109 -0
  37. data/lib/findbug/performance/instrumentation.rb +336 -0
  38. data/lib/findbug/performance/transaction.rb +193 -0
  39. data/lib/findbug/processing/data_scrubber.rb +163 -0
  40. data/lib/findbug/rails/controller_methods.rb +152 -0
  41. data/lib/findbug/railtie.rb +222 -0
  42. data/lib/findbug/storage/circuit_breaker.rb +223 -0
  43. data/lib/findbug/storage/connection_pool.rb +134 -0
  44. data/lib/findbug/storage/redis_buffer.rb +285 -0
  45. data/lib/findbug/tasks/findbug.rake +167 -0
  46. data/lib/findbug/version.rb +5 -0
  47. data/lib/findbug.rb +216 -0
  48. data/lib/generators/findbug/install_generator.rb +67 -0
  49. data/lib/generators/findbug/templates/POST_INSTALL +41 -0
  50. data/lib/generators/findbug/templates/create_findbug_error_events.rb +44 -0
  51. data/lib/generators/findbug/templates/create_findbug_performance_events.rb +47 -0
  52. data/lib/generators/findbug/templates/initializer.rb +157 -0
  53. data/sig/findbug.rbs +4 -0
  54. 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