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.
@@ -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