txray 0.1.0 → 0.2.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 +12 -0
- data/README.md +108 -27
- data/Rakefile +1 -0
- data/doc/monitor.png +0 -0
- data/doc/monitor.svg +229 -0
- data/lib/generators/txray/install_generator.rb +28 -0
- data/lib/generators/txray/templates/txray.yml +37 -0
- data/lib/txray/cli.rb +3 -0
- data/lib/txray/config.rb +28 -1
- data/lib/txray/monitor.rb +25 -1
- data/lib/txray/reporters/github.rb +71 -5
- data/lib/txray/reporters/live.rb +157 -69
- data/lib/txray/reporters/text.rb +65 -16
- data/lib/txray/reporters.rb +4 -0
- data/lib/txray/runtime.rb +47 -20
- data/lib/txray/scanner.rb +14 -1
- data/lib/txray/source_file.rb +56 -10
- data/lib/txray/version.rb +1 -1
- metadata +5 -1
data/lib/txray/monitor.rb
CHANGED
|
@@ -30,9 +30,28 @@ module Txray
|
|
|
30
30
|
nil
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
+
BUCKETS = [ [ 10, "<10ms" ], [ 50, "<50ms" ], [ 100, "<100ms" ], [ 250, "<250ms" ],
|
|
34
|
+
[ 1000, "<1s" ], [ Float::INFINITY, "1s+" ] ].freeze
|
|
35
|
+
|
|
33
36
|
def recent(limit) = @recent.first(limit)
|
|
37
|
+
def pids = @transactions.map { |event| event[:pid] }.uniq.size
|
|
38
|
+
|
|
39
|
+
def breakdown
|
|
40
|
+
flagged = @transactions.count { |event| event[:violations].to_a.any? }
|
|
41
|
+
slow = @transactions.count { |event| slow?(event) && event[:violations].to_a.empty? }
|
|
42
|
+
{ ok: @transactions.size - flagged - slow, slow: slow, flagged: flagged }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def histogram
|
|
46
|
+
BUCKETS.map do |limit, label|
|
|
47
|
+
{ label: label, limit: limit,
|
|
48
|
+
count: durations.count { |value| value < limit && value >= previous_limit(limit) } }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
34
52
|
def durations = @transactions.map { |event| event[:duration_ms].to_f }
|
|
35
|
-
def slow = @transactions.count { |event| event
|
|
53
|
+
def slow = @transactions.count { |event| slow?(event) }
|
|
54
|
+
def slow?(event) = event[:duration_ms].to_f >= @threshold_ms
|
|
36
55
|
def flagged = @transactions.count { |event| event[:violations].to_a.any? }
|
|
37
56
|
def uptime = Time.now - @started_at
|
|
38
57
|
def empty? = @transactions.empty? && @violations.empty?
|
|
@@ -60,6 +79,11 @@ module Txray
|
|
|
60
79
|
|
|
61
80
|
private
|
|
62
81
|
|
|
82
|
+
def previous_limit(limit)
|
|
83
|
+
index = BUCKETS.index { |bucket_limit, _| bucket_limit == limit }
|
|
84
|
+
index.zero? ? 0 : BUCKETS[index - 1].first
|
|
85
|
+
end
|
|
86
|
+
|
|
63
87
|
def add(collection, event)
|
|
64
88
|
collection << event
|
|
65
89
|
collection.shift while collection.size > CAP
|
|
@@ -4,17 +4,83 @@ module Txray
|
|
|
4
4
|
module Reporters
|
|
5
5
|
class Github
|
|
6
6
|
LEVELS = { high: "error", medium: "warning", low: "notice" }.freeze
|
|
7
|
+
SUMMARY_LIMIT = 50
|
|
7
8
|
|
|
8
|
-
def initialize(io: $stdout)
|
|
9
|
+
def initialize(io: $stdout, summary_path: ENV.fetch("GITHUB_STEP_SUMMARY", nil))
|
|
9
10
|
@io = io
|
|
11
|
+
@summary_path = summary_path
|
|
10
12
|
end
|
|
11
13
|
|
|
12
14
|
def report(result)
|
|
13
|
-
result.offenses.each
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
result.offenses.each { |offense| @io.puts annotation(offense) }
|
|
16
|
+
write_summary(result)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def annotation(offense)
|
|
22
|
+
level = LEVELS.fetch(offense.severity, "warning")
|
|
23
|
+
properties = [ "file=#{property(offense.path)}", "line=#{offense.line}", "col=#{offense.column}",
|
|
24
|
+
"title=#{property("txray #{offense.id}")}" ].join(",")
|
|
25
|
+
"::#{level} #{properties}::#{message(offense)}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def message(offense)
|
|
29
|
+
body = [ offense.message, *offense.trace.map { |frame| "via #{frame}" }, offense.rule.remedy ].join("\n")
|
|
30
|
+
escape(body)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def escape(text)
|
|
34
|
+
text.to_s.gsub("%", "%25").gsub("\r", "%0D").gsub("\n", "%0A")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def property(text)
|
|
38
|
+
escape(text).gsub(":", "%3A").gsub(",", "%2C")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def write_summary(result)
|
|
42
|
+
return if @summary_path.nil? || @summary_path.empty?
|
|
43
|
+
|
|
44
|
+
File.open(@summary_path, "a") { |file| file.puts(summary(result)) }
|
|
45
|
+
rescue StandardError
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def summary(result)
|
|
50
|
+
if result.offenses.empty?
|
|
51
|
+
return [ "## txray", "",
|
|
52
|
+
"No slow work found inside transactions across #{result.files.size} files." ].join("\n")
|
|
17
53
|
end
|
|
54
|
+
|
|
55
|
+
[ "## txray", "", headline(result), "", table(result), footnote(result) ].compact.join("\n")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def headline(result)
|
|
59
|
+
counts = Offense::SEVERITIES.reverse.filter_map do |severity|
|
|
60
|
+
"#{result.counts[severity]} #{severity}" if result.counts[severity]
|
|
61
|
+
end
|
|
62
|
+
"**#{result.offenses.size} offenses** across #{result.files.size} files (#{counts.join(", ")})."
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def table(result)
|
|
66
|
+
rows = result.offenses.first(SUMMARY_LIMIT).map do |offense|
|
|
67
|
+
"| #{offense.severity} | `#{offense.id}` | #{link(offense)} | #{cell(offense.message)} |"
|
|
68
|
+
end
|
|
69
|
+
[ "| Severity | Rule | Location | What happens |", "| --- | --- | --- | --- |", *rows ].join("\n")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def link(offense)
|
|
73
|
+
"`#{offense.path}:#{offense.line}`"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def cell(text)
|
|
77
|
+
text.to_s.gsub("|", "\\|").gsub("\n", " ")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def footnote(result)
|
|
81
|
+
return nil if result.offenses.size <= SUMMARY_LIMIT
|
|
82
|
+
|
|
83
|
+
"\n_Showing the first #{SUMMARY_LIMIT} of #{result.offenses.size}. Run `bundle exec txray` locally for the rest._"
|
|
18
84
|
end
|
|
19
85
|
end
|
|
20
86
|
end
|
data/lib/txray/reporters/live.rb
CHANGED
|
@@ -6,13 +6,17 @@ module Txray
|
|
|
6
6
|
module Reporters
|
|
7
7
|
class Live
|
|
8
8
|
SPARKS = %w[▁ ▂ ▃ ▄ ▅ ▆ ▇ █].freeze
|
|
9
|
+
SEVERITY_COLORS = { high: 91, medium: 93, low: 96 }.freeze
|
|
9
10
|
HIDE_CURSOR = "\e[?25l"
|
|
10
11
|
SHOW_CURSOR = "\e[?25h"
|
|
11
12
|
|
|
12
|
-
def initialize(path:, io: $stdout, threshold_ms: 250)
|
|
13
|
+
def initialize(path:, io: $stdout, threshold_ms: 250, color: Reporters.color?(io))
|
|
13
14
|
@io = io
|
|
14
15
|
@path = path
|
|
15
16
|
@threshold_ms = threshold_ms
|
|
17
|
+
@color = color
|
|
18
|
+
@width = 100
|
|
19
|
+
@height = 40
|
|
16
20
|
end
|
|
17
21
|
|
|
18
22
|
def open = @io.print(HIDE_CURSOR)
|
|
@@ -27,11 +31,12 @@ module Txray
|
|
|
27
31
|
def draw(monitor)
|
|
28
32
|
@width, @height = viewport
|
|
29
33
|
@io.print("\e[H\e[2J")
|
|
30
|
-
|
|
31
|
-
|
|
34
|
+
title(monitor)
|
|
35
|
+
meters(monitor)
|
|
36
|
+
histogram(monitor)
|
|
32
37
|
feed(monitor)
|
|
33
38
|
hotspots(monitor)
|
|
34
|
-
|
|
39
|
+
footer
|
|
35
40
|
@io.flush
|
|
36
41
|
end
|
|
37
42
|
|
|
@@ -44,113 +49,202 @@ module Txray
|
|
|
44
49
|
[ 100, 40 ]
|
|
45
50
|
end
|
|
46
51
|
|
|
47
|
-
def
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
+
def title(monitor)
|
|
53
|
+
workers = monitor.pids
|
|
54
|
+
right = "up #{clock(monitor.uptime)}#{" #{workers} pids" if workers > 1}"
|
|
55
|
+
left = " txray #{@path}"
|
|
56
|
+
@io.puts bar(left, right)
|
|
57
|
+
@io.puts
|
|
52
58
|
end
|
|
53
59
|
|
|
54
|
-
def
|
|
55
|
-
|
|
56
|
-
|
|
60
|
+
def meters(monitor)
|
|
61
|
+
return setup_hint if monitor.transactions.empty?
|
|
62
|
+
|
|
63
|
+
counts = monitor.breakdown
|
|
64
|
+
summary = legend(monitor, counts)
|
|
65
|
+
@io.puts " #{label("txns")}#{stacked(counts, room(summary))} #{summary}"
|
|
66
|
+
spread = percentiles(monitor)
|
|
67
|
+
@io.puts " #{label("time")}#{sparkline(monitor, room(spread))} #{spread}"
|
|
57
68
|
@io.puts
|
|
58
69
|
end
|
|
59
70
|
|
|
60
|
-
def
|
|
61
|
-
|
|
71
|
+
def room(text) = (@width - 12 - visible(text)).clamp(8, 34)
|
|
72
|
+
|
|
73
|
+
def setup_hint
|
|
74
|
+
if File.exist?(@path)
|
|
62
75
|
@io.puts " #{dim("waiting for the application to open a transaction")}"
|
|
63
|
-
@io.puts
|
|
64
|
-
return
|
|
76
|
+
return @io.puts
|
|
65
77
|
end
|
|
66
78
|
|
|
67
|
-
@io.puts " #{
|
|
68
|
-
@io.puts
|
|
69
|
-
@io.puts " #{
|
|
79
|
+
@io.puts " #{paint("no event log yet", 93)} #{dim("at #{@path}")}"
|
|
80
|
+
@io.puts
|
|
81
|
+
@io.puts " #{dim("the runtime guard writes it. to turn it on:")}"
|
|
82
|
+
@io.puts
|
|
83
|
+
@io.puts " #{paint("1", 97)} Gemfile #{dim('gem "txray" not require: false')}"
|
|
84
|
+
@io.puts " #{paint("2", 97)} .txray.yml #{dim("runtime:")}"
|
|
85
|
+
@io.puts " #{dim(" enabled: true")}"
|
|
86
|
+
@io.puts " #{paint("3", 97)} #{dim("restart the app, then exercise it")}"
|
|
70
87
|
@io.puts
|
|
71
88
|
end
|
|
72
89
|
|
|
73
|
-
def
|
|
74
|
-
[
|
|
75
|
-
|
|
76
|
-
|
|
90
|
+
def stacked(counts, width)
|
|
91
|
+
parts = [ [ counts[:ok], 92 ], [ counts[:slow], 93 ], [ counts[:flagged], 91 ] ]
|
|
92
|
+
return "#{dim("[")}#{dim("·" * width)}#{dim("]")}" if parts.sum(&:first).zero?
|
|
93
|
+
|
|
94
|
+
cells = allocate(parts.map(&:first), width)
|
|
95
|
+
rendered = parts.each_with_index.map { |(_, color), i| paint("█" * cells[i], color) }.join
|
|
96
|
+
"#{dim("[")}#{rendered}#{dim("]")}"
|
|
77
97
|
end
|
|
78
98
|
|
|
79
|
-
def
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
99
|
+
def allocate(counts, width)
|
|
100
|
+
total = counts.sum
|
|
101
|
+
cells = counts.map { |count| count.zero? ? 0 : [ ((count.to_f / total) * width).round, 1 ].max }
|
|
102
|
+
cells[cells.index(cells.max)] += width - cells.sum
|
|
103
|
+
cells.map { |cell| [ cell, 0 ].max }
|
|
83
104
|
end
|
|
84
105
|
|
|
85
|
-
def
|
|
86
|
-
|
|
87
|
-
|
|
106
|
+
def legend(monitor, counts)
|
|
107
|
+
[ "#{monitor.transactions.size} txns",
|
|
108
|
+
paint("#{counts[:ok]} ok", 92),
|
|
109
|
+
paint("#{counts[:slow]} slow", 93),
|
|
110
|
+
paint("#{counts[:flagged]} flagged", 91) ].join(dim(" · "))
|
|
111
|
+
end
|
|
88
112
|
|
|
89
|
-
|
|
90
|
-
|
|
113
|
+
def percentiles(monitor)
|
|
114
|
+
[ tinted("p50", monitor.percentile(0.5)),
|
|
115
|
+
tinted("p95", monitor.percentile(0.95)),
|
|
116
|
+
tinted("max", monitor.durations.max.to_f) ].join(dim(" · "))
|
|
91
117
|
end
|
|
92
118
|
|
|
119
|
+
def tinted(name, value)
|
|
120
|
+
"#{dim(name)} #{paint(duration(value), heat(value))}"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def heat(value)
|
|
124
|
+
return 91 if value >= @threshold_ms
|
|
125
|
+
return 93 if value >= @threshold_ms / 2.0
|
|
126
|
+
|
|
127
|
+
92
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def sparkline(monitor, width)
|
|
131
|
+
values = monitor.durations.last(width)
|
|
132
|
+
return dim("·" * width) if values.empty?
|
|
133
|
+
|
|
134
|
+
scale = Math.log(1 + [ values.max, 1.0 ].max)
|
|
135
|
+
values.map do |value|
|
|
136
|
+
level = ((Math.log(1 + value) / scale) * (SPARKS.size - 1)).round
|
|
137
|
+
paint(SPARKS[level.clamp(0, SPARKS.size - 1)], heat(value))
|
|
138
|
+
end.join
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def histogram(monitor)
|
|
142
|
+
rows = monitor.histogram.reject { |row| row[:count].zero? }
|
|
143
|
+
return if rows.empty?
|
|
144
|
+
|
|
145
|
+
peak = rows.map { |row| row[:count] }.max
|
|
146
|
+
width = @width < 72 ? [ @width - 20, 10 ].max : [ (@width - 30) / 2, 12 ].max
|
|
147
|
+
columns = @width < 72 ? 1 : 2
|
|
148
|
+
rows.each_slice(columns) { |group| @io.puts(" #{group.map { |row| bucket(row, peak, width) }.join(" ")}") }
|
|
149
|
+
@io.puts
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def bucket(row, peak, width)
|
|
153
|
+
filled = [ ((row[:count].to_f / peak) * width).round, 1 ].max
|
|
154
|
+
color = heat(row[:limit] == Float::INFINITY ? @threshold_ms * 10 : row[:limit] - 1)
|
|
155
|
+
"#{dim(row[:label].rjust(7))} #{paint("█" * filled, color)}#{dim("·" * [ width - filled, 0 ].max)} " \
|
|
156
|
+
"#{count_label(row[:count])}"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def count_label(count) = paint(count.to_s.ljust(4), 97)
|
|
160
|
+
|
|
93
161
|
def feed(monitor)
|
|
94
162
|
rows = monitor.recent(feed_limit)
|
|
95
163
|
return if rows.empty?
|
|
96
164
|
|
|
97
|
-
@io.puts "
|
|
165
|
+
@io.puts bar(" TIME ELAPSED SOURCE", "")
|
|
98
166
|
rows.each { |event| @io.puts(event[:type] == "transaction" ? transaction_row(event) : violation_row(event)) }
|
|
99
167
|
@io.puts
|
|
100
168
|
end
|
|
101
169
|
|
|
102
170
|
def transaction_row(event)
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
171
|
+
findings = event[:violations].to_a
|
|
172
|
+
state = if findings.any?
|
|
173
|
+
paint("●", 91)
|
|
174
|
+
elsif event[:duration_ms].to_f >= @threshold_ms
|
|
175
|
+
paint("●", 93)
|
|
176
|
+
else
|
|
177
|
+
paint("·", 92)
|
|
178
|
+
end
|
|
179
|
+
elapsed = paint(duration(event[:duration_ms]).rjust(9), heat(event[:duration_ms].to_f))
|
|
180
|
+
line = " #{dim(time(event))} #{elapsed} #{state} #{clip(source(event), @width - 34)}"
|
|
181
|
+
[ line, *findings.map { |finding| finding_row(finding) } ].join("\n")
|
|
111
182
|
end
|
|
112
183
|
|
|
113
184
|
def violation_row(event)
|
|
114
|
-
|
|
115
|
-
|
|
185
|
+
rule = event[:rule].to_s
|
|
186
|
+
" #{dim(time(event))} #{dim(" -")} #{paint("●", severity_color(rule))} " \
|
|
187
|
+
"#{paint(rule, severity_color(rule))} #{dim(clip(source(event), @width - 36 - rule.length))}"
|
|
116
188
|
end
|
|
117
189
|
|
|
118
|
-
def
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
" #{" " * 24}#{dim("|")} #{yellow(rule)} #{dim(clip(detail, @width - 28 - rule.length))}"
|
|
190
|
+
def finding_row(finding)
|
|
191
|
+
rule = finding[:rule].to_s
|
|
192
|
+
detail = [ finding[:message], duration_suffix(finding) ].compact.join(" ")
|
|
193
|
+
" #{" " * 20}#{dim("└")} #{paint(rule, severity_color(rule))} " \
|
|
194
|
+
"#{dim(clip(detail, @width - 26 - rule.length))}"
|
|
124
195
|
end
|
|
125
196
|
|
|
126
|
-
def
|
|
127
|
-
|
|
197
|
+
def severity_color(rule)
|
|
198
|
+
SEVERITY_COLORS.fetch(Rules.all[rule]&.severity, 95)
|
|
128
199
|
end
|
|
129
200
|
|
|
130
|
-
def
|
|
201
|
+
def duration_suffix(finding)
|
|
202
|
+
"(#{duration(finding[:duration_ms])})" if finding[:duration_ms]
|
|
203
|
+
end
|
|
131
204
|
|
|
132
205
|
def hotspots(monitor)
|
|
133
|
-
rows = monitor.hotspots(
|
|
206
|
+
rows = monitor.hotspots(hotspot_limit)
|
|
134
207
|
return if rows.empty?
|
|
135
208
|
|
|
136
|
-
@io.puts "
|
|
209
|
+
@io.puts bar(" HOTSPOTS", "")
|
|
137
210
|
rows.each do |entry|
|
|
138
|
-
|
|
139
|
-
|
|
211
|
+
rule = entry[:rule].to_s
|
|
212
|
+
@io.puts " #{paint("#{entry[:count]}x".rjust(5), 97)} #{paint(rule.ljust(32), severity_color(rule))} " \
|
|
213
|
+
"#{dim(clip(source(entry), @width - 45))}"
|
|
140
214
|
end
|
|
141
215
|
@io.puts
|
|
142
216
|
end
|
|
143
217
|
|
|
218
|
+
def footer = @io.print(bar(" ctrl-c stop", ""))
|
|
219
|
+
|
|
144
220
|
def summary(monitor)
|
|
145
221
|
return @io.puts(" no transactions observed") if monitor.empty?
|
|
146
222
|
|
|
147
|
-
|
|
148
|
-
|
|
223
|
+
counts = monitor.breakdown
|
|
224
|
+
@io.puts " #{monitor.transactions.size} transactions over #{clock(monitor.uptime)}: " \
|
|
225
|
+
"#{counts[:ok]} ok, #{counts[:slow]} slow, #{counts[:flagged]} with findings"
|
|
149
226
|
monitor.hotspots(10).each do |entry|
|
|
150
|
-
@io.puts " #{"#{entry[:count]}x".rjust(
|
|
227
|
+
@io.puts " #{"#{entry[:count]}x".rjust(5)} #{entry[:rule].to_s.ljust(32)} #{source(entry)}"
|
|
151
228
|
end
|
|
152
229
|
end
|
|
153
230
|
|
|
231
|
+
def bar(left, right)
|
|
232
|
+
padding = [ @width - visible(left) - visible(right) - 1, 1 ].max
|
|
233
|
+
return "#{left}#{" " * padding}#{right} " unless @color
|
|
234
|
+
|
|
235
|
+
"\e[7m#{left}#{" " * padding}#{right} \e[0m"
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def feed_limit = (@height - 22).clamp(3, 10)
|
|
239
|
+
def hotspot_limit = (@height - 30).clamp(2, 5)
|
|
240
|
+
|
|
241
|
+
def clip(text, budget)
|
|
242
|
+
budget = [ budget, 12 ].max
|
|
243
|
+
text.length <= budget ? text : "..#{text[-(budget - 2)..]}"
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def visible(text) = text.gsub(/\e\[[0-9;?]*[a-zA-Z]/, "").length
|
|
247
|
+
|
|
154
248
|
def duration(milliseconds)
|
|
155
249
|
value = milliseconds.to_f
|
|
156
250
|
return "#{value.round}ms" if value < 1000
|
|
@@ -159,21 +253,15 @@ module Txray
|
|
|
159
253
|
end
|
|
160
254
|
|
|
161
255
|
def time(event) = Time.at(event[:at].to_f).strftime("%H:%M:%S")
|
|
256
|
+
def source(event) = event[:source].to_s.sub(/:in\s+[`'"](.+)[`'"]\z/, " \\1")
|
|
257
|
+
def label(text) = dim(text.ljust(6))
|
|
162
258
|
|
|
163
259
|
def clock(seconds)
|
|
164
260
|
format("%02d:%02d:%02d", seconds / 3600, (seconds % 3600) / 60, seconds % 60)
|
|
165
261
|
end
|
|
166
262
|
|
|
167
|
-
def
|
|
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"
|
|
263
|
+
def paint(text, color) = @color ? "\e[#{color}m#{text}\e[0m" : text
|
|
264
|
+
def dim(text) = @color ? "\e[2m#{text}\e[0m" : text
|
|
177
265
|
end
|
|
178
266
|
end
|
|
179
267
|
end
|
data/lib/txray/reporters/text.rb
CHANGED
|
@@ -1,44 +1,93 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "io/console"
|
|
4
|
+
|
|
3
5
|
module Txray
|
|
4
6
|
module Reporters
|
|
5
7
|
class Text
|
|
6
|
-
COLORS = { high:
|
|
8
|
+
COLORS = { high: 91, medium: 93, low: 96 }.freeze
|
|
7
9
|
|
|
8
|
-
def initialize(io: $stdout, color:
|
|
10
|
+
def initialize(io: $stdout, color: Reporters.color?(io))
|
|
9
11
|
@io = io
|
|
10
12
|
@color = color
|
|
13
|
+
@width = width
|
|
11
14
|
end
|
|
12
15
|
|
|
13
16
|
def report(result)
|
|
14
|
-
result.offenses.group_by(&:path).
|
|
15
|
-
@io.puts
|
|
16
|
-
offenses
|
|
17
|
-
|
|
17
|
+
result.offenses.group_by(&:path).each_with_index do |(path, offenses), index|
|
|
18
|
+
@io.puts if index.positive?
|
|
19
|
+
heading(path, offenses)
|
|
20
|
+
offenses.each_with_index do |offense, position|
|
|
21
|
+
@io.puts if position.positive?
|
|
22
|
+
offense_body(offense)
|
|
23
|
+
end
|
|
18
24
|
end
|
|
19
|
-
|
|
25
|
+
|
|
26
|
+
@io.puts
|
|
27
|
+
summary(result)
|
|
20
28
|
end
|
|
21
29
|
|
|
22
30
|
private
|
|
23
31
|
|
|
24
|
-
def
|
|
25
|
-
|
|
26
|
-
@
|
|
27
|
-
|
|
28
|
-
|
|
32
|
+
def heading(path, offenses)
|
|
33
|
+
count = "#{offenses.size} #{offenses.size == 1 ? "offense" : "offenses"}"
|
|
34
|
+
leader = "·" * [ @width - path.length - count.length - 2, 3 ].max
|
|
35
|
+
@io.puts "#{bold(path)} #{dim(leader)} #{dim(count)}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def offense_body(offense)
|
|
39
|
+
@io.puts " #{location(offense)} #{tint(offense.severity.to_s.ljust(6), offense.severity)} " \
|
|
40
|
+
"#{tint(offense.id, offense.severity)}"
|
|
41
|
+
paragraph(offense.message, " ", 6).each { |line| @io.puts line }
|
|
42
|
+
offense.trace.each { |frame| @io.puts dim(" └ via #{frame}") }
|
|
43
|
+
paragraph(offense.rule.remedy, " → ", 8).each { |line| @io.puts dim(line) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def paragraph(text, prefix, indent)
|
|
47
|
+
lines = fold(text, [ @width - indent, 24 ].max)
|
|
48
|
+
[ "#{prefix}#{lines.first}", *lines.drop(1).map { |line| "#{" " * indent}#{line}" } ]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def fold(text, budget)
|
|
52
|
+
text.split.each_with_object([ "" ]) do |word, lines|
|
|
53
|
+
if lines.last.empty?
|
|
54
|
+
lines[-1] = word
|
|
55
|
+
elsif lines.last.length + 1 + word.length <= budget
|
|
56
|
+
lines[-1] = "#{lines.last} #{word}"
|
|
57
|
+
else
|
|
58
|
+
lines << word
|
|
59
|
+
end
|
|
60
|
+
end
|
|
29
61
|
end
|
|
30
62
|
|
|
31
|
-
def
|
|
63
|
+
def location(offense) = "#{offense.line}:#{offense.column}".ljust(7)
|
|
64
|
+
|
|
65
|
+
def summary(result)
|
|
32
66
|
counts = result.counts
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
67
|
+
parts = [ "#{result.files.size} files scanned", "#{result.offenses.size} offenses" ]
|
|
68
|
+
parts += Offense::SEVERITIES.reverse.filter_map do |severity|
|
|
69
|
+
tint("#{counts[severity]} #{severity}", severity) if counts[severity]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
@io.puts dim("─" * @width)
|
|
73
|
+
@io.puts parts.join(dim(" · "))
|
|
74
|
+
skipped(result)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def skipped(result)
|
|
36
78
|
return if result.skipped.to_a.empty?
|
|
37
79
|
|
|
38
80
|
@io.puts dim("#{result.skipped.size} files could not be parsed and were skipped:")
|
|
39
81
|
result.skipped.each { |path| @io.puts dim(" #{path}") }
|
|
40
82
|
end
|
|
41
83
|
|
|
84
|
+
def width
|
|
85
|
+
columns = IO.console&.winsize&.last
|
|
86
|
+
(columns.to_i.positive? ? columns : 88).clamp(40, 100)
|
|
87
|
+
rescue StandardError
|
|
88
|
+
88
|
|
89
|
+
end
|
|
90
|
+
|
|
42
91
|
def tint(text, severity) = @color ? "\e[#{COLORS.fetch(severity, 0)}m#{text}\e[0m" : text
|
|
43
92
|
def bold(text) = @color ? "\e[1m#{text}\e[0m" : text
|
|
44
93
|
def dim(text) = @color ? "\e[2m#{text}\e[0m" : text
|
data/lib/txray/reporters.rb
CHANGED
|
@@ -8,6 +8,10 @@ require_relative "reporters/live"
|
|
|
8
8
|
|
|
9
9
|
module Txray
|
|
10
10
|
module Reporters
|
|
11
|
+
def self.color?(io)
|
|
12
|
+
ENV["NO_COLOR"].to_s.empty? && io.respond_to?(:tty?) && io.tty?
|
|
13
|
+
end
|
|
14
|
+
|
|
11
15
|
FORMATS = { "text" => Text, "json" => Json, "sarif" => Sarif, "github" => Github }.freeze
|
|
12
16
|
|
|
13
17
|
def self.build(format, io: $stdout)
|
data/lib/txray/runtime.rb
CHANGED
|
@@ -89,6 +89,22 @@ module Txray
|
|
|
89
89
|
@sink.write(event)
|
|
90
90
|
end
|
|
91
91
|
|
|
92
|
+
def open_transaction
|
|
93
|
+
Stack.push(app_backtrace.first)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def close_transaction(outcome)
|
|
97
|
+
transaction = Stack.pop
|
|
98
|
+
return if transaction.nil? || ignoring?
|
|
99
|
+
|
|
100
|
+
duration = transaction.duration_ms
|
|
101
|
+
slow = duration >= @options[:threshold_ms]
|
|
102
|
+
return unless slow || transaction.violations.any?
|
|
103
|
+
|
|
104
|
+
report(transaction.to_event(outcome || :commit))
|
|
105
|
+
announce_slow(duration) if slow
|
|
106
|
+
end
|
|
107
|
+
|
|
92
108
|
private
|
|
93
109
|
|
|
94
110
|
def announce(rule, message)
|
|
@@ -121,31 +137,25 @@ module Txray
|
|
|
121
137
|
end
|
|
122
138
|
|
|
123
139
|
def watch_transactions
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
140
|
+
if notifies_transaction_start?
|
|
141
|
+
subscribe("start_transaction.active_record") { open_transaction }
|
|
142
|
+
subscribe("transaction.active_record") { |event| close_transaction(event.payload[:outcome]) }
|
|
143
|
+
else
|
|
144
|
+
patch_transaction_manager
|
|
145
|
+
end
|
|
130
146
|
end
|
|
131
147
|
|
|
132
|
-
def
|
|
133
|
-
|
|
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?
|
|
148
|
+
def notifies_transaction_start?
|
|
149
|
+
return false unless defined?(ActiveRecord::VERSION::STRING)
|
|
140
150
|
|
|
141
|
-
|
|
142
|
-
report((transaction&.to_event(outcome) || fallback_event(duration, outcome)).merge(duration_ms: duration))
|
|
143
|
-
announce_slow(duration) if slow
|
|
151
|
+
Gem::Version.new(ActiveRecord::VERSION::STRING) >= Gem::Version.new("7.2")
|
|
144
152
|
end
|
|
145
153
|
|
|
146
|
-
def
|
|
147
|
-
|
|
148
|
-
|
|
154
|
+
def patch_transaction_manager
|
|
155
|
+
return if @transactions_patched
|
|
156
|
+
|
|
157
|
+
ActiveRecord::ConnectionAdapters::DatabaseStatements.prepend(TransactionTimer)
|
|
158
|
+
@transactions_patched = true
|
|
149
159
|
end
|
|
150
160
|
|
|
151
161
|
def announce_slow(duration)
|
|
@@ -179,6 +189,23 @@ module Txray
|
|
|
179
189
|
end
|
|
180
190
|
end
|
|
181
191
|
|
|
192
|
+
module TransactionTimer
|
|
193
|
+
def within_new_transaction(*, **, &)
|
|
194
|
+
return super unless Txray::Runtime.installed?
|
|
195
|
+
|
|
196
|
+
Txray::Runtime.open_transaction
|
|
197
|
+
outcome = :commit
|
|
198
|
+
begin
|
|
199
|
+
super
|
|
200
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
201
|
+
outcome = :rollback
|
|
202
|
+
raise
|
|
203
|
+
ensure
|
|
204
|
+
Txray::Runtime.close_transaction(outcome)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
182
209
|
module NetHttpGuard
|
|
183
210
|
def request(req, body = nil, &)
|
|
184
211
|
return super unless Txray::Runtime.transaction_open?
|