forge_ops_tracker 0.10.1 → 0.11.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8366b59c60be4a6766a7d7775e9eaeed1e184645e620351e81aa97bc88c0cacd
4
- data.tar.gz: a971669ad792a6893bdcddb577050883cb3d8f175778d817f02174c9428b5acc
3
+ metadata.gz: af67d3b6d07322ccfd80d56256e5e600c264f60394058acab51b7bfc660435fa
4
+ data.tar.gz: 2716944aed397bb03c754900b2e34f3abc49247b43b6a97b411cec6b6a3ccbc2
5
5
  SHA512:
6
- metadata.gz: 03c78e9de07b46238931804ce2743c5ce659efbfdb5ae86151ab64a355c31a9e2fc142f07e22ad15d2e12bff02cb53fddaebd419f3c889ca9bd325adc3205b54
7
- data.tar.gz: b882fd3008d11185934f8905ad23261bedf5c5290902da1da0a789042f829f117b9e7d2fa66361a79a8d802a1d8561e700b1af81caec8bf4fb41cbbc974eb180
6
+ metadata.gz: 2952a5218507cfceb871821b1f79add47d255b45577ee4bc67a782b574e3d41135d1f971d966846666c3bbcc1de6e86e3c3c7671e8a5ec04b31d9dabf4af0cc4
7
+ data.tar.gz: 32efc755d0d05fca0dd4f4354d00808caf135da2c0a22ad2b9d417017a3e73406b6d1b879da3e5862724340d52222fec9d1fbbfebc45efee58101e824b105373
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.11.0
4
+
5
+ - Database errors now say where to look. When an error comes from a database call
6
+ (`ActiveRecord::StatementInvalid`, or an exception of yours raised from one), the event carries the
7
+ names of the stored procedure or function and the tables or views its SQL touched, so ForgeOps can
8
+ show them on the issue. On by default (`config.capture_sql_objects`); names are identifiers, never
9
+ values. New opt-in `config.capture_sql_statement` (default false) also sends the statement itself,
10
+ with every string and number replaced by `?`. Each project has its own server-side setting that
11
+ can stop the statement being stored regardless of this flag; the names are still kept.
12
+
13
+ ## 0.10.2
14
+
15
+ - Documentation only: the README and package description now describe ForgeOps as a hosted service, link to getforgeops.net, and show the real host in the connection string example instead of a placeholder. No code changes.
16
+
3
17
  ## 0.10.1
4
18
 
5
19
  - Sidekiq job failures now report the real error. Sidekiq raises its own control-flow exception
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # ForgeOpsTracker
2
2
 
3
- Rails exception reporting client for a [ForgeOps](../../) instance.
3
+ Rails exception reporting client for [ForgeOps](https://getforgeops.net).
4
4
 
5
5
  ## Installation
6
6
 
@@ -15,7 +15,7 @@ Set a DSN (from a project's settings page in ForgeOps) via an initializer or env
15
15
  ```ruby
16
16
  # config/initializers/forge_ops_tracker.rb
17
17
  ForgeOpsTracker.configure do |config|
18
- config.dsn = ENV["FORGE_OPS_DSN"] # "https://<api_key>@forgeops.example.com/api/v1/events"
18
+ config.dsn = ENV["FORGE_OPS_DSN"] # "https://<api_key>@getforgeops.net/api/v1/events"
19
19
  config.release = ENV["HEROKU_SLUG_COMMIT"] || `git rev-parse HEAD`.strip
20
20
  config.enabled_environments = %w[production staging] # default; reporting is a no-op elsewhere
21
21
  end
@@ -67,6 +67,25 @@ Bottom line: if an exception would otherwise crash something, you're already cov
67
67
  code already catches and handles it, route that specific `rescue` through `Rails.error.handle`/
68
68
  `.record` instead of a bare one wherever you want ForgeOps to know about it.
69
69
 
70
+ ### Sidekiq job failures and renamed job classes
71
+
72
+ A failing Sidekiq job is reported through `Rails.error` like any other error. Since 0.10.1 the issue
73
+ carries the real error, not Sidekiq's internal `Sidekiq::JobRetry::Handled` retry wrapper (which is
74
+ what every failing job used to arrive as, whatever went wrong inside it).
75
+
76
+ Renaming or removing a job class breaks every job still queued under the old name, since Sidekiq
77
+ stores the class name as a string, and no test can catch it because those jobs live in Redis. ForgeOps
78
+ recognizes that failure (an Active Job `UnknownJobClassError`, or a plain Sidekiq job's `NameError`
79
+ from constantizing the class), names the class that no longer exists, and shows how soon after which
80
+ release it first appeared. Keep the old name as an alias until the queue, retry set and scheduled set
81
+ have drained:
82
+
83
+ ```ruby
84
+ # app/jobs/send_invoice_job.rb
85
+ # Kept only until jobs queued under the old name have drained, then delete this file.
86
+ SendInvoiceJob = DeliverInvoiceJob
87
+ ```
88
+
70
89
  ## Identifying users
71
90
 
72
91
  If you're using Devise, or any other Warden-based authentication, the currently signed-in user is
@@ -168,6 +187,29 @@ ForgeOpsTracker.configure do |config|
168
187
  end
169
188
  ```
170
189
 
190
+ ## Database errors
191
+
192
+ When an error comes from a database call (`ActiveRecord::StatementInvalid`, or your own exception
193
+ raised from one), the event carries the names of the stored procedure or function and the tables or
194
+ views its SQL touched, so the issue tells you where to start looking. This is on by default and
195
+ sends identifiers only, never values. A view and a table are written the same way in SQL, so both
196
+ show as tables/views; the database's own error message usually settles which it was.
197
+
198
+ To also send the SQL statement itself, opt in. Every string and number is replaced by `?` before it
199
+ leaves your process (`WHERE email = 'a@b.co' AND id = 42` is sent as `WHERE email = ? AND id = ?`),
200
+ and ForgeOps masks it again on arrival:
201
+
202
+ ```ruby
203
+ ForgeOpsTracker.configure do |config|
204
+ config.capture_sql_statement = true # default false
205
+ config.capture_sql_objects = false # default true; false stops even the names
206
+ end
207
+ ```
208
+
209
+ Each ForgeOps project also has its own "Capture the SQL behind database errors" setting. Turn it off
210
+ there and the statement is never stored for that project, whatever this flag says; the names are
211
+ still kept.
212
+
171
213
  ## Session tracking (release health)
172
214
 
173
215
  By default, every request is counted as a session: crash-free unless an unhandled exception
@@ -8,6 +8,7 @@ module ForgeOpsTracker
8
8
  attr_accessor :dsn, :environment, :release, :server_name, :app_root, :logger
9
9
  attr_accessor :enabled_environments, :queue_size, :open_timeout, :read_timeout, :scrub_pii
10
10
  attr_accessor :capture_source_context
11
+ attr_accessor :capture_sql_objects, :capture_sql_statement
11
12
  attr_accessor :track_sessions, :session_flush_interval
12
13
  attr_accessor :track_performance, :performance_flush_interval
13
14
  attr_accessor :track_current_user
@@ -45,6 +46,15 @@ module ForgeOpsTracker
45
46
  # off here too if this host app never wants that disk read attempted
46
47
  # in the first place.
47
48
  @capture_source_context = true
49
+ # When an error comes from a database call (ActiveRecord::StatementInvalid and anything that
50
+ # wraps one), send the names of the stored procedure, table and view its SQL touched, so an
51
+ # issue says where to start looking. Names are identifiers, never values, which is why this
52
+ # defaults on. capture_sql_statement is the separate, opt-in step of also sending the
53
+ # statement itself, with every string and number replaced by "?"; off by default because
54
+ # even a masked statement describes the customer's schema, and ForgeOps' own per-project
55
+ # setting is what durably governs whether the server stores it. See SqlStatement.
56
+ @capture_sql_objects = true
57
+ @capture_sql_statement = false
48
58
  # Auto-instruments every request the moment the gem loads, the same "on unless you turn it
49
59
  # off" default error tracking itself already has; nothing else in this gem is opt-in. See
50
60
  # ForgeOpsTracker::Middleware::SessionTracking for what this actually wraps.
@@ -42,6 +42,7 @@ module ForgeOpsTracker
42
42
  }
43
43
  payload[:user] = user if user && !user.empty?
44
44
  payload[:breadcrumbs] = breadcrumbs if breadcrumbs && !breadcrumbs.empty?
45
+ attach_sql(payload, error)
45
46
  scrub(payload)
46
47
  end
47
48
 
@@ -66,10 +67,25 @@ module ForgeOpsTracker
66
67
  context: PiiScrubber.scrub(payload[:context]),
67
68
  tags: PiiScrubber.scrub(payload[:tags])
68
69
  )
70
+ scrubbed[:sql_statement] = PiiScrubber.scrub(payload[:sql_statement]) if payload.key?(:sql_statement)
69
71
  scrubbed[:breadcrumbs] = PiiScrubber.scrub(payload[:breadcrumbs]) if payload.key?(:breadcrumbs)
70
72
  scrubbed
71
73
  end
72
74
 
75
+ # See SqlStatement for what's read off the error and how it's masked. The statement itself
76
+ # only goes out when capture_sql_statement is on; the extracted names go out on their own
77
+ # (capture_sql_objects) so an issue can still name the procedure or view involved.
78
+ def attach_sql(payload, error)
79
+ return unless configuration.capture_sql_objects || configuration.capture_sql_statement
80
+
81
+ masked = SqlStatement.mask(SqlStatement.find_in(error))
82
+ return unless masked
83
+
84
+ objects = SqlStatement.objects(masked)
85
+ payload[:sql_objects] = objects if objects && configuration.capture_sql_objects
86
+ payload[:sql_statement] = masked if configuration.capture_sql_statement
87
+ end
88
+
73
89
  def backtrace_frames(error)
74
90
  Array(error.backtrace).first(MAX_FRAMES).filter_map { |line| parse_backtrace_line(line) }
75
91
  end
@@ -0,0 +1,95 @@
1
+ module ForgeOpsTracker
2
+ # Finds the SQL behind a database error and reduces it to something safe to send: the names of
3
+ # the stored procedures, tables and views it touched, and (only if configuration.
4
+ # capture_sql_statement is on) the statement itself with every string and number replaced by
5
+ # "?". Ported from the server's own SqlStatementMasker/SqlObjectExtractor, same rules; the
6
+ # server applies them again on arrival, so a difference here can only ever mean less is
7
+ # masked client-side, never that something unmasked gets stored.
8
+ #
9
+ # Deliberately a single pass over a few patterns, not a SQL parser; see the server's own
10
+ # comments for the reasoning behind each choice, which apply unchanged here.
11
+ module SqlStatement
12
+ MASK = "?".freeze
13
+ MAX_LENGTH = 4_000
14
+ MAX_NAMES = 10
15
+ MAX_NAME_LENGTH = 200
16
+ MAX_CAUSE_DEPTH = 5
17
+
18
+ LITERAL = /
19
+ '(?:[^']|'')*(?:'|\z) # string literal, or one cut off by truncation
20
+ | (?<tag>\$[A-Za-z_]*\$).*?(?:\k<tag>|\z) # dollar-quoted string
21
+ | (?<![\w$.])\d+(?:\.\d+)?(?!\w) # number, not part of an identifier or placeholder
22
+ /mx
23
+
24
+ PART = /(?:[\w$#@]+|"[^"]+"|\[[^\]]+\]|`[^`]+`)/
25
+ NAME = /#{PART}(?:\.#{PART})*/
26
+ OPERATIONS = %w[SELECT INSERT UPDATE DELETE MERGE WITH CALL EXEC EXECUTE CREATE ALTER DROP TRUNCATE].freeze
27
+ PROCEDURE_CALL = /\b(?:CALL|EXEC(?:UTE)?|PERFORM)\s+(?!IMMEDIATE\b|FUNCTION\b|PROCEDURE\b)(#{NAME})/i
28
+ RELATION = /\b(FROM|JOIN|INTO|UPDATE|TABLE)\s+(#{NAME})(\s*\()?/i
29
+ SELECT_FUNCTION = /\A\s*SELECT\s+(#{NAME})\s*\(/i
30
+ BUILTINS = %w[
31
+ count sum min max avg now coalesce nullif lower upper length concat cast date_trunc
32
+ current_timestamp current_date row_number rank json_build_object json_agg array_agg
33
+ ].freeze
34
+ FROM_INSIDE_FUNCTION = /\b(?:EXTRACT|SUBSTRING|TRIM|OVERLAY)\s*\([^()]*\)/i
35
+ KEYWORDS_NOT_NAMES = %w[select set values where lateral only unnest generate_series].freeze
36
+
37
+ module_function
38
+
39
+ # The raw statement off the error itself or, for an app that wraps a database error in its
40
+ # own exception, off whatever it was raised from. ActiveRecord::StatementInvalid#sql is the
41
+ # one source this reads; nothing else in Ruby exposes the statement on the exception.
42
+ def find_in(error)
43
+ depth = 0
44
+ while error && depth < MAX_CAUSE_DEPTH
45
+ sql = error.sql if error.respond_to?(:sql)
46
+ return sql.to_s if sql.is_a?(String) && !sql.strip.empty?
47
+
48
+ error = error.cause
49
+ depth += 1
50
+ end
51
+ nil
52
+ end
53
+
54
+ def mask(statement)
55
+ return nil if statement.nil? || statement.strip.empty?
56
+
57
+ masked = statement.gsub(LITERAL, MASK)
58
+ masked.length > MAX_LENGTH ? "#{masked[0, MAX_LENGTH]}..." : masked
59
+ end
60
+
61
+ # Takes an already-masked statement (so a keyword inside a string value can't be mistaken for
62
+ # SQL). Returns nil when nothing recognizable was found.
63
+ def objects(masked)
64
+ return nil if masked.nil? || masked.strip.empty?
65
+
66
+ sql = masked.gsub(FROM_INSIDE_FUNCTION, " ")
67
+ procedures = sql.scan(PROCEDURE_CALL).flatten
68
+ relations = []
69
+
70
+ sql.scan(RELATION) do |keyword, name, paren|
71
+ next if KEYWORDS_NOT_NAMES.include?(name.downcase)
72
+
73
+ function_call = paren && %w[FROM JOIN].include?(keyword.upcase)
74
+ (function_call ? procedures : relations) << name
75
+ end
76
+
77
+ function = sql[SELECT_FUNCTION, 1]
78
+ procedures << function if function && !BUILTINS.include?(function.downcase) && !sql.match?(/\bFROM\b/i)
79
+
80
+ operation = sql[/\A\s*(\w+)/, 1].to_s.upcase
81
+ result = {
82
+ operation: (operation if OPERATIONS.include?(operation)),
83
+ procedures: clean(procedures),
84
+ relations: clean(relations)
85
+ }.compact
86
+ result[:procedures].empty? && result[:relations].empty? && !result.key?(:operation) ? nil : result
87
+ end
88
+
89
+ def clean(names)
90
+ names.map { |name| name.to_s.strip[0, MAX_NAME_LENGTH] }
91
+ .select { |name| name.match?(/\A#{NAME}\z/) }
92
+ .uniq.first(MAX_NAMES)
93
+ end
94
+ end
95
+ end
@@ -1,3 +1,3 @@
1
1
  module ForgeOpsTracker
2
- VERSION = "0.10.1"
2
+ VERSION = "0.11.0"
3
3
  end
@@ -2,6 +2,7 @@ require "securerandom"
2
2
  require "forge_ops_tracker/version"
3
3
  require "forge_ops_tracker/configuration"
4
4
  require "forge_ops_tracker/pii_scrubber"
5
+ require "forge_ops_tracker/sql_statement"
5
6
  require "forge_ops_tracker/event_builder"
6
7
  require "forge_ops_tracker/client"
7
8
  require "forge_ops_tracker/delivery_queue"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: forge_ops_tracker
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.1
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ForgeOps
@@ -79,8 +79,8 @@ dependencies:
79
79
  - - "~>"
80
80
  - !ruby/object:Gem::Version
81
81
  version: '0.30'
82
- description: Hooks Rails' error reporter and reports exceptions to a ForgeOps exception
83
- tracker instance over HTTP, without ever raising back into the host application.
82
+ description: Hooks Rails' error reporter and reports exceptions to ForgeOps over HTTP,
83
+ without ever raising back into the host application.
84
84
  executables: []
85
85
  extensions: []
86
86
  extra_rdoc_files: []
@@ -117,6 +117,7 @@ files:
117
117
  - lib/forge_ops_tracker/session_flusher.rb
118
118
  - lib/forge_ops_tracker/span_buffer.rb
119
119
  - lib/forge_ops_tracker/span_queue.rb
120
+ - lib/forge_ops_tracker/sql_statement.rb
120
121
  - lib/forge_ops_tracker/version.rb
121
122
  homepage: https://getforgeops.net
122
123
  licenses:
@@ -140,5 +141,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
140
141
  requirements: []
141
142
  rubygems_version: 4.0.11
142
143
  specification_version: 4
143
- summary: Rails exception reporting client for a ForgeOps tracker
144
+ summary: Rails exception reporting client for ForgeOps
144
145
  test_files: []