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.
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[:duration_ms].to_f >= @threshold_ms }
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 do |offense|
14
- level = LEVELS.fetch(offense.severity, "warning")
15
- title = "txray: #{offense.id}"
16
- @io.puts "::#{level} file=#{offense.path},line=#{offense.line},col=#{offense.column},title=#{title}::#{offense.message}"
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
@@ -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
- header(monitor)
31
- stats(monitor)
34
+ title(monitor)
35
+ meters(monitor)
36
+ histogram(monitor)
32
37
  feed(monitor)
33
38
  hotspots(monitor)
34
- @io.print(dim(" ctrl-c to stop"))
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 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
+ 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 header(monitor)
55
- @io.puts
56
- @io.puts " #{bold(magenta("txray"))} #{dim("watching #{@path}")}#{" " * 2}#{dim(clock(monitor.uptime))}"
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 stats(monitor)
61
- if monitor.empty?
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 " #{label("transactions")}#{count(monitor)}"
68
- @io.puts " #{label("duration")}#{spread(monitor)}"
69
- @io.puts " #{label("")}#{sparkline(monitor)}"
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 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(" "))
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 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(" "))
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 sparkline(monitor)
86
- values = monitor.durations.last(48)
87
- return dim("-") if values.empty?
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
- peak = [ values.max, 1.0 ].max
90
- values.map { |value| SPARKS[((value / peak) * (SPARKS.size - 1)).round] }.join
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 " #{dim("LIVE")}"
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
- 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")
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
- " #{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))}"
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 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))}"
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 duration_suffix(violation)
127
- "(#{duration(violation[:duration_ms])})" if violation[:duration_ms]
197
+ def severity_color(rule)
198
+ SEVERITY_COLORS.fetch(Rules.all[rule]&.severity, 95)
128
199
  end
129
200
 
130
- def source(event) = event[:source].to_s.sub(/:in\s+[`'"](.+)[`'"]\z/, " \\1")
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(5)
206
+ rows = monitor.hotspots(hotspot_limit)
134
207
  return if rows.empty?
135
208
 
136
- @io.puts " #{dim("HOTSPOTS")}"
209
+ @io.puts bar(" HOTSPOTS", "")
137
210
  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))}"
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
- @io.puts " #{monitor.transactions.size} transactions, #{monitor.slow} slow, " \
148
- "#{monitor.findings.size} findings over #{clock(monitor.uptime)}"
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(4)} #{entry[:rule].to_s.ljust(32)} #{source(entry)}"
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 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"
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
@@ -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: 31, medium: 33, low: 36 }.freeze
8
+ COLORS = { high: 91, medium: 93, low: 96 }.freeze
7
9
 
8
- def initialize(io: $stdout, color: io.tty?)
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).each do |path, offenses|
15
- @io.puts bold(path)
16
- offenses.each { |offense| print_offense(offense) }
17
- @io.puts
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
- print_summary(result)
25
+
26
+ @io.puts
27
+ summary(result)
20
28
  end
21
29
 
22
30
  private
23
31
 
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}")
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 print_summary(result)
63
+ def location(offense) = "#{offense.line}:#{offense.column}".ljust(7)
64
+
65
+ def summary(result)
32
66
  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?}"
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
@@ -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
- 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)
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 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?
148
+ def notifies_transaction_start?
149
+ return false unless defined?(ActiveRecord::VERSION::STRING)
140
150
 
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
151
+ Gem::Version.new(ActiveRecord::VERSION::STRING) >= Gem::Version.new("7.2")
144
152
  end
145
153
 
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: [] }
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?