forge_ops_tracker 0.10.2 → 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: 9b32a031e24fb613c219aee3bf01f0517043b05b4d1788d7ece456096d7561a0
4
- data.tar.gz: 39c7f16920425d01862607b6d3f85b957c694ab009ce8ffd010f33714632a3a9
3
+ metadata.gz: af67d3b6d07322ccfd80d56256e5e600c264f60394058acab51b7bfc660435fa
4
+ data.tar.gz: 2716944aed397bb03c754900b2e34f3abc49247b43b6a97b411cec6b6a3ccbc2
5
5
  SHA512:
6
- metadata.gz: 315fb4765179a298da43dd2beabe68970f257dee7b4b1f3938bcc6efa72ac403b8dbb0e39b8d16dfcaf19b76696aed961270dc88fd92731b05ae1610b3d4a223
7
- data.tar.gz: f2907bae8015b4093346a30369cc3da38c8d36f4198ae4935cb1e2d2cea2e6baed44d5fcab4a8278029f228353d991275316fd54a9e08bd3116519b529f09159
6
+ metadata.gz: 2952a5218507cfceb871821b1f79add47d255b45577ee4bc67a782b574e3d41135d1f971d966846666c3bbcc1de6e86e3c3c7671e8a5ec04b31d9dabf4af0cc4
7
+ data.tar.gz: 32efc755d0d05fca0dd4f4354d00808caf135da2c0a22ad2b9d417017a3e73406b6d1b879da3e5862724340d52222fec9d1fbbfebc45efee58101e824b105373
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
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
+
3
13
  ## 0.10.2
4
14
 
5
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.
data/README.md CHANGED
@@ -187,6 +187,29 @@ ForgeOpsTracker.configure do |config|
187
187
  end
188
188
  ```
189
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
+
190
213
  ## Session tracking (release health)
191
214
 
192
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.2"
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.2
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ForgeOps
@@ -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: