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 +4 -4
- data/CHANGELOG.md +14 -0
- data/README.md +44 -2
- 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 +5 -4
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,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
|
|
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>@
|
|
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
|
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
|
|
@@ -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
|
|
83
|
-
|
|
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
|
|
144
|
+
summary: Rails exception reporting client for ForgeOps
|
|
144
145
|
test_files: []
|