forge_ops_tracker 0.14.0 → 0.15.1
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 +16 -0
- data/README.md +6 -3
- data/lib/forge_ops_tracker/delivery_queue.rb +52 -1
- data/lib/forge_ops_tracker/performance_instrumentation.rb +3 -2
- data/lib/forge_ops_tracker/query_explainer.rb +3 -2
- data/lib/forge_ops_tracker/sql_statement.rb +18 -7
- data/lib/forge_ops_tracker/version.rb +1 -1
- data/lib/forge_ops_tracker.rb +3 -2
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 81c08199bfeae97d20295b7c04a46dc3b9dd9be519d4790404b88e2ca514fbe1
|
|
4
|
+
data.tar.gz: dad1efa10164cebcbce9452c1f903812157143e871162076f09298ebf41a6ea7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2a32b34300ee005cd09be7f188275091ec9ba8cf4ef2e65044176a7e6d1427feef170cbc44dbd9f7a25dcf0073b2afb6186db3db2069a5737bfdcb274e62cc7f
|
|
7
|
+
data.tar.gz: 4431641c5e95f757dd82e7af0015de824804610ee4ebf252cbe3a4db13ca5f5aa1cac5fda274a94833be652a9d6c5e25ab4406570d4790b165cf242f8490ff66
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.15.1 (2026-10-01)
|
|
4
|
+
|
|
5
|
+
- Errors reported just before a process exits are now sent before it ends. Delivery runs on a
|
|
6
|
+
background thread, and a short-lived process (a rake task, a cron script, `rails runner`,
|
|
7
|
+
including the test command on a project's setup page) could exit before that thread sent them,
|
|
8
|
+
so they never arrived. On a normal exit the queue now sends what's left, waiting at most 5
|
|
9
|
+
seconds, and only in a process that reported something.
|
|
10
|
+
|
|
11
|
+
## 0.15.0 (2026-09-29)
|
|
12
|
+
|
|
13
|
+
- SQL masking now also catches strings with a backslash-escaped quote (`'o\'brien'`,
|
|
14
|
+
`E'o\'brien'`), prefixed strings (`E''`, `X''`, `N''`, `B''`, `U&''`, prefix included), hex,
|
|
15
|
+
binary and exponent numbers (`0x1F`, `0b101`, `3e10`, `1.5E-3`, `.5`), and, on MySQL and
|
|
16
|
+
MariaDB, `"double-quoted"` strings. `SqlStatement.mask` takes an optional `system:`, and
|
|
17
|
+
database spans (including `ForgeOpsTracker.span`'s `db_system:`) pass their `db.system` to it.
|
|
18
|
+
|
|
3
19
|
## 0.14.0 (2026-09-25)
|
|
4
20
|
|
|
5
21
|
- Every Active Record database span now carries the query's SQL (`db.statement`), masked so every
|
data/README.md
CHANGED
|
@@ -416,9 +416,12 @@ on error events is sent either way.
|
|
|
416
416
|
Every Active Record query span carries the query's SQL and the database it ran on, so a slow trace
|
|
417
417
|
shows exactly which query was slow. The SQL is masked before it leaves your process: every string
|
|
418
418
|
and number is replaced by `?`, so `WHERE email = 'a@b.co' AND id = 42` is sent as
|
|
419
|
-
`WHERE email = ? AND id = ?`, and bind values are never sent at all.
|
|
420
|
-
|
|
421
|
-
`
|
|
419
|
+
`WHERE email = ? AND id = ?`, and bind values are never sent at all. That includes escaped and
|
|
420
|
+
prefixed strings (`E'o\'brien'`, `X'DEADBEEF'`), hex, binary and exponent numbers (`0x1F`,
|
|
421
|
+
`0b101`, `1.5E-3`), and, on MySQL and MariaDB (where `"double quotes"` hold a string, not a name),
|
|
422
|
+
double-quoted strings. ForgeOps masks it again on arrival. The database comes from the
|
|
423
|
+
connection's adapter (`postgresql`, `mysql`, `sqlite`, `mssql`, `oracle`, or the adapter's own
|
|
424
|
+
name otherwise).
|
|
422
425
|
|
|
423
426
|
Spans you create yourself can carry SQL too. Pass `statement:` (and optionally `db_system:`) on a
|
|
424
427
|
`kind: "database"` span; it's masked the same way:
|
|
@@ -8,6 +8,11 @@ module ForgeOpsTracker
|
|
|
8
8
|
# in each forked Puma/Passenger worker process rather than being carried
|
|
9
9
|
# across a fork, which would leave it dead in the child.
|
|
10
10
|
class DeliveryQueue
|
|
11
|
+
# The most a normal process exit waits for queued events to go out (see #drain): long enough for
|
|
12
|
+
# one delivery at the default open_timeout + read_timeout, short enough that an unreachable
|
|
13
|
+
# ForgeOps never holds up a deploy or a cron run for long.
|
|
14
|
+
DRAIN_TIMEOUT = 5
|
|
15
|
+
|
|
11
16
|
# deliver_with names the Client method each payload goes through: :deliver (error events) by
|
|
12
17
|
# default, :deliver_change for ForgeOpsTracker.record_change's own queue.
|
|
13
18
|
def initialize(configuration, client: Client.new(configuration), deliver_with: :deliver)
|
|
@@ -17,6 +22,28 @@ module ForgeOpsTracker
|
|
|
17
22
|
@queue = SizedQueue.new(configuration.queue_size)
|
|
18
23
|
@mutex = Mutex.new
|
|
19
24
|
@thread = nil
|
|
25
|
+
@in_flight = 0
|
|
26
|
+
@drain_registered = false
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Sends whatever is still queued, and lets a delivery already under way finish, before the
|
|
30
|
+
# process exits, within DRAIN_TIMEOUT. Without it a short-lived process (a rake task, a cron
|
|
31
|
+
# script, `rails runner`) lost every error it reported in its last moments: the worker thread
|
|
32
|
+
# dies with the process before it gets to them. Delivers here on the exiting thread rather than
|
|
33
|
+
# waiting on the worker, which in a forked child is a parent's thread that no longer runs.
|
|
34
|
+
def drain(timeout: DRAIN_TIMEOUT)
|
|
35
|
+
deadline = monotonic_now + timeout
|
|
36
|
+
while monotonic_now < deadline
|
|
37
|
+
payload = begin
|
|
38
|
+
@queue.pop(true)
|
|
39
|
+
rescue ThreadError
|
|
40
|
+
break
|
|
41
|
+
end
|
|
42
|
+
client.public_send(@deliver_with, payload)
|
|
43
|
+
end
|
|
44
|
+
sleep 0.01 while @in_flight.positive? && monotonic_now < deadline
|
|
45
|
+
rescue StandardError => e
|
|
46
|
+
log { "drain failed: #{e.class}: #{e.message}" }
|
|
20
47
|
end
|
|
21
48
|
|
|
22
49
|
# Enqueues a payload, dropping it silently (never blocking the caller)
|
|
@@ -35,6 +62,7 @@ module ForgeOpsTracker
|
|
|
35
62
|
attr_reader :configuration, :client
|
|
36
63
|
|
|
37
64
|
def ensure_worker_started
|
|
65
|
+
register_drain
|
|
38
66
|
return if @thread&.alive?
|
|
39
67
|
|
|
40
68
|
@mutex.synchronize do
|
|
@@ -47,12 +75,35 @@ module ForgeOpsTracker
|
|
|
47
75
|
|
|
48
76
|
def run
|
|
49
77
|
loop do
|
|
50
|
-
|
|
78
|
+
payload = @queue.pop
|
|
79
|
+
@in_flight += 1
|
|
80
|
+
begin
|
|
81
|
+
client.public_send(@deliver_with, payload)
|
|
82
|
+
ensure
|
|
83
|
+
@in_flight -= 1
|
|
84
|
+
end
|
|
51
85
|
rescue StandardError => e
|
|
52
86
|
log { "delivery thread error: #{e.class}: #{e.message}" }
|
|
53
87
|
end
|
|
54
88
|
end
|
|
55
89
|
|
|
90
|
+
# Registered on the first push, not at load, so a process that never reports anything never
|
|
91
|
+
# waits on exit at all.
|
|
92
|
+
def register_drain
|
|
93
|
+
return if @drain_registered
|
|
94
|
+
|
|
95
|
+
@mutex.synchronize do
|
|
96
|
+
return if @drain_registered
|
|
97
|
+
|
|
98
|
+
@drain_registered = true
|
|
99
|
+
at_exit { drain }
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def monotonic_now
|
|
104
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
105
|
+
end
|
|
106
|
+
|
|
56
107
|
def log
|
|
57
108
|
configuration.logger&.debug { "[ForgeOpsTracker] #{yield}" }
|
|
58
109
|
end
|
|
@@ -84,9 +84,10 @@ module ForgeOpsTracker
|
|
|
84
84
|
# and number literal becomes "?", so no value the app queried with is ever sent), and which
|
|
85
85
|
# database it ran on, from the connection's adapter. Either is left out when it isn't known.
|
|
86
86
|
def database_span_data(payload)
|
|
87
|
+
system = db_system(payload[:connection])
|
|
87
88
|
{
|
|
88
|
-
"db.statement" => SqlStatement.mask(payload[:sql].to_s),
|
|
89
|
-
"db.system" =>
|
|
89
|
+
"db.statement" => SqlStatement.mask(payload[:sql].to_s, system: system),
|
|
90
|
+
"db.system" => system
|
|
90
91
|
}.compact
|
|
91
92
|
end
|
|
92
93
|
|
|
@@ -102,7 +102,8 @@ module ForgeOpsTracker
|
|
|
102
102
|
return false unless duration_ms && duration_ms >= configuration.explain_threshold_ms.to_f
|
|
103
103
|
|
|
104
104
|
connection = payload[:connection]
|
|
105
|
-
|
|
105
|
+
system = PerformanceInstrumentation.db_system(connection)
|
|
106
|
+
return false unless system == "postgresql"
|
|
106
107
|
|
|
107
108
|
sql = payload[:sql]
|
|
108
109
|
return false unless self.class.explainable?(sql)
|
|
@@ -110,7 +111,7 @@ module ForgeOpsTracker
|
|
|
110
111
|
pool = connection.respond_to?(:pool) ? connection.pool : nil
|
|
111
112
|
return false unless pool
|
|
112
113
|
|
|
113
|
-
statement = SqlStatement.mask(sql)
|
|
114
|
+
statement = SqlStatement.mask(sql, system: system)
|
|
114
115
|
return false unless reserve(statement)
|
|
115
116
|
|
|
116
117
|
enqueue(Job.new(
|
|
@@ -15,11 +15,19 @@ module ForgeOpsTracker
|
|
|
15
15
|
MAX_NAME_LENGTH = 200
|
|
16
16
|
MAX_CAUSE_DEPTH = 5
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
# db.system values where "double quotes" are a string, not a name.
|
|
19
|
+
DOUBLE_QUOTED_STRING_SYSTEMS = %w[mysql mariadb].freeze
|
|
20
|
+
|
|
21
|
+
# The prefix only counts as one when it isn't the end of a word, but the quote always does:
|
|
22
|
+
# LIKE'%x%', with no space, is still a string. A backslash can be the statement's last character
|
|
23
|
+
# ('secret\ cut off mid-escape), and the string is still masked to the end.
|
|
24
|
+
STRING = /(?:(?<![\w$])(?:[EeXxNnBb]|[Uu]&))?'(?:[^'\\]|\\(?:.|\z)|'')*(?:'|\z)/m
|
|
25
|
+
DOUBLE_QUOTED_STRING = /"(?:[^"\\]|\\(?:.|\z)|"")*(?:"|\z)/m
|
|
26
|
+
DOLLAR_QUOTED = /(?<tag>\$[A-Za-z_]*\$).*?(?:\k<tag>|\z)/m
|
|
27
|
+
NUMBER = /(?<![\w$.])(?:0[xX]\h+|0[bB][01]+|(?:\d+(?:\.\d+)?|\.\d+)(?:[eE][+-]?\d+)?)(?!\w)/
|
|
28
|
+
|
|
29
|
+
LITERAL = Regexp.union(STRING, DOLLAR_QUOTED, NUMBER)
|
|
30
|
+
LITERAL_WITH_DOUBLE_QUOTES = Regexp.union(STRING, DOUBLE_QUOTED_STRING, DOLLAR_QUOTED, NUMBER)
|
|
23
31
|
|
|
24
32
|
PART = /(?:[\w$#@]+|"[^"]+"|\[[^\]]+\]|`[^`]+`)/
|
|
25
33
|
NAME = /#{PART}(?:\.#{PART})*/
|
|
@@ -51,10 +59,13 @@ module ForgeOpsTracker
|
|
|
51
59
|
nil
|
|
52
60
|
end
|
|
53
61
|
|
|
54
|
-
|
|
62
|
+
# system is the query's db.system, when known: on MySQL and MariaDB "double quotes" are a
|
|
63
|
+
# string too, so they're masked there and left alone everywhere else.
|
|
64
|
+
def mask(statement, system: nil)
|
|
55
65
|
return nil if statement.nil? || statement.strip.empty?
|
|
56
66
|
|
|
57
|
-
|
|
67
|
+
pattern = DOUBLE_QUOTED_STRING_SYSTEMS.include?(system.to_s.downcase) ? LITERAL_WITH_DOUBLE_QUOTES : LITERAL
|
|
68
|
+
masked = statement.gsub(pattern, MASK)
|
|
58
69
|
masked.length > MAX_LENGTH ? "#{masked[0, MAX_LENGTH]}..." : masked
|
|
59
70
|
end
|
|
60
71
|
|
data/lib/forge_ops_tracker.rb
CHANGED
|
@@ -155,9 +155,10 @@ module ForgeOpsTracker
|
|
|
155
155
|
|
|
156
156
|
private
|
|
157
157
|
def database_span_data(data, statement, db_system)
|
|
158
|
+
system = (db_system.to_s.strip.downcase unless db_system.nil? || db_system.to_s.strip.empty?)
|
|
158
159
|
extra = {
|
|
159
|
-
"db.statement" => (SqlStatement.mask(statement.to_s) unless statement.nil?),
|
|
160
|
-
"db.system" =>
|
|
160
|
+
"db.statement" => (SqlStatement.mask(statement.to_s, system: system) unless statement.nil?),
|
|
161
|
+
"db.system" => system
|
|
161
162
|
}.compact
|
|
162
163
|
extra.empty? ? data : data.merge(extra)
|
|
163
164
|
end
|
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.15.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- ForgeOps
|
|
@@ -29,14 +29,14 @@ dependencies:
|
|
|
29
29
|
requirements:
|
|
30
30
|
- - "~>"
|
|
31
31
|
- !ruby/object:Gem::Version
|
|
32
|
-
version: '
|
|
32
|
+
version: '8.1'
|
|
33
33
|
type: :development
|
|
34
34
|
prerelease: false
|
|
35
35
|
version_requirements: !ruby/object:Gem::Requirement
|
|
36
36
|
requirements:
|
|
37
37
|
- - "~>"
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
|
-
version: '
|
|
39
|
+
version: '8.1'
|
|
40
40
|
- !ruby/object:Gem::Dependency
|
|
41
41
|
name: puma
|
|
42
42
|
requirement: !ruby/object:Gem::Requirement
|