dexkit 0.6.0 → 0.7.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,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ module GuardWrapper
5
+ extend Dex::Concern
6
+
7
+ GuardDefinition = Data.define(:name, :message, :requires, :block)
8
+
9
+ module ClassMethods
10
+ def guard(code, message = nil, requires: nil, &block)
11
+ raise ArgumentError, "guard code must be a Symbol, got: #{code.inspect}" unless code.is_a?(Symbol)
12
+ raise ArgumentError, "guard requires a block" unless block
13
+
14
+ requires = _guard_normalize_requires!(code, requires)
15
+ _guard_validate_unique!(code)
16
+
17
+ _guard_own << GuardDefinition.new(name: code, message: message, requires: requires, block: block)
18
+
19
+ error(code) if respond_to?(:error)
20
+ end
21
+
22
+ def _guard_list
23
+ parent = superclass.respond_to?(:_guard_list) ? superclass._guard_list : []
24
+ parent + _guard_own
25
+ end
26
+
27
+ def callable(**kwargs)
28
+ instance = new(**kwargs)
29
+ failures = instance.send(:_guard_evaluate)
30
+ if failures.empty?
31
+ Operation::Ok.new(nil)
32
+ else
33
+ first = failures.first
34
+ error = Dex::Error.new(first[:guard], first[:message], details: failures)
35
+ Operation::Err.new(error)
36
+ end
37
+ end
38
+
39
+ def callable?(*args, **kwargs)
40
+ if args.size > 1
41
+ raise ArgumentError, "callable? accepts at most one guard name, got #{args.size} arguments"
42
+ end
43
+
44
+ if args.first
45
+ guard_name = args.first
46
+ unless guard_name.is_a?(Symbol)
47
+ raise ArgumentError, "guard name must be a Symbol, got: #{guard_name.inspect}"
48
+ end
49
+ unless _guard_list.any? { |g| g.name == guard_name }
50
+ raise ArgumentError, "unknown guard :#{guard_name}. Declared: #{_guard_list.map(&:name).map(&:inspect).join(", ")}"
51
+ end
52
+ instance = new(**kwargs)
53
+ failures = instance.send(:_guard_evaluate)
54
+ failures.none? { |f| f[:guard] == guard_name }
55
+ else
56
+ callable(**kwargs).ok?
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def _guard_own
63
+ @_guards ||= []
64
+ end
65
+
66
+ def _guard_normalize_requires!(code, requires)
67
+ return [] if requires.nil?
68
+
69
+ deps = Array(requires)
70
+ invalid = deps.reject { |d| d.is_a?(Symbol) }
71
+ if invalid.any?
72
+ raise ArgumentError,
73
+ "guard :#{code} requires: must be Symbol(s), got: #{invalid.map(&:inspect).join(", ")}"
74
+ end
75
+
76
+ all_names = _guard_list.map(&:name)
77
+ deps.each do |dep|
78
+ unless all_names.include?(dep)
79
+ raise ArgumentError,
80
+ "guard :#{code} requires :#{dep}, but no guard with that name has been declared"
81
+ end
82
+ end
83
+
84
+ deps.freeze
85
+ end
86
+
87
+ def _guard_validate_unique!(code)
88
+ all_names = _guard_list.map(&:name)
89
+ if all_names.include?(code)
90
+ raise ArgumentError, "duplicate guard name :#{code}"
91
+ end
92
+ end
93
+ end
94
+
95
+ def _guard_wrap
96
+ guards = self.class._guard_list
97
+ return yield if guards.empty?
98
+
99
+ failures = _guard_evaluate
100
+ unless failures.empty?
101
+ first = failures.first
102
+ error!(first[:guard], first[:message], details: failures)
103
+ end
104
+
105
+ yield
106
+ end
107
+
108
+ private
109
+
110
+ def _guard_evaluate
111
+ guards = self.class._guard_list
112
+ return [] if guards.empty?
113
+
114
+ blocked_names = Set.new
115
+ failures = []
116
+
117
+ guards.each do |guard|
118
+ if guard.requires.any? { |dep| blocked_names.include?(dep) }
119
+ blocked_names << guard.name
120
+ next
121
+ end
122
+
123
+ threat = catch(:_dex_halt) { instance_exec(&guard.block) }
124
+ if threat.is_a?(Operation::Halt)
125
+ raise ArgumentError,
126
+ "guard :#{guard.name} must return truthy/falsy, not call error!/success!/assert!"
127
+ end
128
+
129
+ if threat
130
+ blocked_names << guard.name
131
+ failures << { guard: guard.name, message: guard.message || guard.name.to_s }
132
+ end
133
+ end
134
+
135
+ failures
136
+ end
137
+ end
138
+ end
@@ -9,25 +9,33 @@ module Dex
9
9
  case name
10
10
  when :DirectJob
11
11
  const_set(:DirectJob, Class.new(ActiveJob::Base) do
12
- def perform(class_name:, params:)
12
+ def perform(class_name:, params:, once_key: nil, once_bypass: false)
13
13
  klass = class_name.constantize
14
- klass.new(**klass.send(:_coerce_serialized_hash, params)).call
14
+ op = klass.new(**klass.send(:_coerce_serialized_hash, params))
15
+ op.once(once_key) if once_key
16
+ op.once(nil) if once_bypass
17
+ op.call
15
18
  end
16
19
  end)
17
20
  when :RecordJob
18
21
  const_set(:RecordJob, Class.new(ActiveJob::Base) do
19
- def perform(class_name:, record_id:)
22
+ def perform(class_name:, record_id:, once_key: nil, once_bypass: false)
20
23
  klass = class_name.constantize
21
24
  record = Dex.record_backend.find_record(record_id)
22
25
  params = klass.send(:_coerce_serialized_hash, record.params || {})
23
26
 
24
27
  op = klass.new(**params)
25
28
  op.instance_variable_set(:@_dex_record_id, record_id)
29
+ op.once(once_key) if once_key
30
+ op.once(nil) if once_bypass
26
31
 
27
32
  update_status(record_id, status: "running")
33
+ pipeline_started = true
28
34
  op.call
29
35
  rescue => e
30
- handle_failure(record_id, e)
36
+ # RecordWrapper handles failures during op.call via its own rescue.
37
+ # This catches pre-pipeline failures (find_record, deserialization, etc.)
38
+ mark_failed(record_id, e) unless pipeline_started
31
39
  raise
32
40
  end
33
41
 
@@ -39,13 +47,12 @@ module Dex
39
47
  Dex.warn("Failed to update record status: #{e.message}")
40
48
  end
41
49
 
42
- def handle_failure(record_id, exception)
43
- error_value = if exception.is_a?(Dex::Error)
44
- exception.code.to_s
45
- else
46
- exception.class.name
47
- end
48
- update_status(record_id, status: "failed", error: error_value)
50
+ def mark_failed(record_id, exception)
51
+ update_status(record_id,
52
+ status: "failed",
53
+ error_code: exception.class.name,
54
+ error_message: exception.message,
55
+ performed_at: Time.respond_to?(:current) ? Time.current : Time.now)
49
56
  end
50
57
  end)
51
58
  when :Job
@@ -0,0 +1,240 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ module OnceWrapper
5
+ extend Dex::Concern
6
+
7
+ module ClassMethods
8
+ def once(*props, expires_in: nil, &block)
9
+ if settings_for(:once)[:defined]
10
+ raise ArgumentError, "once can only be declared once per operation"
11
+ end
12
+
13
+ record_settings = settings_for(:record)
14
+ if record_settings[:enabled] == false
15
+ raise ArgumentError, "once requires record to be enabled"
16
+ end
17
+
18
+ if record_settings[:result] == false
19
+ raise ArgumentError, "once requires result recording (cannot use record result: false)"
20
+ end
21
+
22
+ if block && props.any?
23
+ raise ArgumentError, "once accepts either prop names or a block, not both"
24
+ end
25
+
26
+ if expires_in && !expires_in.is_a?(Numeric)
27
+ raise ArgumentError, "once :expires_in must be a duration, got: #{expires_in.inspect}"
28
+ end
29
+
30
+ _once_validate_props!(props) if props.any?
31
+
32
+ set(:once,
33
+ defined: true,
34
+ props: props.any? ? props : nil,
35
+ block: block || nil,
36
+ expires_in: expires_in)
37
+ end
38
+
39
+ def clear_once!(key = nil, **props)
40
+ derived = if key.is_a?(String)
41
+ key
42
+ elsif props.any?
43
+ _once_build_scoped_key(props)
44
+ else
45
+ raise ArgumentError, "pass a String key or keyword arguments matching the once props"
46
+ end
47
+ Dex.record_backend.update_record_by_once_key(derived, once_key: nil)
48
+ end
49
+
50
+ def _once_build_scoped_key(props_hash)
51
+ segments = props_hash.sort_by { |k, _| k.to_s }.map do |k, v|
52
+ "#{k}=#{URI.encode_www_form_component(v.to_s)}"
53
+ end
54
+ "#{name}/#{segments.join("/")}"
55
+ end
56
+
57
+ private
58
+
59
+ def _once_validate_props!(prop_names)
60
+ return unless respond_to?(:literal_properties)
61
+
62
+ defined_names = literal_properties.map(&:name).to_set
63
+ unknown = prop_names.reject { |p| defined_names.include?(p) }
64
+ return if unknown.empty?
65
+
66
+ raise ArgumentError,
67
+ "once references unknown prop(s): #{unknown.map(&:inspect).join(", ")}. " \
68
+ "Defined: #{defined_names.map(&:inspect).join(", ")}"
69
+ end
70
+ end
71
+
72
+ def once(key)
73
+ @_once_key = key
74
+ @_once_key_explicit = true
75
+ self
76
+ end
77
+
78
+ def _once_wrap
79
+ return yield unless _once_active?
80
+
81
+ key = _once_derive_key
82
+ return yield if key.nil? && _once_key_explicit?
83
+
84
+ _once_ensure_backend!
85
+
86
+ raise "once key must not be nil" if key.nil?
87
+
88
+ expires_in = self.class.settings_for(:once)[:expires_in]
89
+ expires_at = expires_in ? _once_current_time + expires_in : nil
90
+
91
+ existing = Dex.record_backend.find_by_once_key(key)
92
+ if existing
93
+ _once_finalize_duplicate!(existing)
94
+ return _once_replay!(existing)
95
+ end
96
+
97
+ expired = Dex.record_backend.find_expired_once_key(key)
98
+ Dex.record_backend.update_record(expired.id.to_s, once_key: nil) if expired
99
+
100
+ _once_claim!(key, expires_at) { yield }
101
+ end
102
+
103
+ private
104
+
105
+ def _once_key_explicit?
106
+ defined?(@_once_key_explicit) && @_once_key_explicit
107
+ end
108
+
109
+ def _once_active?
110
+ return true if _once_key_explicit?
111
+ self.class.settings_for(:once).fetch(:defined, false)
112
+ end
113
+
114
+ def _once_ensure_backend!
115
+ unless Dex.record_backend
116
+ raise "once requires a record backend (configure Dex.record_class)"
117
+ end
118
+
119
+ return if self.class.instance_variable_defined?(:@_once_fields_checked)
120
+
121
+ unless Dex.record_backend.has_field?("once_key")
122
+ raise "once requires once_key column on #{Dex.record_class}. Run the migration to add it."
123
+ end
124
+
125
+ if self.class.settings_for(:once)[:expires_in] && !Dex.record_backend.has_field?("once_key_expires_at")
126
+ raise "once with expires_in requires once_key_expires_at column on #{Dex.record_class}. Run the migration to add it."
127
+ end
128
+
129
+ self.class.instance_variable_set(:@_once_fields_checked, true)
130
+ end
131
+
132
+ def _once_derive_key
133
+ return @_once_key if _once_key_explicit?
134
+
135
+ settings = self.class.settings_for(:once)
136
+
137
+ if settings[:block]
138
+ instance_exec(&settings[:block])
139
+ elsif settings[:props]
140
+ props_hash = settings[:props].each_with_object({}) { |p, h| h[p] = public_send(p) }
141
+ self.class._once_build_scoped_key(props_hash)
142
+ else
143
+ hash = {}
144
+ if self.class.respond_to?(:literal_properties)
145
+ self.class.literal_properties.each { |p| hash[p.name] = public_send(p.name) }
146
+ end
147
+ self.class._once_build_scoped_key(hash)
148
+ end
149
+ end
150
+
151
+ def _once_claim!(key, expires_at)
152
+ begin
153
+ _once_acquire_key!(key, expires_at)
154
+ rescue => e
155
+ if Dex.record_backend.unique_constraint_error?(e)
156
+ existing = Dex.record_backend.find_by_once_key(key)
157
+ if existing
158
+ _once_finalize_duplicate!(existing)
159
+ return _once_replay!(existing)
160
+ end
161
+
162
+ raise "once key #{key.inspect} is claimed by another in-flight execution"
163
+ end
164
+ raise
165
+ end
166
+ yield
167
+ end
168
+
169
+ def _once_acquire_key!(key, expires_at)
170
+ if _once_has_pending_record?
171
+ Dex.record_backend.update_record(@_dex_record_id,
172
+ once_key: key, once_key_expires_at: expires_at)
173
+ else
174
+ record = Dex.record_backend.create_record(
175
+ name: self.class.name,
176
+ once_key: key,
177
+ once_key_expires_at: expires_at,
178
+ status: "pending"
179
+ )
180
+ @_dex_record_id = record.id.to_s
181
+ end
182
+ end
183
+
184
+ def _once_has_pending_record?
185
+ defined?(@_dex_record_id) && @_dex_record_id
186
+ end
187
+
188
+ def _once_finalize_duplicate!(source_record)
189
+ return unless _once_has_pending_record?
190
+
191
+ attrs = { performed_at: _once_current_time }
192
+ if source_record.status == "error"
193
+ attrs[:status] = "error"
194
+ attrs[:error_code] = source_record.error_code
195
+ attrs[:error_message] = source_record.error_message
196
+ attrs[:error_details] = source_record.respond_to?(:error_details) ? source_record.error_details : nil
197
+ else
198
+ attrs[:status] = "completed"
199
+ attrs[:result] = source_record.respond_to?(:result) ? source_record.result : nil
200
+ end
201
+ Dex.record_backend.update_record(@_dex_record_id, attrs)
202
+ rescue => e
203
+ Dex.warn("Failed to finalize replayed record: #{e.message}")
204
+ end
205
+
206
+ def _once_replay!(record)
207
+ case record.status
208
+ when "completed"
209
+ _once_replay_success(record)
210
+ when "error"
211
+ _once_replay_error(record)
212
+ end
213
+ end
214
+
215
+ def _once_replay_success(record)
216
+ stored = record.respond_to?(:result) ? record.result : nil
217
+ success_type = self.class.respond_to?(:_success_type) && self.class._success_type
218
+
219
+ if success_type && stored
220
+ self.class.send(:_coerce_value, success_type, stored)
221
+ elsif stored.is_a?(Hash) && stored.key?("_dex_value")
222
+ stored["_dex_value"]
223
+ else
224
+ stored
225
+ end
226
+ end
227
+
228
+ def _once_replay_error(record)
229
+ raise Dex::Error.new(
230
+ record.error_code.to_sym,
231
+ record.error_message,
232
+ details: record.respond_to?(:error_details) ? record.error_details : nil
233
+ )
234
+ end
235
+
236
+ def _once_current_time
237
+ Time.respond_to?(:current) ? Time.current : Time.now
238
+ end
239
+ end
240
+ end
@@ -34,6 +34,22 @@ module Dex
34
34
  record_class.find(id).update!(safe_attributes(attributes))
35
35
  end
36
36
 
37
+ def find_by_once_key(key)
38
+ raise NotImplementedError
39
+ end
40
+
41
+ def find_expired_once_key(key)
42
+ raise NotImplementedError
43
+ end
44
+
45
+ def update_record_by_once_key(key, **attributes)
46
+ raise NotImplementedError
47
+ end
48
+
49
+ def unique_constraint_error?(exception)
50
+ raise NotImplementedError
51
+ end
52
+
37
53
  def safe_attributes(attributes)
38
54
  attributes.select { |key, _| has_field?(key.to_s) }
39
55
  end
@@ -49,12 +65,71 @@ module Dex
49
65
  @column_set = record_class.column_names.to_set
50
66
  end
51
67
 
68
+ def find_by_once_key(key)
69
+ scope = record_class.where(once_key: key, status: %w[completed error])
70
+ scope = scope.where("once_key_expires_at IS NULL OR once_key_expires_at >= ?", Time.now) if has_field?("once_key_expires_at")
71
+ scope.first
72
+ end
73
+
74
+ def find_expired_once_key(key)
75
+ return nil unless has_field?("once_key_expires_at")
76
+
77
+ record_class
78
+ .where(once_key: key, status: %w[completed error])
79
+ .where("once_key_expires_at IS NOT NULL AND once_key_expires_at < ?", Time.now)
80
+ .first
81
+ end
82
+
83
+ def update_record_by_once_key(key, **attributes)
84
+ record = record_class.where(once_key: key, status: %w[completed error]).first
85
+ record&.update!(safe_attributes(attributes))
86
+ end
87
+
88
+ def unique_constraint_error?(exception)
89
+ defined?(ActiveRecord::RecordNotUnique) && exception.is_a?(ActiveRecord::RecordNotUnique)
90
+ end
91
+
52
92
  def has_field?(field_name)
53
93
  @column_set.include?(field_name.to_s)
54
94
  end
55
95
  end
56
96
 
57
97
  class MongoidAdapter < Base
98
+ def find_by_once_key(key)
99
+ now = Time.now
100
+ record_class.where(
101
+ :once_key => key,
102
+ :status.in => %w[completed error]
103
+ ).and(
104
+ record_class.or(
105
+ { once_key_expires_at: nil },
106
+ { :once_key_expires_at.gte => now }
107
+ )
108
+ ).first
109
+ end
110
+
111
+ def find_expired_once_key(key)
112
+ return nil unless has_field?("once_key_expires_at")
113
+
114
+ record_class.where(
115
+ :once_key => key,
116
+ :status.in => %w[completed error],
117
+ :once_key_expires_at.ne => nil,
118
+ :once_key_expires_at.lt => Time.now
119
+ ).first
120
+ end
121
+
122
+ def update_record_by_once_key(key, **attributes)
123
+ record = record_class.where(:once_key => key, :status.in => %w[completed error]).first
124
+ record&.update!(safe_attributes(attributes))
125
+ end
126
+
127
+ def unique_constraint_error?(exception)
128
+ defined?(Mongo::Error::OperationFailure) &&
129
+ exception.is_a?(Mongo::Error::OperationFailure) &&
130
+ exception.code == 11_000
131
+ end
132
+
58
133
  def has_field?(field_name)
59
134
  record_class.fields.key?(field_name.to_s)
60
135
  end
@@ -7,26 +7,27 @@ module Dex
7
7
  def _record_wrap
8
8
  interceptor = Operation::HaltInterceptor.new { yield }
9
9
 
10
- if interceptor.success?
11
- if _record_has_pending_record?
12
- _record_update_done!(interceptor.result)
13
- elsif _record_enabled?
14
- _record_save!(interceptor.result)
15
- end
10
+ if _record_has_pending_record?
11
+ _record_update_outcome!(interceptor)
12
+ elsif _record_enabled?
13
+ _record_save!(interceptor)
16
14
  end
17
15
 
18
16
  interceptor.rethrow!
19
17
  interceptor.result
18
+ rescue => e
19
+ _record_failure!(e) if _record_has_pending_record? || _record_enabled?
20
+ raise
20
21
  end
21
22
 
22
23
  module ClassMethods
23
24
  def record(enabled = nil, **options)
24
- validate_options!(options, %i[params response], :record)
25
+ validate_options!(options, %i[params result], :record)
25
26
 
26
27
  if enabled == false
27
28
  set :record, enabled: false
28
29
  elsif enabled == true || enabled.nil?
29
- merged = { enabled: true, params: true, response: true }.merge(options)
30
+ merged = { enabled: true, params: true, result: true }.merge(options)
30
31
  set :record, **merged
31
32
  else
32
33
  raise ArgumentError,
@@ -49,27 +50,78 @@ module Dex
49
50
  defined?(@_dex_record_id) && @_dex_record_id
50
51
  end
51
52
 
52
- def _record_save!(result)
53
- Dex.record_backend.create_record(_record_attributes(result))
53
+ def _record_save!(interceptor)
54
+ attrs = _record_base_attrs
55
+ if interceptor.error?
56
+ attrs.merge!(_record_error_attrs(code: interceptor.halt.error_code,
57
+ message: interceptor.halt.error_message,
58
+ details: interceptor.halt.error_details))
59
+ else
60
+ attrs.merge!(_record_success_attrs(interceptor.result))
61
+ end
62
+ Dex.record_backend.create_record(attrs)
54
63
  rescue => e
55
64
  _record_handle_error(e)
56
65
  end
57
66
 
58
- def _record_update_done!(result)
59
- attrs = { status: "done", performed_at: _record_current_time }
60
- attrs[:response] = _record_response(result) if _record_response?
67
+ def _record_update_outcome!(interceptor)
68
+ attrs = if interceptor.error?
69
+ _record_error_attrs(code: interceptor.halt.error_code,
70
+ message: interceptor.halt.error_message,
71
+ details: interceptor.halt.error_details)
72
+ else
73
+ _record_success_attrs(interceptor.result)
74
+ end
61
75
  Dex.record_backend.update_record(@_dex_record_id, attrs)
62
76
  rescue => e
63
77
  _record_handle_error(e)
64
78
  end
65
79
 
66
- def _record_attributes(result)
67
- attrs = { name: self.class.name, performed_at: _record_current_time, status: "done" }
80
+ def _record_failure!(exception)
81
+ attrs = if exception.is_a?(Dex::Error)
82
+ _record_error_attrs(code: exception.code, message: exception.message, details: exception.details)
83
+ else
84
+ {
85
+ status: "failed",
86
+ error_code: exception.class.name,
87
+ error_message: exception.message,
88
+ performed_at: _record_current_time
89
+ }
90
+ end
91
+
92
+ attrs[:once_key] = nil if defined?(@_once_key) || self.class.settings_for(:once).fetch(:defined, false)
93
+
94
+ if _record_has_pending_record?
95
+ Dex.record_backend.update_record(@_dex_record_id, attrs)
96
+ else
97
+ Dex.record_backend.create_record(_record_base_attrs.merge(attrs))
98
+ end
99
+ rescue => e
100
+ _record_handle_error(e)
101
+ end
102
+
103
+ def _record_base_attrs
104
+ attrs = { name: self.class.name }
68
105
  attrs[:params] = _record_params? ? _record_params : nil
69
- attrs[:response] = _record_response? ? _record_response(result) : nil
70
106
  attrs
71
107
  end
72
108
 
109
+ def _record_success_attrs(result)
110
+ attrs = { status: "completed", performed_at: _record_current_time }
111
+ attrs[:result] = _record_result(result) if _record_result?
112
+ attrs
113
+ end
114
+
115
+ def _record_error_attrs(code:, message:, details:)
116
+ {
117
+ status: "error",
118
+ error_code: code.to_s,
119
+ error_message: message || code.to_s,
120
+ error_details: _record_sanitize_details(details),
121
+ performed_at: _record_current_time
122
+ }
123
+ end
124
+
73
125
  def _record_params
74
126
  _props_as_json
75
127
  end
@@ -78,11 +130,11 @@ module Dex
78
130
  self.class.settings_for(:record).fetch(:params, true)
79
131
  end
80
132
 
81
- def _record_response?
82
- self.class.settings_for(:record).fetch(:response, true)
133
+ def _record_result?
134
+ self.class.settings_for(:record).fetch(:result, true)
83
135
  end
84
136
 
85
- def _record_response(result)
137
+ def _record_result(result)
86
138
  success_type = self.class.respond_to?(:_success_type) && self.class._success_type
87
139
 
88
140
  if success_type
@@ -91,7 +143,7 @@ module Dex
91
143
  case result
92
144
  when nil then nil
93
145
  when Hash then result
94
- else { value: result }
146
+ else { _dex_value: result } # namespaced key so replay can distinguish wrapped primitives from user hashes
95
147
  end
96
148
  end
97
149
  end
@@ -100,6 +152,21 @@ module Dex
100
152
  Time.respond_to?(:current) ? Time.current : Time.now
101
153
  end
102
154
 
155
+ def _record_sanitize_details(details)
156
+ _record_sanitize_value(details)
157
+ end
158
+
159
+ def _record_sanitize_value(value)
160
+ case value
161
+ when NilClass, String, Integer, Float, TrueClass, FalseClass then value
162
+ when Symbol then value.to_s
163
+ when Hash then value.transform_values { |v| _record_sanitize_value(v) }
164
+ when Array then value.map { |v| _record_sanitize_value(v) }
165
+ when Exception then "#{value.class}: #{value.message}"
166
+ else value.to_s
167
+ end
168
+ end
169
+
103
170
  def _record_handle_error(error)
104
171
  Dex.warn("Failed to record operation: #{error.message}")
105
172
  end