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.
Files changed (49) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +35 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +193 -0
  5. data/examples/sample.ndjson +15 -0
  6. data/exe/rspec-telemetry +7 -0
  7. data/exe/rspec-telemetry-compare +6 -0
  8. data/exe/rspec-telemetry-viewer +67 -0
  9. data/lib/rspec_telemetry/analyzer.rb +170 -0
  10. data/lib/rspec_telemetry/cli.rb +71 -0
  11. data/lib/rspec_telemetry/compare_cli.rb +129 -0
  12. data/lib/rspec_telemetry/config.rb +40 -0
  13. data/lib/rspec_telemetry/console_report.rb +124 -0
  14. data/lib/rspec_telemetry/factory_aggregation.rb +50 -0
  15. data/lib/rspec_telemetry/factory_comparison.rb +101 -0
  16. data/lib/rspec_telemetry/formatter.rb +91 -0
  17. data/lib/rspec_telemetry/ndjson.rb +24 -0
  18. data/lib/rspec_telemetry/recorder.rb +75 -0
  19. data/lib/rspec_telemetry/subscribers/factory_bot.rb +88 -0
  20. data/lib/rspec_telemetry/summary.rb +134 -0
  21. data/lib/rspec_telemetry/trace/viewer/app.rb +269 -0
  22. data/lib/rspec_telemetry/trace/viewer/app_renderer.rb +88 -0
  23. data/lib/rspec_telemetry/trace/viewer/detail_lines.rb +75 -0
  24. data/lib/rspec_telemetry/trace/viewer/detail_pane.rb +28 -0
  25. data/lib/rspec_telemetry/trace/viewer/document.rb +198 -0
  26. data/lib/rspec_telemetry/trace/viewer/follow_controller.rb +51 -0
  27. data/lib/rspec_telemetry/trace/viewer/format.rb +23 -0
  28. data/lib/rspec_telemetry/trace/viewer/label.rb +84 -0
  29. data/lib/rspec_telemetry/trace/viewer/layout.rb +100 -0
  30. data/lib/rspec_telemetry/trace/viewer/pane_resizer.rb +99 -0
  31. data/lib/rspec_telemetry/trace/viewer/report_pane.rb +26 -0
  32. data/lib/rspec_telemetry/trace/viewer/report_view.rb +86 -0
  33. data/lib/rspec_telemetry/trace/viewer/screen/ranked_screen.rb +66 -0
  34. data/lib/rspec_telemetry/trace/viewer/screen/timeline_screen.rb +180 -0
  35. data/lib/rspec_telemetry/trace/viewer/source.rb +52 -0
  36. data/lib/rspec_telemetry/trace/viewer/source_pane.rb +70 -0
  37. data/lib/rspec_telemetry/trace/viewer/source_resolver.rb +56 -0
  38. data/lib/rspec_telemetry/trace/viewer/source_view.rb +63 -0
  39. data/lib/rspec_telemetry/trace/viewer/status_line.rb +50 -0
  40. data/lib/rspec_telemetry/trace/viewer/text_report.rb +49 -0
  41. data/lib/rspec_telemetry/trace/viewer/theme.rb +30 -0
  42. data/lib/rspec_telemetry/trace/viewer/time_bar.rb +46 -0
  43. data/lib/rspec_telemetry/trace/viewer/timeline_pane.rb +53 -0
  44. data/lib/rspec_telemetry/trace/viewer/version.rb +9 -0
  45. data/lib/rspec_telemetry/trace/viewer.rb +31 -0
  46. data/lib/rspec_telemetry/version.rb +5 -0
  47. data/lib/rspec_telemetry/writer.rb +59 -0
  48. data/lib/rspec_telemetry.rb +102 -0
  49. 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