rspec_telemetry 0.3.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 +35 -0
- data/LICENSE.txt +21 -0
- data/README.md +193 -0
- data/examples/sample.ndjson +15 -0
- data/exe/rspec-telemetry +7 -0
- data/exe/rspec-telemetry-compare +6 -0
- data/exe/rspec-telemetry-viewer +67 -0
- data/lib/rspec_telemetry/analyzer.rb +170 -0
- data/lib/rspec_telemetry/cli.rb +71 -0
- data/lib/rspec_telemetry/compare_cli.rb +129 -0
- data/lib/rspec_telemetry/config.rb +40 -0
- data/lib/rspec_telemetry/console_report.rb +124 -0
- data/lib/rspec_telemetry/factory_aggregation.rb +50 -0
- data/lib/rspec_telemetry/factory_comparison.rb +101 -0
- data/lib/rspec_telemetry/formatter.rb +91 -0
- data/lib/rspec_telemetry/ndjson.rb +24 -0
- data/lib/rspec_telemetry/recorder.rb +75 -0
- data/lib/rspec_telemetry/subscribers/factory_bot.rb +88 -0
- data/lib/rspec_telemetry/summary.rb +134 -0
- data/lib/rspec_telemetry/trace/viewer/app.rb +269 -0
- data/lib/rspec_telemetry/trace/viewer/app_renderer.rb +88 -0
- data/lib/rspec_telemetry/trace/viewer/detail_lines.rb +75 -0
- data/lib/rspec_telemetry/trace/viewer/detail_pane.rb +28 -0
- data/lib/rspec_telemetry/trace/viewer/document.rb +198 -0
- data/lib/rspec_telemetry/trace/viewer/follow_controller.rb +51 -0
- data/lib/rspec_telemetry/trace/viewer/format.rb +23 -0
- data/lib/rspec_telemetry/trace/viewer/label.rb +84 -0
- data/lib/rspec_telemetry/trace/viewer/layout.rb +100 -0
- data/lib/rspec_telemetry/trace/viewer/pane_resizer.rb +99 -0
- data/lib/rspec_telemetry/trace/viewer/report_pane.rb +26 -0
- data/lib/rspec_telemetry/trace/viewer/report_view.rb +86 -0
- data/lib/rspec_telemetry/trace/viewer/screen/ranked_screen.rb +66 -0
- data/lib/rspec_telemetry/trace/viewer/screen/timeline_screen.rb +180 -0
- data/lib/rspec_telemetry/trace/viewer/source.rb +52 -0
- data/lib/rspec_telemetry/trace/viewer/source_pane.rb +70 -0
- data/lib/rspec_telemetry/trace/viewer/source_resolver.rb +56 -0
- data/lib/rspec_telemetry/trace/viewer/source_view.rb +63 -0
- data/lib/rspec_telemetry/trace/viewer/status_line.rb +50 -0
- data/lib/rspec_telemetry/trace/viewer/text_report.rb +49 -0
- data/lib/rspec_telemetry/trace/viewer/theme.rb +30 -0
- data/lib/rspec_telemetry/trace/viewer/time_bar.rb +46 -0
- data/lib/rspec_telemetry/trace/viewer/timeline_pane.rb +53 -0
- data/lib/rspec_telemetry/trace/viewer/version.rb +9 -0
- data/lib/rspec_telemetry/trace/viewer.rb +31 -0
- data/lib/rspec_telemetry/version.rb +5 -0
- data/lib/rspec_telemetry/writer.rb +59 -0
- data/lib/rspec_telemetry.rb +102 -0
- metadata +122 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
require_relative "factory_comparison"
|
|
6
|
+
|
|
7
|
+
module RSpecTelemetry
|
|
8
|
+
class CompareCLI
|
|
9
|
+
def initialize(argv, out: $stdout, err: $stderr)
|
|
10
|
+
@argv = argv
|
|
11
|
+
@out = out
|
|
12
|
+
@err = err
|
|
13
|
+
@options = {all_depths: false, sort: "duration"}
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def run
|
|
17
|
+
paths = parse!
|
|
18
|
+
unless paths.length == 2
|
|
19
|
+
@err.puts("Specify exactly two telemetry files: BEFORE AFTER")
|
|
20
|
+
return 1
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
comparison = FactoryComparison.new(paths[0], paths[1], all_depths: @options[:all_depths])
|
|
24
|
+
rows = sort_rows(comparison.rows)
|
|
25
|
+
@out.puts(render(rows, duration_label: comparison.all_depths ? "Self(ms)" : "Total(ms)"))
|
|
26
|
+
0
|
|
27
|
+
rescue Errno::ENOENT => e
|
|
28
|
+
@err.puts("File not found: #{e.message}")
|
|
29
|
+
1
|
|
30
|
+
rescue OptionParser::ParseError => e
|
|
31
|
+
@err.puts(e.message)
|
|
32
|
+
1
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def parse!
|
|
38
|
+
parser = OptionParser.new do |options|
|
|
39
|
+
options.banner = "Usage: rspec-telemetry-compare [options] BEFORE AFTER"
|
|
40
|
+
options.on("--all-depths", "Include nested FactoryBot events") do
|
|
41
|
+
@options[:all_depths] = true
|
|
42
|
+
end
|
|
43
|
+
options.on(
|
|
44
|
+
"--sort KEY",
|
|
45
|
+
%w[duration count factory],
|
|
46
|
+
"Sort by duration, count, or factory (default: duration)"
|
|
47
|
+
) do |value|
|
|
48
|
+
@options[:sort] = value
|
|
49
|
+
end
|
|
50
|
+
options.on("-h", "--help", "Show this help") do
|
|
51
|
+
@out.puts(options)
|
|
52
|
+
exit(0)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
parser.parse(@argv)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def sort_rows(rows)
|
|
60
|
+
case @options[:sort]
|
|
61
|
+
when "count"
|
|
62
|
+
rows.sort_by { |row| [-row.count_diff.abs, row.factory] }
|
|
63
|
+
when "factory"
|
|
64
|
+
rows.sort_by(&:factory)
|
|
65
|
+
else
|
|
66
|
+
rows.sort_by { |row| [-row.duration_diff_ms.abs, row.factory] }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def render(rows, duration_label:)
|
|
71
|
+
headings = [
|
|
72
|
+
"Factory",
|
|
73
|
+
"Before",
|
|
74
|
+
"After",
|
|
75
|
+
"Diff",
|
|
76
|
+
"Change",
|
|
77
|
+
"Before #{duration_label}",
|
|
78
|
+
"After #{duration_label}",
|
|
79
|
+
"Diff(ms)",
|
|
80
|
+
"Change"
|
|
81
|
+
]
|
|
82
|
+
body = rows.map do |row|
|
|
83
|
+
[
|
|
84
|
+
row.factory,
|
|
85
|
+
row.before_count.to_s,
|
|
86
|
+
row.after_count.to_s,
|
|
87
|
+
signed_integer(row.count_diff),
|
|
88
|
+
percent(row.count_change_percent),
|
|
89
|
+
decimal(row.before_duration_ms),
|
|
90
|
+
decimal(row.after_duration_ms),
|
|
91
|
+
signed_decimal(row.duration_diff_ms),
|
|
92
|
+
percent(row.duration_change_percent)
|
|
93
|
+
]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
widths = headings.each_index.map do |index|
|
|
97
|
+
([headings[index]] + body.map { |columns| columns[index] }).map(&:length).max
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
lines = []
|
|
101
|
+
lines << format_row(headings, widths)
|
|
102
|
+
lines << widths.map { |width| "-" * width }.join("-+-")
|
|
103
|
+
body.each { |columns| lines << format_row(columns, widths) }
|
|
104
|
+
lines.join("\n")
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def format_row(columns, widths)
|
|
108
|
+
columns.each_with_index.map do |value, index|
|
|
109
|
+
index.zero? ? value.ljust(widths[index]) : value.rjust(widths[index])
|
|
110
|
+
end.join(" | ")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def signed_integer(value)
|
|
114
|
+
format("%+d", value)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def decimal(value)
|
|
118
|
+
format("%.1f", value)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def signed_decimal(value)
|
|
122
|
+
format("%+.1f", value)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def percent(value)
|
|
126
|
+
value ? format("%+.1f%%", value) : "-"
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpecTelemetry
|
|
4
|
+
class Config
|
|
5
|
+
attr_accessor(
|
|
6
|
+
:enabled,
|
|
7
|
+
:output_path,
|
|
8
|
+
:capture_examples,
|
|
9
|
+
:capture_factory_bot,
|
|
10
|
+
:print_summary,
|
|
11
|
+
:flush_each,
|
|
12
|
+
:slow_factory_threshold_ms,
|
|
13
|
+
:slow_example_threshold_ms,
|
|
14
|
+
:summary_io,
|
|
15
|
+
:summary_limit
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
def initialize
|
|
19
|
+
@enabled = true
|
|
20
|
+
@output_path = self.class.default_output_path
|
|
21
|
+
@capture_examples = true
|
|
22
|
+
@capture_factory_bot = true
|
|
23
|
+
@print_summary = false
|
|
24
|
+
@flush_each = false
|
|
25
|
+
@slow_factory_threshold_ms = nil
|
|
26
|
+
@slow_example_threshold_ms = nil
|
|
27
|
+
@summary_io = $stderr
|
|
28
|
+
@summary_limit = 20
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.default_output_path
|
|
32
|
+
suffix = ENV["TEST_ENV_NUMBER"]
|
|
33
|
+
if suffix && !suffix.empty?
|
|
34
|
+
"tmp/rspec_telemetry.#{suffix}.ndjson"
|
|
35
|
+
else
|
|
36
|
+
"tmp/rspec_telemetry.ndjson"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpecTelemetry
|
|
4
|
+
class ConsoleReport
|
|
5
|
+
module Helpers
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def fmt(ms)
|
|
9
|
+
ms = ms.to_f
|
|
10
|
+
ms >= 1000 ? format("%.2fs", ms / 1000.0) : format("%.1fms", ms)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def pct(ratio) = format("%.1f%%", ratio * 100)
|
|
14
|
+
|
|
15
|
+
def truncate(str, len) = str.length > len ? "#{str[0, len - 1]}…" : str
|
|
16
|
+
|
|
17
|
+
def section(title) = ["", title, "-" * title.length]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
include Helpers
|
|
21
|
+
|
|
22
|
+
def initialize(analyzer, files_count:, top: 15)
|
|
23
|
+
@analyzer = analyzer
|
|
24
|
+
@files_count = files_count
|
|
25
|
+
@top = top
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def render
|
|
29
|
+
lines = overview + slow_files + slow_examples + top_factories
|
|
30
|
+
lines << ""
|
|
31
|
+
lines << "Tip: drill into one example with `rspec-telemetry --example \"<example_id>\"`"
|
|
32
|
+
lines.join("\n")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.drill_down(events, example_id)
|
|
36
|
+
lines = Helpers.section("Example: #{example_id}")
|
|
37
|
+
finished = events.find { |e| e["type"] == "example.finished" }
|
|
38
|
+
if finished
|
|
39
|
+
lines << " #{finished["full_description"]}"
|
|
40
|
+
lines << " status: #{finished["status"]} duration: #{Helpers.fmt(finished["duration_ms"])}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
factories = events.select { |e| e["type"] == "factory_bot.run_factory" }
|
|
44
|
+
return lines.join("\n") if factories.empty?
|
|
45
|
+
|
|
46
|
+
lines << ""
|
|
47
|
+
lines << " FactoryBot calls (indented by nesting depth):"
|
|
48
|
+
factories.each { |f| lines << factory_line(f) }
|
|
49
|
+
total = factories.sum { |f| (f["self_duration_ms"] || f["duration_ms"]).to_f }
|
|
50
|
+
lines << ""
|
|
51
|
+
lines << " factory self total: #{Helpers.fmt(total)} across #{factories.size} calls"
|
|
52
|
+
lines.join("\n")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.factory_line(fields)
|
|
56
|
+
indent = " " + (" " * fields["depth"].to_i)
|
|
57
|
+
traits = Array(fields["traits"]).empty? ? "" : " [#{fields["traits"].join(",")}]"
|
|
58
|
+
self_ms = fields["self_duration_ms"] || fields["duration_ms"]
|
|
59
|
+
"#{indent}#{fields["factory"]}:#{fields["strategy"]}#{traits} " \
|
|
60
|
+
"self #{Helpers.fmt(self_ms)} / total #{Helpers.fmt(fields["duration_ms"])}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def overview
|
|
66
|
+
a = @analyzer
|
|
67
|
+
section("Overview") +
|
|
68
|
+
[
|
|
69
|
+
" files analyzed: #{@files_count}",
|
|
70
|
+
" examples: #{a.example_count} (#{a.failure_count} failed, #{a.pending_count} pending)",
|
|
71
|
+
" suite wall time: #{fmt(a.suite_duration_ms)}",
|
|
72
|
+
" example time (sum): #{fmt(a.total_example_ms)}",
|
|
73
|
+
" factory self time: #{fmt(a.total_factory_self_ms)} (#{pct(a.factory_time_ratio)} of example time)"
|
|
74
|
+
]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def slow_files
|
|
78
|
+
rows = @analyzer.slow_files(@top)
|
|
79
|
+
return [] if rows.empty?
|
|
80
|
+
|
|
81
|
+
section("Slowest files (sum of example time)") +
|
|
82
|
+
rows.each_with_index.map do |f, i|
|
|
83
|
+
format(" %2d. %-9s %3d ex %s", i + 1, fmt(f.duration_ms), f.example_count, f.file_path)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def slow_examples
|
|
88
|
+
rows = @analyzer.slow_examples(@top)
|
|
89
|
+
return [] if rows.empty?
|
|
90
|
+
|
|
91
|
+
lines = section("Slowest examples")
|
|
92
|
+
rows.each_with_index do |e, i|
|
|
93
|
+
fb = e.fb_count.to_i.positive? ? " [factories: #{fmt(e.fb_self_total_ms)} / #{e.fb_count} calls]" : ""
|
|
94
|
+
lines << format(" %2d. %-9s %s", i + 1, fmt(e.duration_ms), e.example_id)
|
|
95
|
+
lines << " #{e.full_description}#{fb}" if e.full_description || !fb.empty?
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
lines
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def top_factories
|
|
102
|
+
rows = @analyzer.top_factories(@top)
|
|
103
|
+
return [] if rows.empty?
|
|
104
|
+
|
|
105
|
+
lines = section("Slowest factories (by self time, excludes nested children)")
|
|
106
|
+
lines << format(" %-28s %6s %10s %10s %9s %9s", "factory:strategy", "count", "self", "total", "avg", "max")
|
|
107
|
+
rows.each_with_index do |f, i|
|
|
108
|
+
lines <<
|
|
109
|
+
format(
|
|
110
|
+
" %2d. %-28s %6d %10s %10s %9s %9s",
|
|
111
|
+
i + 1,
|
|
112
|
+
truncate(f.key, 28),
|
|
113
|
+
f.count,
|
|
114
|
+
fmt(f.self_total_ms),
|
|
115
|
+
fmt(f.total_ms),
|
|
116
|
+
fmt(f.avg_ms),
|
|
117
|
+
fmt(f.max_ms)
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
lines
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpecTelemetry
|
|
4
|
+
module FactoryAggregation
|
|
5
|
+
# Shared by live summaries, CLI reports, and the viewer so ranking rules stay
|
|
6
|
+
# identical. A Struct (not Data) so collection runs on Ruby 3.1, where this is
|
|
7
|
+
# on the require path via summary.rb.
|
|
8
|
+
Stat = Struct.new(:key, :factory, :strategy, :count, :total_ms, :self_total_ms, :max_ms, keyword_init: true) do
|
|
9
|
+
def avg_ms = count.zero? ? 0.0 : total_ms / count
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
class Accumulator
|
|
13
|
+
def initialize
|
|
14
|
+
@rows = {}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def add(factory:, strategy:, duration_ms:, self_duration_ms: nil)
|
|
18
|
+
key = "#{factory}:#{strategy}"
|
|
19
|
+
total = duration_ms.to_f
|
|
20
|
+
self_ms = (self_duration_ms || duration_ms).to_f
|
|
21
|
+
row = (@rows[key] ||= {factory: factory, strategy: strategy, count: 0, total: 0.0, self: 0.0, max: 0.0})
|
|
22
|
+
row[:count] += 1
|
|
23
|
+
row[:total] += total
|
|
24
|
+
row[:self] += self_ms
|
|
25
|
+
row[:max] = total if total > row[:max]
|
|
26
|
+
self
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def stats
|
|
30
|
+
@rows.map do |key, row|
|
|
31
|
+
Stat.new(
|
|
32
|
+
key: key,
|
|
33
|
+
factory: row[:factory],
|
|
34
|
+
strategy: row[:strategy],
|
|
35
|
+
count: row[:count],
|
|
36
|
+
total_ms: row[:total],
|
|
37
|
+
self_total_ms: row[:self],
|
|
38
|
+
max_ms: row[:max]
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def top(limit = nil)
|
|
44
|
+
# Rank by self time so nested factories are not double-counted.
|
|
45
|
+
ranked = stats.sort_by { |stat| -stat.self_total_ms }
|
|
46
|
+
limit ? ranked.first(limit) : ranked
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ndjson"
|
|
4
|
+
|
|
5
|
+
module RSpecTelemetry
|
|
6
|
+
class FactoryComparison
|
|
7
|
+
FactoryStat = Struct.new(:count, :duration_ms)
|
|
8
|
+
Row = Struct.new(
|
|
9
|
+
:factory,
|
|
10
|
+
:before_count,
|
|
11
|
+
:after_count,
|
|
12
|
+
:before_duration_ms,
|
|
13
|
+
:after_duration_ms,
|
|
14
|
+
keyword_init: true
|
|
15
|
+
) do
|
|
16
|
+
def count_diff
|
|
17
|
+
after_count - before_count
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def duration_diff_ms
|
|
21
|
+
after_duration_ms - before_duration_ms
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def count_change_percent
|
|
25
|
+
change_percent(before_count, after_count)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def duration_change_percent
|
|
29
|
+
change_percent(before_duration_ms, after_duration_ms)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def change_percent(before_value, after_value)
|
|
35
|
+
return nil if before_value.zero?
|
|
36
|
+
|
|
37
|
+
((after_value - before_value) / before_value.to_f) * 100
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
attr_reader :before_path, :after_path, :all_depths
|
|
42
|
+
|
|
43
|
+
def initialize(before_path, after_path, all_depths: false)
|
|
44
|
+
@before_path = before_path
|
|
45
|
+
@after_path = after_path
|
|
46
|
+
@all_depths = all_depths
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def rows
|
|
50
|
+
before = aggregate(before_path)
|
|
51
|
+
after = aggregate(after_path)
|
|
52
|
+
|
|
53
|
+
(before.keys | after.keys).sort.map do |factory|
|
|
54
|
+
before_stat = before.fetch(factory, empty_stat)
|
|
55
|
+
after_stat = after.fetch(factory, empty_stat)
|
|
56
|
+
|
|
57
|
+
Row.new(
|
|
58
|
+
factory: factory,
|
|
59
|
+
before_count: before_stat.count,
|
|
60
|
+
after_count: after_stat.count,
|
|
61
|
+
before_duration_ms: before_stat.duration_ms,
|
|
62
|
+
after_duration_ms: after_stat.duration_ms
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def aggregate(path)
|
|
70
|
+
stats = Hash.new { |hash, factory| hash[factory] = FactoryStat.new(0, 0.0) }
|
|
71
|
+
|
|
72
|
+
File.foreach(path) do |line|
|
|
73
|
+
event = Ndjson.parse(line)
|
|
74
|
+
next unless factory_event?(event)
|
|
75
|
+
|
|
76
|
+
stat = stats[event["factory"].to_s]
|
|
77
|
+
stat.count += 1
|
|
78
|
+
stat.duration_ms += duration_ms(event)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
stats
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def factory_event?(event)
|
|
85
|
+
return false unless event
|
|
86
|
+
return false unless event["type"] == "factory_bot.run_factory"
|
|
87
|
+
return true if all_depths
|
|
88
|
+
|
|
89
|
+
event["depth"].to_i.zero?
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def duration_ms(event)
|
|
93
|
+
key = all_depths ? "self_duration_ms" : "duration_ms"
|
|
94
|
+
event[key].to_f
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def empty_stat
|
|
98
|
+
FactoryStat.new(0, 0.0)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rspec/core"
|
|
4
|
+
require "rspec/core/formatters/base_formatter"
|
|
5
|
+
|
|
6
|
+
module RSpecTelemetry
|
|
7
|
+
class Formatter < RSpec::Core::Formatters::BaseFormatter
|
|
8
|
+
RSpec::Core::Formatters.register(
|
|
9
|
+
self,
|
|
10
|
+
:start,
|
|
11
|
+
:example_started,
|
|
12
|
+
:example_finished,
|
|
13
|
+
:dump_summary,
|
|
14
|
+
:close
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
def start(_notification)
|
|
18
|
+
RSpecTelemetry.safely("formatter#start") { RSpecTelemetry.start! }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def example_started(notification)
|
|
22
|
+
RSpecTelemetry.safely("formatter#example_started") do
|
|
23
|
+
example = notification.example
|
|
24
|
+
# Runs before before(:each), so FactoryBot calls inside hooks get this example_id.
|
|
25
|
+
recorder.set_current_example(example.id)
|
|
26
|
+
next unless config.capture_examples
|
|
27
|
+
|
|
28
|
+
recorder.record(
|
|
29
|
+
"example.started",
|
|
30
|
+
file_path: example.file_path,
|
|
31
|
+
line_number: example.metadata[:line_number],
|
|
32
|
+
full_description: example.full_description
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def example_finished(notification)
|
|
38
|
+
RSpecTelemetry.safely("formatter#example_finished") do
|
|
39
|
+
example = notification.example
|
|
40
|
+
record_example_finished(example) if config.capture_examples
|
|
41
|
+
recorder.flush
|
|
42
|
+
ensure
|
|
43
|
+
# Avoid attributing after(:suite) or later work to the last example.
|
|
44
|
+
recorder.clear_current_example
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def dump_summary(notification)
|
|
49
|
+
RSpecTelemetry.safely("formatter#dump_summary") do
|
|
50
|
+
recorder.record(
|
|
51
|
+
"suite.finished",
|
|
52
|
+
example_id: nil,
|
|
53
|
+
duration_ms: (notification.duration * 1000.0).round(3),
|
|
54
|
+
example_count: notification.example_count,
|
|
55
|
+
failure_count: notification.failure_count,
|
|
56
|
+
pending_count: notification.pending_count
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def close(_notification)
|
|
62
|
+
RSpecTelemetry.safely("formatter#close") { RSpecTelemetry.finish! }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def record_example_finished(example)
|
|
68
|
+
result = example.execution_result
|
|
69
|
+
exception = result.exception
|
|
70
|
+
|
|
71
|
+
recorder.record(
|
|
72
|
+
"example.finished",
|
|
73
|
+
file_path: example.file_path,
|
|
74
|
+
line_number: example.metadata[:line_number],
|
|
75
|
+
full_description: example.full_description,
|
|
76
|
+
status: result.status.to_s,
|
|
77
|
+
duration_ms: (result.run_time.to_f * 1000.0).round(3),
|
|
78
|
+
exception_class: exception&.class&.name,
|
|
79
|
+
exception_message: exception&.message
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def recorder
|
|
84
|
+
RSpecTelemetry.recorder
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def config
|
|
88
|
+
RSpecTelemetry.config
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module RSpecTelemetry
|
|
6
|
+
module Ndjson
|
|
7
|
+
# Trace files are untrusted and may contain a truncated final line after a crash.
|
|
8
|
+
def self.parse(line)
|
|
9
|
+
text = scrub(line).strip
|
|
10
|
+
return nil if text.empty?
|
|
11
|
+
|
|
12
|
+
value = JSON.parse(text)
|
|
13
|
+
value.is_a?(Hash) ? value : nil
|
|
14
|
+
rescue JSON::ParserError
|
|
15
|
+
nil
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Keep downstream rendering on valid UTF-8 even when the trace has bad bytes.
|
|
19
|
+
def self.scrub(line)
|
|
20
|
+
string = line.to_s
|
|
21
|
+
string.valid_encoding? ? string : string.scrub("?")
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module RSpecTelemetry
|
|
6
|
+
class Recorder
|
|
7
|
+
# FactoryBot notifications read this to attach themselves to the active example.
|
|
8
|
+
EXAMPLE_ID = :rspec_telemetry_example_id
|
|
9
|
+
|
|
10
|
+
attr_reader :config, :summary
|
|
11
|
+
|
|
12
|
+
def initialize(config, writer: nil, summary: nil)
|
|
13
|
+
@config = config
|
|
14
|
+
@writer = writer || Writer.new(config.output_path, flush_each: config.flush_each)
|
|
15
|
+
@summary = summary || Summary.new(config)
|
|
16
|
+
@started = false
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def start
|
|
20
|
+
return if @started || !@config.enabled
|
|
21
|
+
|
|
22
|
+
@writer.open
|
|
23
|
+
@started = true
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def started?
|
|
27
|
+
@started
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def record(type, fields = {})
|
|
31
|
+
return unless @config.enabled && @started
|
|
32
|
+
|
|
33
|
+
event = common_fields(type).merge(fields)
|
|
34
|
+
@writer.write(event)
|
|
35
|
+
@summary.add(event)
|
|
36
|
+
event
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def flush
|
|
40
|
+
@writer.flush
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def finish
|
|
44
|
+
return unless @started
|
|
45
|
+
|
|
46
|
+
SummaryPrinter.print(@summary, @config) if @config.print_summary
|
|
47
|
+
@writer.close
|
|
48
|
+
@started = false
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def common_fields(type)
|
|
52
|
+
{
|
|
53
|
+
type: type,
|
|
54
|
+
timestamp: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%6NZ"),
|
|
55
|
+
monotonic_time: Process.clock_gettime(Process::CLOCK_MONOTONIC),
|
|
56
|
+
pid: Process.pid,
|
|
57
|
+
thread_id: self.class.thread_id,
|
|
58
|
+
example_id: Thread.current[EXAMPLE_ID]
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def set_current_example(id)
|
|
63
|
+
Thread.current[EXAMPLE_ID] = id
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def clear_current_example
|
|
67
|
+
Thread.current[EXAMPLE_ID] = nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.thread_id
|
|
71
|
+
t = Thread.current
|
|
72
|
+
t.respond_to?(:native_thread_id) && t.native_thread_id ? t.native_thread_id : t.object_id
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|