rspec-rewind 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: fa005ed6c1c90ab9f476de60e4ae3f34f09e98306cdc64794b9ff171e880d703
4
+ data.tar.gz: ac9ac1e102d7b52cbe88ed25d21829ee94714d39a13b68ceaa7caa5ba4008b61
5
+ SHA512:
6
+ metadata.gz: 5cf553951d1d8de3b01c4dc81d9c17cb3b1add64b437e83f4289a906584ded67ce50f4d2664b596e2ad0f2f75251c8b71005587ac6f3df7d58f8999afd293d64
7
+ data.tar.gz: ad6e9e857fd293dcc1e30dc2653b449ab12a7d96c4f2c3a8d2c0164187cd7ec198bfe0056eb9d357936cb164d2a9f552c02f7151ab91e231ec15348f282b8437
data/CHANGELOG.md ADDED
@@ -0,0 +1,12 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## Unreleased
9
+
10
+ ## 0.1.0 (2026-02-07)
11
+
12
+ - Initial release.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Yudai Takada
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,211 @@
1
+ <h1 align="center">rspec-rewind</h1>
2
+
3
+ <p align="center">
4
+ Deterministic retry orchestration for flaky RSpec examples.
5
+ </p>
6
+
7
+ <p align="center">
8
+ <img src="https://img.shields.io/badge/ruby-%3E%3D%203.1-ruby.svg" alt="Ruby Version">
9
+ <img src="https://img.shields.io/badge/rspec--core-%3E%3D%203.12%2C%20%3C%204.0-brightgreen.svg" alt="RSpec Core Version">
10
+ <img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License">
11
+ <a href="https://badge.fury.io/rb/rspec-rewind"><img src="https://badge.fury.io/rb/rspec-rewind.svg" alt="Gem Version" height="18"></a>
12
+ <a href="https://github.com/ydah/rspec-rewind/actions/workflows/main.yml">
13
+ <img src="https://github.com/ydah/rspec-rewind/actions/workflows/main.yml/badge.svg" alt="CI Status">
14
+ </a>
15
+ </p>
16
+
17
+ <p align="center">
18
+ <a href="#installation">Installation</a> •
19
+ <a href="#quick-start">Quick Start</a> •
20
+ <a href="#per-example-controls">Per-Example Controls</a> •
21
+ <a href="#configuration">Configuration</a> •
22
+ <a href="#observability">Observability</a> •
23
+ <a href="#compatibility">Compatibility</a>
24
+ </p>
25
+
26
+ `rspec-rewind` is a modern retry gem for RSpec, inspired by [`rspec-retry`](https://github.com/NoRedInk/rspec-retry), with deterministic control and flaky-test observability for CI-heavy projects.
27
+
28
+ ## Why rspec-rewind
29
+
30
+ - `retries` always means "extra attempts" (not total attempts).
31
+ - Retry filtering with `retry_on`, `skip_retry_on`, and `retry_if`.
32
+ - Configurable delay via fixed, linear, exponential, or custom backoff.
33
+ - Suite-level retry budget to prevent hidden retry inflation.
34
+ - Flaky detection hooks and optional JSONL reporting.
35
+
36
+ ## Installation
37
+
38
+ Add to your Gemfile:
39
+
40
+ ```ruby
41
+ gem "rspec-rewind"
42
+ ```
43
+
44
+ Then run:
45
+
46
+ ```bash
47
+ bundle install
48
+ ```
49
+
50
+ ## Quick Start
51
+
52
+ `require "rspec/rewind"` installs an around hook automatically.
53
+
54
+ ```ruby
55
+ # spec/spec_helper.rb
56
+ require "rspec/rewind"
57
+
58
+ RSpec::Rewind.configure do |config|
59
+ config.default_retries = 1
60
+ config.backoff = RSpec::Rewind::Backoff.exponential(base: 0.1, factor: 2.0, max: 1.0, jitter: 0.2)
61
+ config.retry_on = [Net::ReadTimeout, Errno::ECONNRESET]
62
+ config.skip_retry_on = [NoMethodError]
63
+ config.retry_budget = 20
64
+ config.flaky_report_path = "tmp/rspec-rewind/flaky.jsonl"
65
+ end
66
+ ```
67
+
68
+ Basic per-example retry:
69
+
70
+ ```ruby
71
+ it "eventually becomes consistent", rewind: 2 do
72
+ expect(fetch_remote_state).to eq("ready")
73
+ end
74
+ ```
75
+
76
+ ## Per-Example Controls
77
+
78
+ ```ruby
79
+ it "uses metadata overrides",
80
+ rewind: 3,
81
+ rewind_wait: 0.2,
82
+ rewind_retry_on: [Net::ReadTimeout, /502/],
83
+ rewind_skip_retry_on: [NoMethodError],
84
+ rewind_if: ->(exception, _example) { exception.message.include?("gateway") } do
85
+ expect(call_api).to eq(:ok)
86
+ end
87
+ ```
88
+
89
+ | Metadata key | Description |
90
+ | --- | --- |
91
+ | `rewind` | Retry count override. `true` = use default, `false` = disable retries for that example/group. |
92
+ | `rewind_wait` | Fixed sleep before next attempt. |
93
+ | `rewind_backoff` | Backoff strategy (numeric or callable). |
94
+ | `rewind_retry_on` | Extra allow-list matchers. |
95
+ | `rewind_skip_retry_on` | Extra deny-list matchers (checked first). |
96
+ | `rewind_if` | Predicate `(exception)` or `(exception, example)` returning truthy/falsey. |
97
+
98
+ Matcher types for `retry_on` and `skip_retry_on`: `Module`, `Regexp`, or callable.
99
+
100
+ ## Configuration
101
+
102
+ ```ruby
103
+ RSpec::Rewind.configure do |config|
104
+ config.default_retries = 0
105
+ config.backoff = RSpec::Rewind::Backoff.fixed(0)
106
+ config.retry_on = []
107
+ config.skip_retry_on = []
108
+ config.retry_if = nil
109
+ config.retry_callback = nil
110
+ config.flaky_callback = nil
111
+ config.retry_budget = nil
112
+ config.flaky_report_path = nil
113
+ config.verbose = false
114
+ config.display_retry_failure_messages = false
115
+ config.clear_lets_on_failure = true
116
+ end
117
+ ```
118
+
119
+ Backoff helpers:
120
+
121
+ ```ruby
122
+ RSpec::Rewind::Backoff.fixed(0.2)
123
+ RSpec::Rewind::Backoff.linear(step: 0.1, max: 1.0)
124
+ RSpec::Rewind::Backoff.exponential(base: 0.1, factor: 2.0, max: 2.0, jitter: 0.2)
125
+ ```
126
+
127
+ Environment override:
128
+
129
+ ```bash
130
+ RSPEC_REWIND_RETRIES=2 bundle exec rspec
131
+ ```
132
+
133
+ `RSPEC_REWIND_RETRIES` has highest priority over defaults and metadata.
134
+
135
+ ## Retry Decision Order
136
+
137
+ 1. Stop if no exception happened.
138
+ 2. Stop if exception matches any `skip_retry_on`.
139
+ 3. If `retry_on` is set, stop unless exception matches at least one matcher.
140
+ 4. If `retry_if` exists, retry only when predicate returns truthy.
141
+ 5. Stop if retry budget is exhausted.
142
+
143
+ ## Observability
144
+
145
+ ### Retry and Flaky Callbacks
146
+
147
+ ```ruby
148
+ RSpec::Rewind.configure do |config|
149
+ config.retry_callback = ->(event) do
150
+ puts "[retry] #{event.example_id} attempt=#{event.attempt}/#{event.retries}"
151
+ end
152
+
153
+ config.flaky_callback = ->(event) do
154
+ puts "[flaky] #{event.description} (attempt #{event.attempt})"
155
+ end
156
+ end
157
+ ```
158
+
159
+ ### JSONL Flaky Report
160
+
161
+ ```ruby
162
+ RSpec::Rewind.configure do |config|
163
+ config.flaky_report_path = "tmp/rspec-rewind/flaky.jsonl"
164
+ end
165
+ ```
166
+
167
+ Each flaky JSONL row includes:
168
+
169
+ - `schema_version`
170
+ - `status` (`flaky`)
171
+ - `retry_reason`
172
+ - `example_id`
173
+ - `description`
174
+ - `location`
175
+ - `attempt`
176
+ - `retries`
177
+ - `exception_class`
178
+ - `exception_message`
179
+ - `duration`
180
+ - `sleep_seconds`
181
+ - `timestamp`
182
+
183
+ ## Compatibility
184
+
185
+ - Ruby `>= 3.1`
186
+ - RSpec Core `>= 3.12`, `< 4.0`
187
+
188
+ ## Development
189
+
190
+ ```bash
191
+ bundle exec rspec
192
+ bundle exec rake rbs
193
+ ```
194
+
195
+ CI validates:
196
+
197
+ - Specs across Ruby 3.1, 3.2, 3.3, 3.4, 4.0, and head
198
+ - Minimum compatibility with RSpec 3.12 (`BUNDLE_GEMFILE=gemfiles/rspec_3_12.gemfile`)
199
+ - Type signatures (`rake rbs`)
200
+ - Coverage threshold (`COVERAGE=1 rspec`)
201
+ - Gem packaging (`rake build`)
202
+ - Dependency security audit (`bundler-audit`)
203
+
204
+ ## Contributing
205
+
206
+ Bug reports and pull requests are welcome on GitHub:
207
+ https://github.com/ydah/rspec-rewind
208
+
209
+ ## License
210
+
211
+ Released under the [MIT License](LICENSE.txt).
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Rewind
5
+ class AttemptRunner
6
+ FATAL_EXCEPTIONS = [
7
+ NoMemoryError,
8
+ ScriptError,
9
+ SignalException,
10
+ SystemExit,
11
+ SecurityError
12
+ ].freeze
13
+
14
+ def run(run_target:, exception_source:)
15
+ started_at = monotonic_time
16
+
17
+ begin
18
+ run_target.run
19
+ duration = monotonic_time - started_at
20
+ [current_exception(exception_source), duration, false]
21
+ rescue Exception => e
22
+ raise if fatal_exception?(e)
23
+
24
+ duration = monotonic_time - started_at
25
+ [e, duration, true]
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def monotonic_time
32
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
33
+ end
34
+
35
+ def current_exception(exception_source)
36
+ return nil unless exception_source.respond_to?(:exception)
37
+
38
+ exception_source.exception
39
+ end
40
+
41
+ def fatal_exception?(exception)
42
+ FATAL_EXCEPTIONS.any? { |klass| exception.is_a?(klass) }
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Rewind
5
+ module Backoff
6
+ module_function
7
+
8
+ def fixed(seconds)
9
+ value = normalize_numeric(seconds, 'seconds')
10
+ ->(**_) { value }
11
+ end
12
+
13
+ def linear(step:, max: nil)
14
+ step_value = normalize_numeric(step, 'step')
15
+ max_value = max.nil? ? nil : normalize_numeric(max, 'max')
16
+
17
+ lambda do |retry_number:, **_|
18
+ delay = step_value * retry_number.to_i
19
+ clamp(delay, max_value)
20
+ end
21
+ end
22
+
23
+ def exponential(base:, factor: 2.0, max: nil, jitter: 0.0)
24
+ base_value = normalize_numeric(base, 'base')
25
+ factor_value = normalize_numeric(factor, 'factor')
26
+ jitter_value = normalize_numeric(jitter, 'jitter')
27
+ max_value = max.nil? ? nil : normalize_numeric(max, 'max')
28
+
29
+ lambda do |retry_number:, **_|
30
+ exponent = [retry_number.to_i - 1, 0].max
31
+ delay = base_value * (factor_value**exponent)
32
+ delay = clamp(delay, max_value)
33
+
34
+ next delay if jitter_value.zero?
35
+
36
+ spread = delay * jitter_value
37
+ min_delay = [delay - spread, 0.0].max
38
+ max_delay = delay + spread
39
+ (Kernel.rand * (max_delay - min_delay)) + min_delay
40
+ end
41
+ end
42
+
43
+ def clamp(value, max)
44
+ return value unless max
45
+
46
+ [value, max].min
47
+ end
48
+ private_class_method :clamp
49
+
50
+ def normalize_numeric(value, name)
51
+ number = begin
52
+ Float(value)
53
+ rescue TypeError, ArgumentError
54
+ raise ArgumentError, "#{name} must be a numeric value"
55
+ end
56
+
57
+ raise ArgumentError, "#{name} must be >= 0" if number.negative?
58
+
59
+ number
60
+ end
61
+ private_class_method :normalize_numeric
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Rewind
5
+ class Configuration
6
+ include MatcherValidation
7
+
8
+ attr_reader :default_retries, :backoff, :retry_on, :skip_retry_on, :retry_if, :retry_callback, :flaky_callback,
9
+ :verbose, :display_retry_failure_messages, :clear_lets_on_failure, :retry_budget, :flaky_reporter
10
+
11
+ def initialize
12
+ self.default_retries = 0
13
+ self.backoff = Backoff.fixed(0)
14
+ self.retry_on = []
15
+ self.skip_retry_on = []
16
+ self.retry_if = nil
17
+ self.retry_callback = nil
18
+ self.flaky_callback = nil
19
+ self.verbose = false
20
+ self.display_retry_failure_messages = false
21
+ self.clear_lets_on_failure = true
22
+ self.retry_budget = nil
23
+ self.flaky_reporter = FlakyReporter.null
24
+ end
25
+
26
+ def default_retries=(value)
27
+ @default_retries = parse_non_negative_integer(value, source: 'default_retries')
28
+ end
29
+
30
+ def backoff=(value)
31
+ @backoff = normalize_backoff(value)
32
+ end
33
+
34
+ def retry_on=(values)
35
+ @retry_on = normalize_matchers(values, field: 'retry_on')
36
+ end
37
+
38
+ def skip_retry_on=(values)
39
+ @skip_retry_on = normalize_matchers(values, field: 'skip_retry_on')
40
+ end
41
+
42
+ def retry_if=(callable)
43
+ @retry_if = normalize_callable(callable, field: 'retry_if')
44
+ end
45
+
46
+ def retry_callback=(callable)
47
+ @retry_callback = normalize_callable(callable, field: 'retry_callback')
48
+ end
49
+
50
+ def flaky_callback=(callable)
51
+ @flaky_callback = normalize_callable(callable, field: 'flaky_callback')
52
+ end
53
+
54
+ def verbose=(value)
55
+ @verbose = normalize_boolean(value, field: 'verbose')
56
+ end
57
+
58
+ def display_retry_failure_messages=(value)
59
+ @display_retry_failure_messages = normalize_boolean(value, field: 'display_retry_failure_messages')
60
+ end
61
+
62
+ def clear_lets_on_failure=(value)
63
+ @clear_lets_on_failure = normalize_boolean(value, field: 'clear_lets_on_failure')
64
+ end
65
+
66
+ def retry_budget=(limit_or_budget)
67
+ @retry_budget =
68
+ if limit_or_budget.is_a?(RetryBudget)
69
+ limit_or_budget
70
+ else
71
+ RetryBudget.new(limit_or_budget)
72
+ end
73
+ end
74
+
75
+ def flaky_reporter=(reporter)
76
+ @flaky_reporter = reporter || FlakyReporter.null
77
+ end
78
+
79
+ def flaky_report_path=(path)
80
+ @flaky_reporter = path.nil? ? FlakyReporter.null : FlakyReporter.jsonl(path)
81
+ end
82
+
83
+ private
84
+
85
+ def parse_non_negative_integer(value, source:)
86
+ parsed = begin
87
+ Integer(value)
88
+ rescue TypeError, ArgumentError
89
+ raise ArgumentError, "#{source} must be a non-negative integer"
90
+ end
91
+
92
+ raise ArgumentError, "#{source} must be >= 0" if parsed.negative?
93
+
94
+ parsed
95
+ end
96
+
97
+ def normalize_backoff(value)
98
+ if value.is_a?(Numeric)
99
+ number = Float(value)
100
+ raise ArgumentError, 'backoff must be >= 0' if number.negative?
101
+
102
+ return number
103
+ end
104
+
105
+ return value if value.respond_to?(:call)
106
+
107
+ raise ArgumentError, 'backoff must be a non-negative numeric value or callable'
108
+ end
109
+
110
+ def normalize_callable(callable, field:)
111
+ return nil if callable.nil?
112
+
113
+ return callable if callable.respond_to?(:call)
114
+
115
+ raise ArgumentError, "#{field} must be nil or callable"
116
+ end
117
+
118
+ def normalize_boolean(value, field:)
119
+ return value if [true, false].include?(value)
120
+
121
+ raise ArgumentError, "#{field} must be true or false"
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Rewind
5
+ EVENT_SCHEMA_VERSION = 1
6
+
7
+ Event = Struct.new(
8
+ :schema_version,
9
+ :status,
10
+ :retry_reason,
11
+ :example_id,
12
+ :description,
13
+ :location,
14
+ :attempt,
15
+ :retries,
16
+ :exception_class,
17
+ :exception_message,
18
+ :duration,
19
+ :sleep_seconds,
20
+ :timestamp,
21
+ keyword_init: true
22
+ )
23
+ end
24
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Rewind
5
+ class ExampleContext
6
+ def initialize(example:)
7
+ @example = example
8
+ end
9
+
10
+ def source
11
+ return @example.example if @example.respond_to?(:example) && @example.example
12
+
13
+ @example
14
+ end
15
+
16
+ def metadata
17
+ return {} unless source.respond_to?(:metadata)
18
+
19
+ source.metadata || {}
20
+ end
21
+
22
+ def id
23
+ source.respond_to?(:id) ? source.id : 'unknown'
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Rewind
5
+ module ExampleMethods
6
+ def run_with_rewind(options = {})
7
+ normalized = normalize_options(options)
8
+
9
+ Runner.new(example: self, configuration: RSpec::Rewind.configuration).run(**normalized)
10
+ end
11
+
12
+ private
13
+
14
+ def normalize_options(options)
15
+ return {} if options.nil? || options.empty?
16
+
17
+ symbolized = options.transform_keys(&:to_sym)
18
+
19
+ {
20
+ retries: symbolized[:retries],
21
+ backoff: symbolized[:backoff],
22
+ wait: symbolized[:wait],
23
+ retry_on: symbolized[:retry_on],
24
+ skip_retry_on: symbolized[:skip_retry_on],
25
+ retry_if: symbolized[:retry_if]
26
+ }.compact
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Rewind
5
+ class ExampleStateResetter
6
+ def initialize(configuration:)
7
+ @configuration = configuration
8
+ end
9
+
10
+ def reset(example_source)
11
+ clear_exception(example_source)
12
+ clear_execution_result(example_source)
13
+ clear_lets(example_source) if @configuration.clear_lets_on_failure
14
+ end
15
+
16
+ private
17
+
18
+ def clear_exception(example_source)
19
+ if example_source.respond_to?(:clear_exception)
20
+ example_source.clear_exception
21
+ elsif example_source.instance_variable_defined?(:@exception)
22
+ example_source.instance_variable_set(:@exception, nil)
23
+ end
24
+ end
25
+
26
+ def clear_execution_result(example_source)
27
+ return unless example_source.respond_to?(:execution_result)
28
+
29
+ result = example_source.execution_result
30
+ return unless result
31
+
32
+ set_if_writer(result, :status, nil)
33
+ set_if_writer(result, :exception, nil)
34
+ set_if_writer(result, :pending_message, nil)
35
+ set_if_writer(result, :run_time, nil)
36
+ end
37
+
38
+ def clear_lets(example_source)
39
+ return unless example_source.respond_to?(:example_group_instance)
40
+
41
+ group_instance = example_source.example_group_instance
42
+ return unless group_instance
43
+
44
+ if group_instance.respond_to?(:clear_lets)
45
+ group_instance.clear_lets
46
+ elsif group_instance.instance_variable_defined?(:@__memoized)
47
+ group_instance.instance_variable_set(:@__memoized, nil)
48
+ end
49
+ end
50
+
51
+ def set_if_writer(target, attribute, value)
52
+ writer = "#{attribute}="
53
+ target.public_send(writer, value) if target.respond_to?(writer)
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'fileutils'
5
+
6
+ module RSpec
7
+ module Rewind
8
+ class FlakyReporter
9
+ class << self
10
+ def null
11
+ @null ||= NullReporter.new
12
+ end
13
+
14
+ def jsonl(path)
15
+ JsonlReporter.new(path)
16
+ end
17
+ end
18
+
19
+ class NullReporter
20
+ def record(_event); end
21
+ end
22
+
23
+ class JsonlReporter
24
+ def initialize(path)
25
+ @path = path
26
+ @mutex = Mutex.new
27
+ end
28
+
29
+ def record(event)
30
+ payload = {
31
+ schema_version: event.schema_version,
32
+ status: event.status,
33
+ retry_reason: event.retry_reason,
34
+ example_id: event.example_id,
35
+ description: event.description,
36
+ location: event.location,
37
+ attempt: event.attempt,
38
+ retries: event.retries,
39
+ exception_class: event.exception_class,
40
+ exception_message: event.exception_message,
41
+ duration: event.duration,
42
+ sleep_seconds: event.sleep_seconds,
43
+ timestamp: event.timestamp
44
+ }
45
+
46
+ @mutex.synchronize do
47
+ FileUtils.mkdir_p(File.dirname(@path))
48
+ File.open(@path, 'a') { |file| file.puts(JSON.generate(payload)) }
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end