txray 0.1.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 +7 -0
- data/CHANGELOG.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +274 -0
- data/Rakefile +9 -0
- data/exe/txray +6 -0
- data/lib/txray/analyzer.rb +109 -0
- data/lib/txray/catalog.rb +89 -0
- data/lib/txray/classifier.rb +102 -0
- data/lib/txray/cli.rb +98 -0
- data/lib/txray/client_index.rb +119 -0
- data/lib/txray/config.rb +75 -0
- data/lib/txray/error.rb +5 -0
- data/lib/txray/method_index.rb +124 -0
- data/lib/txray/monitor.rb +68 -0
- data/lib/txray/node_helpers.rb +62 -0
- data/lib/txray/offense.rb +49 -0
- data/lib/txray/railtie.rb +26 -0
- data/lib/txray/reporters/github.rb +21 -0
- data/lib/txray/reporters/json.rb +22 -0
- data/lib/txray/reporters/live.rb +179 -0
- data/lib/txray/reporters/sarif.rb +52 -0
- data/lib/txray/reporters/text.rb +47 -0
- data/lib/txray/reporters.rb +18 -0
- data/lib/txray/rule.rb +113 -0
- data/lib/txray/runtime/sink.rb +40 -0
- data/lib/txray/runtime/transaction.rb +49 -0
- data/lib/txray/runtime.rb +197 -0
- data/lib/txray/scanner.rb +70 -0
- data/lib/txray/source_file.rb +43 -0
- data/lib/txray/tail.rb +43 -0
- data/lib/txray/tasks.rake +9 -0
- data/lib/txray/transaction_scope.rb +125 -0
- data/lib/txray/version.rb +5 -0
- data/lib/txray/watch_command.rb +43 -0
- data/lib/txray.rb +37 -0
- metadata +100 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "io/console"
|
|
4
|
+
|
|
5
|
+
module Txray
|
|
6
|
+
module Reporters
|
|
7
|
+
class Live
|
|
8
|
+
SPARKS = %w[▁ ▂ ▃ ▄ ▅ ▆ ▇ █].freeze
|
|
9
|
+
HIDE_CURSOR = "\e[?25l"
|
|
10
|
+
SHOW_CURSOR = "\e[?25h"
|
|
11
|
+
|
|
12
|
+
def initialize(path:, io: $stdout, threshold_ms: 250)
|
|
13
|
+
@io = io
|
|
14
|
+
@path = path
|
|
15
|
+
@threshold_ms = threshold_ms
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def open = @io.print(HIDE_CURSOR)
|
|
19
|
+
|
|
20
|
+
def close(monitor)
|
|
21
|
+
@width, @height = viewport
|
|
22
|
+
@io.print(SHOW_CURSOR)
|
|
23
|
+
@io.puts
|
|
24
|
+
summary(monitor)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def draw(monitor)
|
|
28
|
+
@width, @height = viewport
|
|
29
|
+
@io.print("\e[H\e[2J")
|
|
30
|
+
header(monitor)
|
|
31
|
+
stats(monitor)
|
|
32
|
+
feed(monitor)
|
|
33
|
+
hotspots(monitor)
|
|
34
|
+
@io.print(dim(" ctrl-c to stop"))
|
|
35
|
+
@io.flush
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def viewport
|
|
41
|
+
rows, columns = IO.console&.winsize
|
|
42
|
+
[ columns.to_i.positive? ? columns : 100, rows.to_i.positive? ? rows : 40 ]
|
|
43
|
+
rescue StandardError
|
|
44
|
+
[ 100, 40 ]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def feed_limit = (@height - 19).clamp(3, 9)
|
|
48
|
+
|
|
49
|
+
def clip(text, budget)
|
|
50
|
+
budget = [ budget, 12 ].max
|
|
51
|
+
text.length <= budget ? text : "..#{text[-(budget - 2)..]}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def header(monitor)
|
|
55
|
+
@io.puts
|
|
56
|
+
@io.puts " #{bold(magenta("txray"))} #{dim("watching #{@path}")}#{" " * 2}#{dim(clock(monitor.uptime))}"
|
|
57
|
+
@io.puts
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def stats(monitor)
|
|
61
|
+
if monitor.empty?
|
|
62
|
+
@io.puts " #{dim("waiting for the application to open a transaction")}"
|
|
63
|
+
@io.puts
|
|
64
|
+
return
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
@io.puts " #{label("transactions")}#{count(monitor)}"
|
|
68
|
+
@io.puts " #{label("duration")}#{spread(monitor)}"
|
|
69
|
+
@io.puts " #{label("")}#{sparkline(monitor)}"
|
|
70
|
+
@io.puts
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def count(monitor)
|
|
74
|
+
[ "#{monitor.transactions.size} seen",
|
|
75
|
+
tint("#{monitor.slow} slow", monitor.slow.zero? ? :dim : :yellow),
|
|
76
|
+
tint("#{monitor.flagged} with findings", monitor.flagged.zero? ? :dim : :red) ].join(dim(" "))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def spread(monitor)
|
|
80
|
+
[ "p50 #{duration(monitor.percentile(0.5))}",
|
|
81
|
+
"p95 #{duration(monitor.percentile(0.95))}",
|
|
82
|
+
"max #{duration(monitor.durations.max.to_f)}" ].join(dim(" "))
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def sparkline(monitor)
|
|
86
|
+
values = monitor.durations.last(48)
|
|
87
|
+
return dim("-") if values.empty?
|
|
88
|
+
|
|
89
|
+
peak = [ values.max, 1.0 ].max
|
|
90
|
+
values.map { |value| SPARKS[((value / peak) * (SPARKS.size - 1)).round] }.join
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def feed(monitor)
|
|
94
|
+
rows = monitor.recent(feed_limit)
|
|
95
|
+
return if rows.empty?
|
|
96
|
+
|
|
97
|
+
@io.puts " #{dim("LIVE")}"
|
|
98
|
+
rows.each { |event| @io.puts(event[:type] == "transaction" ? transaction_row(event) : violation_row(event)) }
|
|
99
|
+
@io.puts
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def transaction_row(event)
|
|
103
|
+
marker = if event[:violations].to_a.any?
|
|
104
|
+
red("x")
|
|
105
|
+
else
|
|
106
|
+
(event[:duration_ms].to_f >= @threshold_ms ? yellow("!") : green("."))
|
|
107
|
+
end
|
|
108
|
+
line = " #{dim(time(event))} #{duration(event[:duration_ms]).rjust(18)} #{marker} " \
|
|
109
|
+
"#{clip(source(event), @width - 36)}"
|
|
110
|
+
[ line, *event[:violations].to_a.map { |violation| finding_row(violation) } ].join("\n")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def violation_row(event)
|
|
114
|
+
" #{dim(time(event))} #{elapsed(event[:duration_ms]).rjust(18)} #{red("x")} " \
|
|
115
|
+
"#{red(event[:rule].to_s)} #{dim(clip(source(event), @width - 36 - event[:rule].to_s.length))}"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def elapsed(milliseconds) = milliseconds ? duration(milliseconds) : dim("-")
|
|
119
|
+
|
|
120
|
+
def finding_row(violation)
|
|
121
|
+
detail = [ violation[:message], duration_suffix(violation) ].compact.join(" ")
|
|
122
|
+
rule = violation[:rule].to_s
|
|
123
|
+
" #{" " * 24}#{dim("|")} #{yellow(rule)} #{dim(clip(detail, @width - 28 - rule.length))}"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def duration_suffix(violation)
|
|
127
|
+
"(#{duration(violation[:duration_ms])})" if violation[:duration_ms]
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def source(event) = event[:source].to_s.sub(/:in\s+[`'"](.+)[`'"]\z/, " \\1")
|
|
131
|
+
|
|
132
|
+
def hotspots(monitor)
|
|
133
|
+
rows = monitor.hotspots(5)
|
|
134
|
+
return if rows.empty?
|
|
135
|
+
|
|
136
|
+
@io.puts " #{dim("HOTSPOTS")}"
|
|
137
|
+
rows.each do |entry|
|
|
138
|
+
@io.puts " #{"#{entry[:count]}x".rjust(4)} #{yellow(entry[:rule].to_s.ljust(32))} " \
|
|
139
|
+
"#{dim(clip(source(entry), @width - 42))}"
|
|
140
|
+
end
|
|
141
|
+
@io.puts
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def summary(monitor)
|
|
145
|
+
return @io.puts(" no transactions observed") if monitor.empty?
|
|
146
|
+
|
|
147
|
+
@io.puts " #{monitor.transactions.size} transactions, #{monitor.slow} slow, " \
|
|
148
|
+
"#{monitor.findings.size} findings over #{clock(monitor.uptime)}"
|
|
149
|
+
monitor.hotspots(10).each do |entry|
|
|
150
|
+
@io.puts " #{"#{entry[:count]}x".rjust(4)} #{entry[:rule].to_s.ljust(32)} #{source(entry)}"
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def duration(milliseconds)
|
|
155
|
+
value = milliseconds.to_f
|
|
156
|
+
return "#{value.round}ms" if value < 1000
|
|
157
|
+
|
|
158
|
+
"#{(value / 1000).round(2)}s"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def time(event) = Time.at(event[:at].to_f).strftime("%H:%M:%S")
|
|
162
|
+
|
|
163
|
+
def clock(seconds)
|
|
164
|
+
format("%02d:%02d:%02d", seconds / 3600, (seconds % 3600) / 60, seconds % 60)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def label(text) = text.ljust(16)
|
|
168
|
+
|
|
169
|
+
def tint(text, color) = color == :dim ? dim(text) : send(color, text)
|
|
170
|
+
|
|
171
|
+
def bold(text) = "\e[1m#{text}\e[0m"
|
|
172
|
+
def dim(text) = "\e[2m#{text}\e[0m"
|
|
173
|
+
def red(text) = "\e[31m#{text}\e[0m"
|
|
174
|
+
def green(text) = "\e[32m#{text}\e[0m"
|
|
175
|
+
def yellow(text) = "\e[33m#{text}\e[0m"
|
|
176
|
+
def magenta(text) = "\e[35m#{text}\e[0m"
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Txray
|
|
6
|
+
module Reporters
|
|
7
|
+
class Sarif
|
|
8
|
+
LEVELS = { high: "error", medium: "warning", low: "note" }.freeze
|
|
9
|
+
|
|
10
|
+
def initialize(io: $stdout)
|
|
11
|
+
@io = io
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def report(result)
|
|
15
|
+
@io.puts JSON.pretty_generate(
|
|
16
|
+
"$schema" => "https://json.schemastore.org/sarif-2.1.0.json",
|
|
17
|
+
version: "2.1.0",
|
|
18
|
+
runs: [ { tool: tool, results: result.offenses.map { |offense| sarif_result(offense) } } ]
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def tool
|
|
25
|
+
{
|
|
26
|
+
driver: {
|
|
27
|
+
name: "txray",
|
|
28
|
+
version: Txray::VERSION,
|
|
29
|
+
informationUri: "https://github.com/theowecker/txray",
|
|
30
|
+
rules: Rules.all.values.map do |rule|
|
|
31
|
+
{ id: rule.id, shortDescription: { text: rule.id.tr("-", " ") }, fullDescription: { text: rule.remedy } }
|
|
32
|
+
end
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def sarif_result(offense)
|
|
38
|
+
{
|
|
39
|
+
ruleId: offense.id,
|
|
40
|
+
level: LEVELS.fetch(offense.severity, "warning"),
|
|
41
|
+
message: { text: "#{offense.message}. #{offense.rule.remedy}" },
|
|
42
|
+
locations: [ {
|
|
43
|
+
physicalLocation: {
|
|
44
|
+
artifactLocation: { uri: offense.path },
|
|
45
|
+
region: { startLine: offense.line, startColumn: offense.column }
|
|
46
|
+
}
|
|
47
|
+
} ]
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Txray
|
|
4
|
+
module Reporters
|
|
5
|
+
class Text
|
|
6
|
+
COLORS = { high: 31, medium: 33, low: 36 }.freeze
|
|
7
|
+
|
|
8
|
+
def initialize(io: $stdout, color: io.tty?)
|
|
9
|
+
@io = io
|
|
10
|
+
@color = color
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def report(result)
|
|
14
|
+
result.offenses.group_by(&:path).each do |path, offenses|
|
|
15
|
+
@io.puts bold(path)
|
|
16
|
+
offenses.each { |offense| print_offense(offense) }
|
|
17
|
+
@io.puts
|
|
18
|
+
end
|
|
19
|
+
print_summary(result)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def print_offense(offense)
|
|
25
|
+
@io.puts " #{offense.line}:#{offense.column} #{tint(offense.severity.to_s.ljust(6), offense.severity)} #{offense.id}"
|
|
26
|
+
@io.puts " #{offense.message}"
|
|
27
|
+
offense.trace.each { |frame| @io.puts dim(" via #{frame}") }
|
|
28
|
+
@io.puts dim(" #{offense.rule.remedy}")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def print_summary(result)
|
|
32
|
+
counts = result.counts
|
|
33
|
+
summary = Offense::SEVERITIES.reverse.filter_map { |s| "#{counts[s]} #{s}" if counts[s] }
|
|
34
|
+
@io.puts "#{result.files.size} files scanned, #{result.offenses.size} offenses" \
|
|
35
|
+
"#{" (#{summary.join(", ")})" unless summary.empty?}"
|
|
36
|
+
return if result.skipped.to_a.empty?
|
|
37
|
+
|
|
38
|
+
@io.puts dim("#{result.skipped.size} files could not be parsed and were skipped:")
|
|
39
|
+
result.skipped.each { |path| @io.puts dim(" #{path}") }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def tint(text, severity) = @color ? "\e[#{COLORS.fetch(severity, 0)}m#{text}\e[0m" : text
|
|
43
|
+
def bold(text) = @color ? "\e[1m#{text}\e[0m" : text
|
|
44
|
+
def dim(text) = @color ? "\e[2m#{text}\e[0m" : text
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "reporters/text"
|
|
4
|
+
require_relative "reporters/json"
|
|
5
|
+
require_relative "reporters/sarif"
|
|
6
|
+
require_relative "reporters/github"
|
|
7
|
+
require_relative "reporters/live"
|
|
8
|
+
|
|
9
|
+
module Txray
|
|
10
|
+
module Reporters
|
|
11
|
+
FORMATS = { "text" => Text, "json" => Json, "sarif" => Sarif, "github" => Github }.freeze
|
|
12
|
+
|
|
13
|
+
def self.build(format, io: $stdout)
|
|
14
|
+
klass = FORMATS[format.to_s] or raise Error, "unknown format #{format}, expected one of #{FORMATS.keys.join(", ")}"
|
|
15
|
+
klass.new(io: io)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
data/lib/txray/rule.rb
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Txray
|
|
4
|
+
Rule = Struct.new(:id, :category, :severity, :message, :remedy, keyword_init: true)
|
|
5
|
+
|
|
6
|
+
module Rules
|
|
7
|
+
def self.all = @all ||= {}
|
|
8
|
+
|
|
9
|
+
def self.register(id:, category:, severity:, message:, remedy:)
|
|
10
|
+
all[id] = Rule.new(id: id, category: category, severity: severity, message: message, remedy: remedy)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.[](id) = all.fetch(id)
|
|
14
|
+
def self.ids = all.keys
|
|
15
|
+
|
|
16
|
+
register(
|
|
17
|
+
id: "http-in-transaction",
|
|
18
|
+
category: :transaction,
|
|
19
|
+
severity: :high,
|
|
20
|
+
message: "HTTP request `%{snippet}` runs inside %{scope}",
|
|
21
|
+
remedy: "Move the request outside the transaction, or enqueue it from an after_commit callback."
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
register(
|
|
25
|
+
id: "external-service-in-transaction",
|
|
26
|
+
category: :transaction,
|
|
27
|
+
severity: :high,
|
|
28
|
+
message: "External service call `%{snippet}` runs inside %{scope}",
|
|
29
|
+
remedy: "Third party clients hold the connection and the row locks for their full round trip. Call them after commit."
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
register(
|
|
33
|
+
id: "mail-in-transaction",
|
|
34
|
+
category: :transaction,
|
|
35
|
+
severity: :high,
|
|
36
|
+
message: "Synchronous mail delivery `%{snippet}` runs inside %{scope}",
|
|
37
|
+
remedy: "Use deliver_later from an after_commit callback so SMTP latency stays out of the transaction."
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
register(
|
|
41
|
+
id: "shell-in-transaction",
|
|
42
|
+
category: :transaction,
|
|
43
|
+
severity: :high,
|
|
44
|
+
message: "Subprocess `%{snippet}` runs inside %{scope}",
|
|
45
|
+
remedy: "Shelling out blocks the connection for an unbounded time. Run it after the transaction commits."
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
register(
|
|
49
|
+
id: "sleep-in-transaction",
|
|
50
|
+
category: :transaction,
|
|
51
|
+
severity: :high,
|
|
52
|
+
message: "`%{snippet}` deliberately blocks inside %{scope}",
|
|
53
|
+
remedy: "Sleeping while holding row locks stalls every writer behind you. Sleep outside the transaction."
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
register(
|
|
57
|
+
id: "job-enqueue-in-transaction",
|
|
58
|
+
category: :transaction,
|
|
59
|
+
severity: :medium,
|
|
60
|
+
message: "Background job `%{snippet}` is enqueued inside %{scope}",
|
|
61
|
+
remedy: "The worker can pick the job up before the transaction commits and read stale or missing rows. Enqueue from after_commit."
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
register(
|
|
65
|
+
id: "upload-in-transaction",
|
|
66
|
+
category: :transaction,
|
|
67
|
+
severity: :medium,
|
|
68
|
+
message: "Attachment operation `%{snippet}` runs inside %{scope}",
|
|
69
|
+
remedy: "Active Storage uploads to object storage over the network. Attach after commit or upload before opening the transaction."
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
register(
|
|
73
|
+
id: "iteration-in-transaction",
|
|
74
|
+
category: :transaction,
|
|
75
|
+
severity: :medium,
|
|
76
|
+
message: "Loop `%{snippet}` performs database work per iteration inside %{scope}",
|
|
77
|
+
remedy: "Transaction duration grows with collection size. Batch the writes or move the loop outside the transaction."
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
register(
|
|
81
|
+
id: "broadcast-in-transaction",
|
|
82
|
+
category: :transaction,
|
|
83
|
+
severity: :medium,
|
|
84
|
+
message: "Broadcast `%{snippet}` is pushed inside %{scope}",
|
|
85
|
+
remedy: "Rendering and pushing before the commit lets subscribers see rows that are not committed yet, " \
|
|
86
|
+
"or that roll back. Broadcast from after_commit."
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
register(
|
|
90
|
+
id: "dynamic-dispatch-in-transaction",
|
|
91
|
+
category: :transaction,
|
|
92
|
+
severity: :low,
|
|
93
|
+
message: "`%{snippet}` dispatches to a name txray cannot resolve inside %{scope}",
|
|
94
|
+
remedy: "Static analysis stops here. Check by hand that the target does no network or filesystem work, or enable the runtime guard."
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
register(
|
|
98
|
+
id: "blocking-io-in-transaction",
|
|
99
|
+
category: :transaction,
|
|
100
|
+
severity: :medium,
|
|
101
|
+
message: "File or media work `%{snippet}` runs inside %{scope}",
|
|
102
|
+
remedy: "Parsing, rendering and image processing scale with input size. Do the work before opening the transaction."
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
register(
|
|
106
|
+
id: "cache-in-transaction",
|
|
107
|
+
category: :transaction,
|
|
108
|
+
severity: :low,
|
|
109
|
+
message: "Cache or key value call `%{snippet}` runs inside %{scope}",
|
|
110
|
+
remedy: "A cache round trip is still network latency held against an open transaction. Read it before the transaction."
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Txray
|
|
6
|
+
module Runtime
|
|
7
|
+
class Sink
|
|
8
|
+
def self.build(path)
|
|
9
|
+
path ? new(path) : Null.new
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(path)
|
|
13
|
+
@path = path
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def write(event)
|
|
17
|
+
prepare
|
|
18
|
+
File.open(@path, "a") do |file|
|
|
19
|
+
file.flock(File::LOCK_EX)
|
|
20
|
+
file.puts(JSON.generate(event))
|
|
21
|
+
end
|
|
22
|
+
rescue StandardError
|
|
23
|
+
nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def prepare
|
|
29
|
+
return if @prepared
|
|
30
|
+
|
|
31
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
32
|
+
@prepared = true
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
class Null
|
|
36
|
+
def write(_event) = nil
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Txray
|
|
4
|
+
module Runtime
|
|
5
|
+
class Transaction
|
|
6
|
+
attr_reader :source, :violations
|
|
7
|
+
|
|
8
|
+
def initialize(source)
|
|
9
|
+
@source = source
|
|
10
|
+
@violations = []
|
|
11
|
+
@started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def record(violation) = @violations << violation
|
|
15
|
+
|
|
16
|
+
def duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started_at) * 1000).round(1)
|
|
17
|
+
|
|
18
|
+
def to_event(outcome)
|
|
19
|
+
{
|
|
20
|
+
type: "transaction",
|
|
21
|
+
at: Time.now.to_f,
|
|
22
|
+
pid: Process.pid,
|
|
23
|
+
duration_ms: duration_ms,
|
|
24
|
+
outcome: outcome.to_s,
|
|
25
|
+
source: source,
|
|
26
|
+
violations: violations
|
|
27
|
+
}
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
class Stack
|
|
32
|
+
KEY = :txray_transactions
|
|
33
|
+
|
|
34
|
+
class << self
|
|
35
|
+
def push(source)
|
|
36
|
+
frames.push(Transaction.new(source))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def pop = frames.pop
|
|
40
|
+
def current = frames.last
|
|
41
|
+
def open? = !frames.empty?
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def frames = Thread.current[KEY] ||= []
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
require_relative "runtime/sink"
|
|
7
|
+
require_relative "runtime/transaction"
|
|
8
|
+
|
|
9
|
+
module Txray
|
|
10
|
+
module Runtime
|
|
11
|
+
class Violation < Error; end
|
|
12
|
+
|
|
13
|
+
IGNORE_KEY = :txray_ignoring
|
|
14
|
+
GEM_ROOT = File.expand_path("..", __dir__)
|
|
15
|
+
|
|
16
|
+
DEFAULTS = {
|
|
17
|
+
threshold_ms: 250,
|
|
18
|
+
on_violation: :log,
|
|
19
|
+
guard_http: true,
|
|
20
|
+
guard_jobs: true,
|
|
21
|
+
guard_mail: true,
|
|
22
|
+
log_path: nil,
|
|
23
|
+
ignore: [],
|
|
24
|
+
logger: nil
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
attr_reader :options
|
|
29
|
+
|
|
30
|
+
def install(**overrides)
|
|
31
|
+
return if @installed
|
|
32
|
+
|
|
33
|
+
@installed = true
|
|
34
|
+
@options = DEFAULTS.merge(overrides.compact)
|
|
35
|
+
@ignore = Array(@options[:ignore]).map { |pattern| Regexp.new(pattern.to_s) }
|
|
36
|
+
@sink = Sink.build(@options[:log_path])
|
|
37
|
+
@subscribers = []
|
|
38
|
+
|
|
39
|
+
watch_transactions
|
|
40
|
+
watch_jobs if @options[:guard_jobs]
|
|
41
|
+
watch_mail if @options[:guard_mail]
|
|
42
|
+
patch_net_http if @options[:guard_http]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def uninstall
|
|
46
|
+
@subscribers.to_a.each { |subscriber| ActiveSupport::Notifications.unsubscribe(subscriber) }
|
|
47
|
+
@subscribers = []
|
|
48
|
+
@installed = nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def installed? = @installed == true
|
|
52
|
+
|
|
53
|
+
def ignore
|
|
54
|
+
previous = Thread.current[IGNORE_KEY]
|
|
55
|
+
Thread.current[IGNORE_KEY] = true
|
|
56
|
+
yield
|
|
57
|
+
ensure
|
|
58
|
+
Thread.current[IGNORE_KEY] = previous
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def ignoring? = Thread.current[IGNORE_KEY] == true
|
|
62
|
+
|
|
63
|
+
def transaction_open?
|
|
64
|
+
return false unless defined?(ActiveRecord::Base)
|
|
65
|
+
|
|
66
|
+
pool = ActiveRecord::Base.connection_pool
|
|
67
|
+
return false unless pool.active_connection?
|
|
68
|
+
|
|
69
|
+
connection = pool.respond_to?(:lease_connection) ? pool.lease_connection : pool.connection
|
|
70
|
+
connection.transaction_open?
|
|
71
|
+
rescue StandardError
|
|
72
|
+
false
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def violation(rule, message, duration_ms: nil)
|
|
76
|
+
return if ignoring?
|
|
77
|
+
|
|
78
|
+
source = app_backtrace.first
|
|
79
|
+
return if ignored?("#{message} #{source}")
|
|
80
|
+
|
|
81
|
+
record = { rule: rule, message: message, duration_ms: duration_ms, source: source }
|
|
82
|
+
open = Stack.current
|
|
83
|
+
open&.record(record)
|
|
84
|
+
report(record.merge(type: "violation", at: Time.now.to_f, pid: Process.pid)) if open.nil?
|
|
85
|
+
announce(rule, message)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def report(event)
|
|
89
|
+
@sink.write(event)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def announce(rule, message)
|
|
95
|
+
text = "[txray] #{rule}: #{message}\n #{app_backtrace.join("\n ")}"
|
|
96
|
+
raise Violation, text if @options[:on_violation] == :raise
|
|
97
|
+
|
|
98
|
+
logger.warn(text)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def ignored?(text) = @ignore.any? { |pattern| pattern.match?(text) }
|
|
102
|
+
|
|
103
|
+
def logger = @options[:logger] || (defined?(Rails) && Rails.logger) || Logger.new($stderr)
|
|
104
|
+
|
|
105
|
+
def app_backtrace
|
|
106
|
+
root = defined?(Rails) && Rails.root ? Rails.root.to_s : Dir.pwd
|
|
107
|
+
frames = caller.reject { |line| vendored?(line) }
|
|
108
|
+
own = frames.select { |line| line.start_with?(root) }
|
|
109
|
+
own = frames if own.empty?
|
|
110
|
+
own.map { |line| line.delete_prefix("#{root}/") }.first(5)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def vendored?(line)
|
|
114
|
+
line.start_with?(GEM_ROOT, RbConfig::CONFIG["libdir"]) || line.include?("/gems/")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def subscribe(event, &block)
|
|
118
|
+
@subscribers << ActiveSupport::Notifications.subscribe(event) do |*args|
|
|
119
|
+
block.call(ActiveSupport::Notifications::Event.new(*args))
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def watch_transactions
|
|
124
|
+
subscribe("start_transaction.active_record") { open_transaction }
|
|
125
|
+
subscribe("transaction.active_record") { |event| close_transaction(event) }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def open_transaction
|
|
129
|
+
Stack.push(app_backtrace.first)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def close_transaction(event)
|
|
133
|
+
transaction = Stack.pop
|
|
134
|
+
return if ignoring?
|
|
135
|
+
|
|
136
|
+
duration = transaction&.duration_ms || event.duration.round(1)
|
|
137
|
+
slow = duration >= @options[:threshold_ms]
|
|
138
|
+
violations = transaction&.violations.to_a
|
|
139
|
+
return unless slow || violations.any?
|
|
140
|
+
|
|
141
|
+
outcome = event.payload[:outcome] || :commit
|
|
142
|
+
report((transaction&.to_event(outcome) || fallback_event(duration, outcome)).merge(duration_ms: duration))
|
|
143
|
+
announce_slow(duration) if slow
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def fallback_event(duration, outcome)
|
|
147
|
+
{ type: "transaction", at: Time.now.to_f, pid: Process.pid, duration_ms: duration,
|
|
148
|
+
outcome: outcome.to_s, source: app_backtrace.first, violations: [] }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def announce_slow(duration)
|
|
152
|
+
announce("slow-transaction",
|
|
153
|
+
"transaction held for #{duration}ms, over the #{@options[:threshold_ms]}ms threshold")
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def watch_jobs
|
|
157
|
+
subscribe("enqueue.active_job") do |event|
|
|
158
|
+
next unless transaction_open?
|
|
159
|
+
|
|
160
|
+
violation("job-enqueue-in-transaction", "#{event.payload[:job].class} enqueued inside an open transaction")
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def watch_mail
|
|
165
|
+
subscribe("deliver.action_mailer") do |event|
|
|
166
|
+
next unless transaction_open?
|
|
167
|
+
|
|
168
|
+
violation("mail-in-transaction", "#{event.payload[:mailer]} delivered mail inside an open transaction",
|
|
169
|
+
duration_ms: event.duration.round(1))
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def patch_net_http
|
|
174
|
+
return if @http_patched
|
|
175
|
+
|
|
176
|
+
require "net/http"
|
|
177
|
+
Net::HTTP.prepend(NetHttpGuard)
|
|
178
|
+
@http_patched = true
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
module NetHttpGuard
|
|
183
|
+
def request(req, body = nil, &)
|
|
184
|
+
return super unless Txray::Runtime.transaction_open?
|
|
185
|
+
|
|
186
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
187
|
+
begin
|
|
188
|
+
super
|
|
189
|
+
ensure
|
|
190
|
+
elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round(1)
|
|
191
|
+
Txray::Runtime.violation("http-in-transaction", "#{req.method} #{address}#{req.path} inside an open " \
|
|
192
|
+
"transaction", duration_ms: elapsed)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|