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
data/lib/txray/cli.rb
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module Txray
|
|
6
|
+
class CLI
|
|
7
|
+
EXIT_CLEAN = 0
|
|
8
|
+
EXIT_OFFENSES = 1
|
|
9
|
+
EXIT_ERROR = 2
|
|
10
|
+
|
|
11
|
+
def self.start(argv, io: $stdout) = new(io: io).run(argv)
|
|
12
|
+
|
|
13
|
+
def initialize(io: $stdout)
|
|
14
|
+
@io = io
|
|
15
|
+
@options = {}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def run(argv)
|
|
19
|
+
paths = parser.parse(argv)
|
|
20
|
+
return init_config if @options[:init]
|
|
21
|
+
return watch if paths.first == "watch"
|
|
22
|
+
|
|
23
|
+
config = Config.load(@options[:config]).merge(
|
|
24
|
+
"fail_level" => @options[:fail_level],
|
|
25
|
+
"disabled_rules" => @options[:disabled_rules],
|
|
26
|
+
"max_depth" => @options[:max_depth]
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
result = Scanner.new(config, paths: paths).run
|
|
30
|
+
result = filter(result)
|
|
31
|
+
Reporters.build(@options.fetch(:format, "text"), io: @io).report(result)
|
|
32
|
+
result.failing?(config.fail_level) ? EXIT_OFFENSES : EXIT_CLEAN
|
|
33
|
+
rescue OptionParser::ParseError, Error => e
|
|
34
|
+
@io.puts "txray: #{e.message}"
|
|
35
|
+
EXIT_ERROR
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def filter(result)
|
|
41
|
+
return result unless @options[:only]
|
|
42
|
+
|
|
43
|
+
Result.new(offenses: result.offenses.select { |offense| @options[:only].include?(offense.id) },
|
|
44
|
+
files: result.files, skipped: result.skipped)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def watch
|
|
48
|
+
runtime = Config.load(@options[:config]).runtime
|
|
49
|
+
WatchCommand.new(
|
|
50
|
+
path: @options[:file] || runtime["log_path"],
|
|
51
|
+
threshold_ms: (@options[:threshold] || runtime["threshold_ms"]).to_i,
|
|
52
|
+
io: @io,
|
|
53
|
+
from_start: @options[:from_start] == true
|
|
54
|
+
).run
|
|
55
|
+
EXIT_CLEAN
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def init_config
|
|
59
|
+
if File.exist?(Config::FILENAME)
|
|
60
|
+
@io.puts "#{Config::FILENAME} already exists"
|
|
61
|
+
return EXIT_ERROR
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
File.write(Config::FILENAME, YAML.dump(Config::DEFAULTS))
|
|
65
|
+
@io.puts "created #{Config::FILENAME}"
|
|
66
|
+
EXIT_CLEAN
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def parser
|
|
70
|
+
OptionParser.new do |opts|
|
|
71
|
+
opts.banner = "Usage: txray [options] [paths]\n txray watch [options]"
|
|
72
|
+
opts.on("-f", "--format FORMAT", "text, json, sarif or github (default: text)") { |v| @options[:format] = v }
|
|
73
|
+
opts.on("-c", "--config PATH", "path to a .txray.yml file") { |v| @options[:config] = v }
|
|
74
|
+
opts.on("--fail-level LEVEL", "low, medium, high or none (default: low)") { |v| @options[:fail_level] = v }
|
|
75
|
+
opts.on("--only RULES", Array, "report only these rule ids") { |v| @options[:only] = v }
|
|
76
|
+
opts.on("--except RULES", Array, "skip these rule ids") { |v| @options[:disabled_rules] = v }
|
|
77
|
+
opts.on("--depth N", Integer, "how far to follow method calls (default: 3)") { |v| @options[:max_depth] = v }
|
|
78
|
+
opts.on("--rules", "list every rule and exit") { list_rules }
|
|
79
|
+
opts.on("--init", "write a default .txray.yml") { @options[:init] = true }
|
|
80
|
+
opts.on("--file PATH", "watch: event log written by the runtime guard") { |v| @options[:file] = v }
|
|
81
|
+
opts.on("--threshold MS", Integer, "watch: slow transaction threshold") { |v| @options[:threshold] = v }
|
|
82
|
+
opts.on("--from-start", "watch: replay the existing log first") { @options[:from_start] = true }
|
|
83
|
+
opts.on("-v", "--version", "print the version") { print_and_exit(VERSION) }
|
|
84
|
+
opts.on("-h", "--help", "print this help") { print_and_exit(opts.to_s) }
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def list_rules
|
|
89
|
+
Rules.all.each_value { |rule| @io.puts "#{rule.id.ljust(34)} #{rule.severity.to_s.ljust(7)} #{rule.remedy}" }
|
|
90
|
+
exit EXIT_CLEAN
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def print_and_exit(text)
|
|
94
|
+
@io.puts text
|
|
95
|
+
exit EXIT_CLEAN
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Txray
|
|
4
|
+
class ClientIndex
|
|
5
|
+
RETURN_WRITES = [
|
|
6
|
+
Prism::InstanceVariableOrWriteNode, Prism::InstanceVariableWriteNode,
|
|
7
|
+
Prism::LocalVariableOrWriteNode, Prism::LocalVariableWriteNode,
|
|
8
|
+
Prism::ConstantOrWriteNode, Prism::ConstantWriteNode
|
|
9
|
+
].freeze
|
|
10
|
+
|
|
11
|
+
def initialize(classifier)
|
|
12
|
+
@classifier = classifier
|
|
13
|
+
@locals = {}
|
|
14
|
+
@ivars = {}
|
|
15
|
+
@constants = {}
|
|
16
|
+
@methods = {}
|
|
17
|
+
@delegations = {}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def index(source)
|
|
21
|
+
Namespaces.each(source.root) do |namespace, node|
|
|
22
|
+
case node
|
|
23
|
+
when Prism::LocalVariableWriteNode then bind(@locals, source.path, node.name, node.value)
|
|
24
|
+
when Prism::InstanceVariableWriteNode, Prism::InstanceVariableOrWriteNode
|
|
25
|
+
bind(@ivars, namespace, node.name, node.value)
|
|
26
|
+
when Prism::ConstantWriteNode then bind_constant(namespace, node)
|
|
27
|
+
when Prism::DefNode then bind_method(namespace, node)
|
|
28
|
+
when Prism::CallNode then bind_delegation(namespace, node)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def kind_of(node, context, depth = 0)
|
|
34
|
+
return nil if node.nil? || depth > 4
|
|
35
|
+
|
|
36
|
+
case node
|
|
37
|
+
when Prism::LocalVariableReadNode then @locals.dig(context.path, node.name)
|
|
38
|
+
when Prism::InstanceVariableReadNode then @ivars.dig(context.namespace, node.name)
|
|
39
|
+
when Prism::ConstantReadNode, Prism::ConstantPathNode then constant_kind(node, context.namespace)
|
|
40
|
+
when Prism::CallNode then call_kind(node, context, depth)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def delegated_kind(namespace, name, depth = 0)
|
|
45
|
+
target = @delegations.dig(namespace, name)
|
|
46
|
+
return nil if target.nil? || depth > 4
|
|
47
|
+
|
|
48
|
+
@methods.dig(namespace, target) || delegated_kind(namespace, target, depth + 1)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def call_kind(node, context, depth)
|
|
54
|
+
return @methods.dig(context.namespace, node.name) if node.receiver.nil?
|
|
55
|
+
|
|
56
|
+
constructed_kind(node) || kind_of(node.receiver, context, depth + 1)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def constructed_kind(node)
|
|
60
|
+
@classifier.constructor?(node) ? @classifier.constant_rule(NodeHelpers.constant_name(node.receiver)) : nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def constant_kind(node, namespace)
|
|
64
|
+
name = NodeHelpers.constant_name(node).to_s
|
|
65
|
+
@constants[qualify(namespace, name)] || @constants[name]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def qualify(namespace, name) = namespace.to_s.empty? ? name.to_s : "#{namespace}::#{name}"
|
|
69
|
+
|
|
70
|
+
def bind(table, scope, name, value)
|
|
71
|
+
kind = source_kind(value)
|
|
72
|
+
(table[scope] ||= {})[name] = kind if kind
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def bind_constant(namespace, node)
|
|
76
|
+
kind = source_kind(node.value)
|
|
77
|
+
@constants[qualify(namespace, node.name)] = kind if kind
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def bind_method(namespace, node)
|
|
81
|
+
kind = returned_kind(node.body)
|
|
82
|
+
(@methods[namespace] ||= {})[node.name] = kind if kind
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def returned_kind(body)
|
|
86
|
+
node = body.is_a?(Prism::StatementsNode) ? body.body.last : body
|
|
87
|
+
return source_kind(node.value) if RETURN_WRITES.any? { |type| node.is_a?(type) }
|
|
88
|
+
|
|
89
|
+
source_kind(node)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def bind_delegation(namespace, call)
|
|
93
|
+
return unless call.name == :delegate && call.receiver.nil?
|
|
94
|
+
|
|
95
|
+
target = delegation_target(call)
|
|
96
|
+
return if target.nil?
|
|
97
|
+
|
|
98
|
+
NodeHelpers.symbol_arguments(call).each { |name| (@delegations[namespace] ||= {})[name] = target }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def delegation_target(call)
|
|
102
|
+
hash = NodeHelpers.positional_arguments(call).grep(Prism::KeywordHashNode).first
|
|
103
|
+
return nil if hash.nil?
|
|
104
|
+
|
|
105
|
+
value = hash.elements.grep(Prism::AssocNode).find { |element| key_name(element) == "to" }&.value
|
|
106
|
+
value.unescaped.to_sym if value.is_a?(Prism::SymbolNode)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def key_name(element)
|
|
110
|
+
element.key.unescaped if element.key.respond_to?(:unescaped)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def source_kind(node)
|
|
114
|
+
return nil unless node.is_a?(Prism::CallNode) && @classifier.constructor?(node)
|
|
115
|
+
|
|
116
|
+
@classifier.constant_rule(NodeHelpers.constant_name(node.receiver))
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
data/lib/txray/config.rb
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "pathname"
|
|
5
|
+
|
|
6
|
+
module Txray
|
|
7
|
+
class Config
|
|
8
|
+
FILENAME = ".txray.yml"
|
|
9
|
+
|
|
10
|
+
DEFAULTS = {
|
|
11
|
+
"include" => [ "app", "lib", "db/migrate" ],
|
|
12
|
+
"exclude" => %w[spec test vendor node_modules tmp log .git],
|
|
13
|
+
"max_depth" => 3,
|
|
14
|
+
"fail_level" => "low",
|
|
15
|
+
"disabled_rules" => [],
|
|
16
|
+
"external_clients" => [],
|
|
17
|
+
"severities" => {},
|
|
18
|
+
"runtime" => {
|
|
19
|
+
"enabled" => false,
|
|
20
|
+
"threshold_ms" => 250,
|
|
21
|
+
"on_violation" => "log",
|
|
22
|
+
"log_path" => "tmp/txray.ndjson",
|
|
23
|
+
"ignore" => [],
|
|
24
|
+
"guard_http" => true,
|
|
25
|
+
"guard_jobs" => true,
|
|
26
|
+
"guard_mail" => true
|
|
27
|
+
}
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
def self.discover(from = Dir.pwd)
|
|
31
|
+
Pathname.new(from).ascend do |directory|
|
|
32
|
+
candidate = directory.join(FILENAME)
|
|
33
|
+
return candidate.to_s if candidate.file?
|
|
34
|
+
end
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.load(path = nil)
|
|
39
|
+
raise Error, "no such config file: #{path}" if path && !File.exist?(path)
|
|
40
|
+
|
|
41
|
+
path ||= discover
|
|
42
|
+
data = path ? YAML.safe_load_file(path) : {}
|
|
43
|
+
new(data.is_a?(Hash) ? data : {})
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def initialize(data = {})
|
|
47
|
+
@data = DEFAULTS.merge(data) { |_key, default, given| default.is_a?(Hash) ? default.merge(given.to_h) : given }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def includes = Array(@data["include"])
|
|
51
|
+
def excludes = Array(@data["exclude"])
|
|
52
|
+
def max_depth = Integer(@data["max_depth"])
|
|
53
|
+
def external_clients = Array(@data["external_clients"]).map(&:to_s)
|
|
54
|
+
def disabled_rules = Array(@data["disabled_rules"]).map(&:to_s)
|
|
55
|
+
def runtime = @data["runtime"].transform_keys(&:to_s)
|
|
56
|
+
|
|
57
|
+
def fail_level = @data["fail_level"].to_s.to_sym
|
|
58
|
+
|
|
59
|
+
def rule_enabled?(id) = !disabled_rules.include?(id.to_s)
|
|
60
|
+
|
|
61
|
+
def rule(id)
|
|
62
|
+
severity = @data["severities"].to_h[id.to_s]
|
|
63
|
+
base = Rules[id]
|
|
64
|
+
return base unless severity
|
|
65
|
+
|
|
66
|
+
base.dup.tap { |copy| copy.severity = severity.to_sym }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def merge(overrides)
|
|
70
|
+
self.class.new(@data.merge(overrides.compact.transform_keys(&:to_s)))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def to_h = @data
|
|
74
|
+
end
|
|
75
|
+
end
|
data/lib/txray/error.rb
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Txray
|
|
4
|
+
MethodEntry = Struct.new(:namespace, :name, :node, :source, :singleton, keyword_init: true) do
|
|
5
|
+
def label = "#{namespace}#{singleton ? "." : "#"}#{name}"
|
|
6
|
+
def line = node.location.start_line
|
|
7
|
+
def path = source.path
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
class MethodIndex
|
|
11
|
+
MAX_RESOLUTION_DEPTH = 4
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@instance = {}
|
|
15
|
+
@singleton = {}
|
|
16
|
+
@includes = {}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def index(source)
|
|
20
|
+
Namespaces.each(source.root) do |namespace, node|
|
|
21
|
+
case node
|
|
22
|
+
when Prism::DefNode then add_method(namespace, node, source)
|
|
23
|
+
when Prism::CallNode
|
|
24
|
+
add_include(namespace, node)
|
|
25
|
+
add_defined_method(namespace, node, source)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def unique(name)
|
|
31
|
+
matches = @instance.each_value.filter_map { |methods| methods[name] }
|
|
32
|
+
matches.first if matches.size == 1
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def lookup(namespace, name, singleton: false, depth: 0)
|
|
36
|
+
return nil if namespace.nil? || namespace.empty? || depth > MAX_RESOLUTION_DEPTH
|
|
37
|
+
|
|
38
|
+
table = singleton ? @singleton : @instance
|
|
39
|
+
direct = table.dig(namespace, name)
|
|
40
|
+
return direct if direct
|
|
41
|
+
|
|
42
|
+
from_modules(namespace, name, singleton, depth) || from_lexical_parent(namespace, name, singleton, depth)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def add_method(namespace, node, source)
|
|
48
|
+
singleton = !node.receiver.nil?
|
|
49
|
+
table = singleton ? @singleton : @instance
|
|
50
|
+
entry = MethodEntry.new(namespace: namespace, name: node.name.to_sym, node: node, source: source,
|
|
51
|
+
singleton: singleton)
|
|
52
|
+
(table[namespace] ||= {})[entry.name] ||= entry
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def add_defined_method(namespace, call, source)
|
|
56
|
+
return unless call.receiver.nil? && call.name == :define_method && call.block.is_a?(Prism::BlockNode)
|
|
57
|
+
|
|
58
|
+
name = NodeHelpers.symbol_arguments(call).first
|
|
59
|
+
return if name.nil?
|
|
60
|
+
|
|
61
|
+
entry = MethodEntry.new(namespace: namespace, name: name, node: call.block, source: source, singleton: false)
|
|
62
|
+
(@instance[namespace] ||= {})[name] ||= entry
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def add_include(namespace, node)
|
|
66
|
+
return unless node.receiver.nil? && %i[include prepend extend].include?(node.name)
|
|
67
|
+
|
|
68
|
+
NodeHelpers.positional_arguments(node).each do |argument|
|
|
69
|
+
name = NodeHelpers.constant_name(argument)
|
|
70
|
+
(@includes[namespace] ||= []) << name if name
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def from_modules(namespace, name, singleton, depth)
|
|
75
|
+
@includes.fetch(namespace, []).each do |mod|
|
|
76
|
+
resolved = resolve_namespace(namespace, mod)
|
|
77
|
+
entry = resolved && lookup(resolved, name, singleton: singleton, depth: depth + 1)
|
|
78
|
+
return entry if entry
|
|
79
|
+
end
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def from_lexical_parent(namespace, name, singleton, depth)
|
|
84
|
+
parent = namespace.split("::")[0..-2].join("::")
|
|
85
|
+
parent.empty? ? nil : lookup(parent, name, singleton: singleton, depth: depth + 1)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def resolve_namespace(from, mod)
|
|
89
|
+
segments = from.split("::")
|
|
90
|
+
candidates = segments.each_index.map { |i| (segments[0..i] + [ mod ]).join("::") }.reverse
|
|
91
|
+
(candidates + [ mod ]).find { |candidate| known?(candidate) }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def known?(namespace)
|
|
95
|
+
@instance.key?(namespace) || @singleton.key?(namespace) || @includes.key?(namespace)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
module Namespaces
|
|
100
|
+
module_function
|
|
101
|
+
|
|
102
|
+
def each(node, namespace = "", &block)
|
|
103
|
+
return if node.nil?
|
|
104
|
+
|
|
105
|
+
case node
|
|
106
|
+
when Prism::ClassNode, Prism::ModuleNode
|
|
107
|
+
child = join(namespace, NodeHelpers.constant_name(node.constant_path))
|
|
108
|
+
block.call(child, node)
|
|
109
|
+
each(node.body, child, &block)
|
|
110
|
+
when Prism::SingletonClassNode
|
|
111
|
+
each(node.body, namespace, &block)
|
|
112
|
+
else
|
|
113
|
+
block.call(namespace, node)
|
|
114
|
+
node.compact_child_nodes.each { |c| each(c, namespace, &block) }
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def join(namespace, name)
|
|
119
|
+
return namespace if name.nil?
|
|
120
|
+
|
|
121
|
+
namespace.empty? ? name : "#{namespace}::#{name}"
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Txray
|
|
6
|
+
class Monitor
|
|
7
|
+
CAP = 2000
|
|
8
|
+
|
|
9
|
+
attr_reader :transactions, :violations, :started_at
|
|
10
|
+
|
|
11
|
+
def initialize(threshold_ms: 250)
|
|
12
|
+
@threshold_ms = threshold_ms
|
|
13
|
+
@transactions = []
|
|
14
|
+
@violations = []
|
|
15
|
+
@recent = []
|
|
16
|
+
@started_at = Time.now
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def absorb(line)
|
|
20
|
+
event = JSON.parse(line, symbolize_names: true)
|
|
21
|
+
case event[:type]
|
|
22
|
+
when "transaction" then add(@transactions, event)
|
|
23
|
+
when "violation" then add(@violations, event)
|
|
24
|
+
else return nil
|
|
25
|
+
end
|
|
26
|
+
@recent.unshift(event)
|
|
27
|
+
@recent.pop while @recent.size > 40
|
|
28
|
+
event
|
|
29
|
+
rescue JSON::ParserError
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def recent(limit) = @recent.first(limit)
|
|
34
|
+
def durations = @transactions.map { |event| event[:duration_ms].to_f }
|
|
35
|
+
def slow = @transactions.count { |event| event[:duration_ms].to_f >= @threshold_ms }
|
|
36
|
+
def flagged = @transactions.count { |event| event[:violations].to_a.any? }
|
|
37
|
+
def uptime = Time.now - @started_at
|
|
38
|
+
def empty? = @transactions.empty? && @violations.empty?
|
|
39
|
+
|
|
40
|
+
def percentile(fraction)
|
|
41
|
+
values = durations.sort
|
|
42
|
+
return 0.0 if values.empty?
|
|
43
|
+
|
|
44
|
+
values[[ (values.size * fraction).ceil - 1, 0 ].max]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def findings
|
|
48
|
+
nested = @transactions.flat_map { |event| event[:violations].to_a }
|
|
49
|
+
slow = @transactions.select { |event| event[:duration_ms].to_f >= @threshold_ms }
|
|
50
|
+
.map { |event| { rule: "slow-transaction", source: event[:source] } }
|
|
51
|
+
@violations + nested + slow
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def hotspots(limit)
|
|
55
|
+
findings.group_by { |event| [ event[:rule], event[:source] ] }
|
|
56
|
+
.map { |(rule, source), events| { rule: rule, source: source, count: events.size } }
|
|
57
|
+
.sort_by { |entry| -entry[:count] }
|
|
58
|
+
.first(limit)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def add(collection, event)
|
|
64
|
+
collection << event
|
|
65
|
+
collection.shift while collection.size > CAP
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Txray
|
|
4
|
+
module NodeHelpers
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def each_node(node, &block)
|
|
8
|
+
return if node.nil?
|
|
9
|
+
|
|
10
|
+
block.call(node)
|
|
11
|
+
node.compact_child_nodes.each { |child| each_node(child, &block) }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def constant_name(node)
|
|
15
|
+
case node
|
|
16
|
+
when Prism::ConstantReadNode
|
|
17
|
+
node.name.to_s
|
|
18
|
+
when Prism::ConstantPathNode
|
|
19
|
+
[ constant_name(node.parent), constant_path_child(node) ].compact.reject(&:empty?).join("::")
|
|
20
|
+
when Prism::SelfNode
|
|
21
|
+
"self"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def constant_path_child(node)
|
|
26
|
+
return node.name.to_s if node.respond_to?(:name) && node.name
|
|
27
|
+
return constant_name(node.child) if node.respond_to?(:child)
|
|
28
|
+
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def symbol_arguments(call)
|
|
33
|
+
positional_arguments(call).grep(Prism::SymbolNode).map { |symbol| symbol.unescaped.to_sym }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def positional_arguments(call)
|
|
37
|
+
call.arguments&.arguments.to_a
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def block_body(call)
|
|
41
|
+
call.block.body if call.block.is_a?(Prism::BlockNode)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def receiver_name(call)
|
|
45
|
+
constant_name(call.receiver) || dynamic_receiver_name(call.receiver)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def dynamic_receiver_name(node)
|
|
49
|
+
case node
|
|
50
|
+
when Prism::CallNode
|
|
51
|
+
[ receiver_name(node), node.name ].compact.join(".")
|
|
52
|
+
when Prism::InstanceVariableReadNode, Prism::GlobalVariableReadNode, Prism::LocalVariableReadNode
|
|
53
|
+
node.name.to_s
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def snippet(node, limit = 90)
|
|
58
|
+
text = node.slice.to_s.lines.first.to_s.strip
|
|
59
|
+
text.length > limit ? "#{text[0, limit - 3]}..." : text
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Txray
|
|
4
|
+
Frame = Struct.new(:label, :path, :line, keyword_init: true) do
|
|
5
|
+
def to_s = "#{label} (#{path}:#{line})"
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
class Offense
|
|
9
|
+
SEVERITIES = %i[low medium high].freeze
|
|
10
|
+
|
|
11
|
+
attr_reader :rule, :path, :line, :column, :snippet, :scope, :trace
|
|
12
|
+
|
|
13
|
+
def initialize(rule:, path:, line:, column:, snippet:, scope:, trace: [])
|
|
14
|
+
@rule = rule
|
|
15
|
+
@path = path
|
|
16
|
+
@line = line
|
|
17
|
+
@column = column
|
|
18
|
+
@snippet = snippet
|
|
19
|
+
@scope = scope
|
|
20
|
+
@trace = trace
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def id = rule.id
|
|
24
|
+
def severity = rule.severity
|
|
25
|
+
def severity_rank = SEVERITIES.index(severity) || 0
|
|
26
|
+
def location = "#{path}:#{line}:#{column}"
|
|
27
|
+
def message = format(rule.message, snippet: snippet, scope: scope.label)
|
|
28
|
+
|
|
29
|
+
def key = [ rule.id, path, line, column ]
|
|
30
|
+
|
|
31
|
+
def sort_key = [ -severity_rank, path, line, column ]
|
|
32
|
+
|
|
33
|
+
def to_h
|
|
34
|
+
{
|
|
35
|
+
rule: rule.id,
|
|
36
|
+
severity: severity,
|
|
37
|
+
category: rule.category,
|
|
38
|
+
path: path,
|
|
39
|
+
line: line,
|
|
40
|
+
column: column,
|
|
41
|
+
message: message,
|
|
42
|
+
snippet: snippet,
|
|
43
|
+
scope: { kind: scope.kind, label: scope.label, path: scope.path, line: scope.line },
|
|
44
|
+
trace: trace.map { |frame| { label: frame.label, path: frame.path, line: frame.line } },
|
|
45
|
+
remedy: rule.remedy
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Txray
|
|
4
|
+
class Railtie < ::Rails::Railtie
|
|
5
|
+
config.txray = ActiveSupport::OrderedOptions.new
|
|
6
|
+
|
|
7
|
+
rake_tasks { load File.expand_path("tasks.rake", __dir__) }
|
|
8
|
+
|
|
9
|
+
initializer "txray.runtime" do |app|
|
|
10
|
+
settings = Config.load.runtime.merge(app.config.txray.to_h.transform_keys(&:to_s))
|
|
11
|
+
next unless settings["enabled"]
|
|
12
|
+
|
|
13
|
+
ActiveSupport.on_load(:active_record) do
|
|
14
|
+
Txray::Runtime.install(
|
|
15
|
+
threshold_ms: settings["threshold_ms"],
|
|
16
|
+
on_violation: settings["on_violation"],
|
|
17
|
+
guard_http: settings["guard_http"],
|
|
18
|
+
guard_jobs: settings["guard_jobs"],
|
|
19
|
+
guard_mail: settings["guard_mail"],
|
|
20
|
+
log_path: settings["log_path"],
|
|
21
|
+
ignore: settings["ignore"]
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Txray
|
|
4
|
+
module Reporters
|
|
5
|
+
class Github
|
|
6
|
+
LEVELS = { high: "error", medium: "warning", low: "notice" }.freeze
|
|
7
|
+
|
|
8
|
+
def initialize(io: $stdout)
|
|
9
|
+
@io = io
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
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}"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Txray
|
|
6
|
+
module Reporters
|
|
7
|
+
class Json
|
|
8
|
+
def initialize(io: $stdout)
|
|
9
|
+
@io = io
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def report(result)
|
|
13
|
+
@io.puts JSON.pretty_generate(
|
|
14
|
+
version: Txray::VERSION,
|
|
15
|
+
summary: { files: result.files.size, offenses: result.offenses.size, severities: result.counts,
|
|
16
|
+
skipped: result.skipped.to_a },
|
|
17
|
+
offenses: result.offenses.map(&:to_h)
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|