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 +4 -4
- data/CHANGELOG.md +10 -0
- data/README.md +23 -0
- data/lib/forge_ops_tracker/configuration.rb +10 -0
- data/lib/forge_ops_tracker/event_builder.rb +16 -0
- data/lib/forge_ops_tracker/sql_statement.rb +95 -0
- data/lib/forge_ops_tracker/version.rb +1 -1
- data/lib/forge_ops_tracker.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: af67d3b6d07322ccfd80d56256e5e600c264f60394058acab51b7bfc660435fa
|
|
4
|
+
data.tar.gz: 2716944aed397bb03c754900b2e34f3abc49247b43b6a97b411cec6b6a3ccbc2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/forge_ops_tracker.rb
CHANGED
|
@@ -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.
|
|
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:
|