igniter-embed 0.5.2

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,393 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Igniter
6
+ module Embed
7
+ module Contractable
8
+ class Runner
9
+ OutputsLike = Struct.new(:payload, keyword_init: true) do
10
+ def to_h
11
+ payload
12
+ end
13
+ end
14
+ ExecutionLike = Struct.new(:outputs, keyword_init: true)
15
+
16
+ SEVERITY_MAP = {
17
+ observation: :info,
18
+ primary_success: :info,
19
+ candidate_success: :info,
20
+ divergence: :warning,
21
+ acceptance_failure: :warning,
22
+ primary_error: :error,
23
+ candidate_error: :error,
24
+ store_error: :error
25
+ }.freeze
26
+
27
+ SUMMARY_MAP = {
28
+ observation: "observation recorded",
29
+ primary_success: "primary succeeded",
30
+ candidate_success: "candidate succeeded",
31
+ divergence: "outputs diverged from primary",
32
+ acceptance_failure: "acceptance policy failed",
33
+ primary_error: "primary raised an error",
34
+ candidate_error: "candidate raised an error",
35
+ store_error: "store adapter raised an error"
36
+ }.freeze
37
+
38
+ attr_reader :config
39
+
40
+ def initialize(config:)
41
+ @config = config
42
+ @config.validate!
43
+ end
44
+
45
+ def call(*args, **kwargs)
46
+ observation_id = generate_observation_id
47
+ primary_result = primary_payload(args, kwargs, observation_id)
48
+ started_at = config.now
49
+ sampled = config.sampled?
50
+ dispatch_event(:primary_success, observation_id: observation_id, observation: nil, error: nil, metadata: { inputs: redacted_inputs(args, kwargs) })
51
+
52
+ if sampled
53
+ handoff = build_async_handoff(observation_id, args, kwargs)
54
+ work = -> { observe(observation_id: observation_id, started_at: started_at, primary_result: primary_result, args: args, kwargs: kwargs, sampled: true) }
55
+ dispatch_async(name: config.name, inputs: redacted_inputs(args, kwargs), metadata: metadata_payload, handoff: handoff, &work)
56
+ else
57
+ record_observation(sampled_observation(observation_id: observation_id, started_at: started_at, primary_result: primary_result, args: args, kwargs: kwargs))
58
+ end
59
+
60
+ primary_result
61
+ end
62
+
63
+ private
64
+
65
+ def primary_payload(args, kwargs, observation_id)
66
+ invoke(config.primary_callable, args, kwargs)
67
+ rescue StandardError => e
68
+ dispatch_event(:primary_error, observation_id: observation_id, observation: nil, error: serialize_error(e), metadata: { inputs: safe_redacted_inputs(args, kwargs) })
69
+ raise
70
+ end
71
+
72
+ def observe(observation_id:, started_at:, primary_result:, args:, kwargs:, sampled:)
73
+ primary = normalize_side(config.primary_normalizer, primary_result)
74
+ candidate = candidate_payload(args, kwargs)
75
+ report = build_report(primary: primary, candidate: candidate, args: args, kwargs: kwargs)
76
+ acceptance = if candidate
77
+ Acceptance.evaluate(
78
+ policy: config.acceptance_policy,
79
+ report: report,
80
+ candidate: candidate,
81
+ options: config.acceptance_options
82
+ )
83
+ end
84
+
85
+ record_observation(
86
+ observation(
87
+ observation_id: observation_id,
88
+ started_at: started_at,
89
+ finished_at: config.now,
90
+ args: args,
91
+ kwargs: kwargs,
92
+ sampled: sampled,
93
+ primary: primary,
94
+ candidate: candidate,
95
+ report: report,
96
+ acceptance: acceptance
97
+ )
98
+ )
99
+ end
100
+
101
+ def sampled_observation(observation_id:, started_at:, primary_result:, args:, kwargs:)
102
+ primary = normalize_side(config.primary_normalizer, primary_result)
103
+ observation(
104
+ observation_id: observation_id,
105
+ started_at: started_at,
106
+ finished_at: config.now,
107
+ args: args,
108
+ kwargs: kwargs,
109
+ sampled: false,
110
+ primary: primary,
111
+ candidate: nil,
112
+ report: nil,
113
+ acceptance: nil
114
+ )
115
+ end
116
+
117
+ def candidate_payload(args, kwargs)
118
+ return nil if config.observed_service?
119
+
120
+ candidate_result = invoke(config.candidate_callable, args, kwargs)
121
+ normalize_side(config.candidate_normalizer, candidate_result)
122
+ rescue StandardError => e
123
+ {
124
+ status: :error,
125
+ outputs: {},
126
+ metadata: {},
127
+ error: serialize_error(e)
128
+ }
129
+ end
130
+
131
+ def normalize_side(normalizer, value)
132
+ normalized = normalizer.call(value)
133
+ {
134
+ status: normalized.fetch(:status, :ok).to_sym,
135
+ outputs: normalize_hash(normalized.fetch(:outputs)),
136
+ metadata: normalize_hash(normalized.fetch(:metadata, {})),
137
+ error: normalized[:error]
138
+ }
139
+ rescue StandardError => e
140
+ {
141
+ status: :error,
142
+ outputs: {},
143
+ metadata: {},
144
+ error: serialize_error(e)
145
+ }
146
+ end
147
+
148
+ def build_report(primary:, candidate:, args:, kwargs:)
149
+ return nil unless candidate
150
+
151
+ Igniter::Extensions::Contracts::DifferentialPack.compare(
152
+ inputs: redacted_inputs(args, kwargs),
153
+ primary_result: execution_like(primary.fetch(:outputs)),
154
+ candidate_result: execution_like(candidate.fetch(:outputs)),
155
+ primary_name: "#{config.name}:primary",
156
+ candidate_name: "#{config.name}:candidate"
157
+ )
158
+ end
159
+
160
+ def observation(observation_id:, started_at:, finished_at:, args:, kwargs:, sampled:, primary:, candidate:, report:, acceptance:)
161
+ {
162
+ schema_version: 1,
163
+ receipt_kind: :contractable_observation,
164
+ observation_id: observation_id,
165
+ name: config.name,
166
+ role: config.role,
167
+ stage: config.stage,
168
+ mode: candidate ? :shadow : :observe,
169
+ async: config.async,
170
+ sampled: sampled,
171
+ status: nil,
172
+ started_at: serialize_time(started_at),
173
+ finished_at: serialize_time(finished_at),
174
+ duration_ms: duration_ms(started_at, finished_at),
175
+ inputs: redacted_inputs(args, kwargs),
176
+ primary: primary,
177
+ candidate: candidate,
178
+ report: report_payload(report),
179
+ match: report&.match?,
180
+ accepted: acceptance&.fetch(:accepted),
181
+ acceptance: acceptance,
182
+ error: candidate&.fetch(:error),
183
+ store_error: nil,
184
+ metadata: metadata_payload,
185
+ redaction: redaction_metadata
186
+ }
187
+ end
188
+
189
+ def record_observation(observation)
190
+ if config.store_adapter
191
+ begin
192
+ observation[:status] = observation_status(observation)
193
+ if config.store_adapter.respond_to?(:record_observation)
194
+ config.store_adapter.record_observation(observation)
195
+ else
196
+ config.store_adapter.record(observation)
197
+ end
198
+ rescue StandardError => e
199
+ observation[:store_error] = serialize_error(e)
200
+ observation[:status] = observation_status(observation)
201
+ end
202
+ else
203
+ observation[:status] = observation_status(observation)
204
+ end
205
+ config.observation_callback&.call(observation)
206
+ dispatch_observation_events(observation)
207
+ observation
208
+ end
209
+
210
+ def dispatch_observation_events(observation)
211
+ obs_id = observation[:observation_id]
212
+ dispatch_event(:candidate_success, observation_id: obs_id, observation: observation) if candidate_success?(observation)
213
+ dispatch_event(:candidate_error, observation_id: obs_id, observation: observation, error: observation[:error]) if observation[:error]
214
+ dispatch_event(:divergence, observation_id: obs_id, observation: observation) if observation[:match] == false
215
+ dispatch_event(:acceptance_failure, observation_id: obs_id, observation: observation) if observation[:accepted] == false
216
+ dispatch_event(:store_error, observation_id: obs_id, observation: observation, error: observation[:store_error]) if observation[:store_error]
217
+ dispatch_event(:observation, observation_id: obs_id, observation: observation)
218
+ end
219
+
220
+ def dispatch_event(event, observation_id:, observation:, error: nil, metadata: {})
221
+ receipt = build_event_receipt(event: event, observation_id: observation_id, observation: observation)
222
+ config.handlers_for(event).each do |event_handler|
223
+ event_handler.handler.call(
224
+ event_payload(
225
+ event: event,
226
+ observation: observation,
227
+ error: error,
228
+ metadata: metadata,
229
+ receipt: receipt
230
+ )
231
+ )
232
+ end
233
+ return unless config.store_adapter.respond_to?(:record_event)
234
+
235
+ begin
236
+ config.store_adapter.record_event(receipt)
237
+ rescue StandardError
238
+ nil
239
+ end
240
+ end
241
+
242
+ def event_payload(event:, observation:, error:, metadata:, receipt:)
243
+ {
244
+ name: config.name,
245
+ role: config.role,
246
+ stage: config.stage,
247
+ event: event,
248
+ observation: observation,
249
+ report: observation&.fetch(:report, nil),
250
+ error: error,
251
+ metadata: metadata_payload.merge(normalize_hash(metadata)),
252
+ receipt: receipt
253
+ }
254
+ end
255
+
256
+ def build_event_receipt(event:, observation_id:, observation:)
257
+ observation_ref = if observation
258
+ {
259
+ observation_id: observation[:observation_id],
260
+ match: observation[:match],
261
+ accepted: observation[:accepted]
262
+ }
263
+ end
264
+
265
+ {
266
+ schema_version: 1,
267
+ receipt_kind: :contractable_event,
268
+ event_id: generate_event_id,
269
+ observation_id: observation_id,
270
+ event: event,
271
+ name: config.name,
272
+ occurred_at: serialize_time(config.now),
273
+ severity: SEVERITY_MAP.fetch(event, :info),
274
+ summary: SUMMARY_MAP.fetch(event, event.to_s.tr("_", " ")),
275
+ observation_ref: observation_ref,
276
+ metadata: {}
277
+ }
278
+ end
279
+
280
+ def observation_status(observation)
281
+ return :unsampled if observation[:sampled] == false
282
+ return :store_error if observation[:store_error]
283
+ return :candidate_error if observation.dig(:candidate, :status) == :error
284
+ return :acceptance_failed if observation[:accepted] == false
285
+ return :diverged if observation[:match] == false
286
+
287
+ :ok
288
+ end
289
+
290
+ def redaction_metadata
291
+ {
292
+ input_policy: config.redaction_input_policy,
293
+ output_policy: :none,
294
+ classes: []
295
+ }
296
+ end
297
+
298
+ def build_async_handoff(observation_id, args, kwargs)
299
+ {
300
+ schema_version: 1,
301
+ kind: :contractable_async_handoff,
302
+ observation_id: observation_id,
303
+ name: config.name,
304
+ inputs: redacted_inputs(args, kwargs),
305
+ metadata: metadata_payload,
306
+ queued_at: serialize_time(config.now)
307
+ }
308
+ end
309
+
310
+ def dispatch_async(name:, inputs:, metadata:, handoff:, &block)
311
+ adapter = config.async_adapter
312
+ params = adapter.method(:enqueue).parameters
313
+ accepts_handoff = params.any? { |(type, pname)| %i[key keyreq].include?(type) && pname == :handoff } ||
314
+ params.any? { |(type, _)| type == :keyrest }
315
+ if accepts_handoff
316
+ adapter.enqueue(name: name, inputs: inputs, metadata: metadata, handoff: handoff, &block)
317
+ else
318
+ adapter.enqueue(name: name, inputs: inputs, metadata: metadata, &block)
319
+ end
320
+ end
321
+
322
+ def candidate_success?(observation)
323
+ candidate = observation[:candidate]
324
+ candidate && candidate[:status] != :error
325
+ end
326
+
327
+ def invoke(callable, args, kwargs)
328
+ return Igniter::Contracts::Contractable.invoke(callable, **kwargs).to_h if Igniter::Contracts::Contractable.contractable?(callable)
329
+
330
+ callable.call(*args, **kwargs)
331
+ end
332
+
333
+ def execution_like(outputs)
334
+ ExecutionLike.new(outputs: OutputsLike.new(payload: outputs))
335
+ end
336
+
337
+ def report_payload(report)
338
+ return nil unless report
339
+
340
+ {
341
+ match: report.match?,
342
+ summary: report.summary,
343
+ details: report.to_h
344
+ }
345
+ end
346
+
347
+ def redacted_inputs(args, kwargs)
348
+ normalize_hash(config.normalize_inputs(args, kwargs))
349
+ end
350
+
351
+ def safe_redacted_inputs(args, kwargs)
352
+ redacted_inputs(args, kwargs)
353
+ rescue StandardError
354
+ {}
355
+ end
356
+
357
+ def metadata_payload
358
+ normalize_hash(config.metadata_payload)
359
+ end
360
+
361
+ def normalize_hash(value)
362
+ value.to_h.transform_keys(&:to_sym)
363
+ end
364
+
365
+ def serialize_error(error)
366
+ {
367
+ type: error.class.name,
368
+ message: error.message,
369
+ details: error.respond_to?(:to_h) ? error.to_h : {}
370
+ }
371
+ end
372
+
373
+ def serialize_time(value)
374
+ value.respond_to?(:iso8601) ? value.iso8601 : value.to_s
375
+ end
376
+
377
+ def duration_ms(started_at, finished_at)
378
+ return nil unless started_at.respond_to?(:to_f) && finished_at.respond_to?(:to_f)
379
+
380
+ ((finished_at.to_f - started_at.to_f) * 1000).round(3)
381
+ end
382
+
383
+ def generate_observation_id
384
+ "obs_#{SecureRandom.hex(12)}"
385
+ end
386
+
387
+ def generate_event_id
388
+ "evt_#{SecureRandom.hex(12)}"
389
+ end
390
+ end
391
+ end
392
+ end
393
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Embed
5
+ module Contractable
6
+ class SugarBuilder
7
+ attr_reader :configured
8
+
9
+ def initialize(config:)
10
+ @config = config
11
+ @configured = false
12
+ end
13
+
14
+ def migration(from:, to:)
15
+ mark_configured!
16
+ config.role :migration_candidate
17
+ config.stage :shadowed
18
+ config.primary from
19
+ config.candidate to
20
+ config
21
+ end
22
+
23
+ def migrate(from, to:)
24
+ migration(from: from, to: to)
25
+ end
26
+
27
+ def observe(callable)
28
+ mark_configured!
29
+ config.role :observed_service
30
+ config.stage :captured
31
+ config.primary callable
32
+ config
33
+ end
34
+
35
+ def discover(callable)
36
+ mark_configured!
37
+ config.role :discovery_probe
38
+ config.stage :profiled
39
+ config.primary callable
40
+ config
41
+ end
42
+
43
+ def shadow(async: nil, sample: nil)
44
+ mark_configured!
45
+ config.async(async) unless async.nil?
46
+ config.sample(sample) unless sample.nil?
47
+ config
48
+ end
49
+
50
+ def capture(**options)
51
+ mark_configured!
52
+ config.metadata(capture: options)
53
+ end
54
+
55
+ def use(capability, adapter = nil, **options)
56
+ mark_configured!
57
+ case capability.to_sym
58
+ when :normalizer
59
+ require_adapter!(capability, adapter)
60
+ config.normalize_primary adapter
61
+ config.normalize_candidate adapter
62
+ when :redaction
63
+ config.redact_inputs adapter || redaction_adapter(**options)
64
+ config.redaction_input_policy = if adapter
65
+ :custom
66
+ elsif options[:only]
67
+ :only
68
+ else
69
+ :except
70
+ end
71
+ when :acceptance
72
+ raise SugarError, "use :acceptance requires policy:" unless options.key?(:policy)
73
+
74
+ policy = options.fetch(:policy)
75
+ config.accept policy, **options.reject { |key, _value| key == :policy }
76
+ when :store
77
+ require_adapter!(capability, adapter)
78
+ config.store adapter
79
+ when :logging, :reporting, :metrics, :validation
80
+ config.capability capability, explicit_capability_target(capability, adapter, **options)
81
+ else
82
+ raise SugarError, "use :#{capability} is not supported in this implementation slice"
83
+ end
84
+ config
85
+ end
86
+
87
+ def on(event, callable = nil, &block)
88
+ mark_configured!
89
+ config.on(event, callable, &block)
90
+ end
91
+
92
+ def configured?
93
+ !!configured
94
+ end
95
+
96
+ def method_missing(name, ...)
97
+ if config.respond_to?(name)
98
+ mark_configured!
99
+ return config.public_send(name, ...)
100
+ end
101
+
102
+ super
103
+ end
104
+
105
+ def respond_to_missing?(name, include_private = false)
106
+ config.respond_to?(name, include_private) || super
107
+ end
108
+
109
+ private
110
+
111
+ attr_reader :config
112
+
113
+ def mark_configured!
114
+ @configured = true
115
+ end
116
+
117
+ def require_adapter!(capability, adapter)
118
+ return if adapter
119
+
120
+ raise SugarError, "use :#{capability} requires an explicit adapter"
121
+ end
122
+
123
+ def explicit_capability_target(capability, adapter = nil, contract: nil, callable: nil, target: nil)
124
+ targets = [adapter, contract, callable, target].compact
125
+ raise SugarError, "use :#{capability} requires an explicit target" if targets.empty?
126
+ raise SugarError, "use :#{capability} accepts only one explicit target" if targets.length > 1
127
+
128
+ targets.first
129
+ end
130
+
131
+ def redaction_adapter(only: nil, except: nil)
132
+ raise SugarError, "use :redaction accepts only one of :only or :except" if only && except
133
+ raise SugarError, "use :redaction requires an adapter, :only, or :except" unless only || except
134
+
135
+ keys = Array(only || except).map(&:to_sym)
136
+ lambda do |*args, **kwargs|
137
+ inputs = kwargs.empty? && args.first.respond_to?(:to_h) ? args.first.to_h : kwargs
138
+ normalized = inputs.transform_keys(&:to_sym)
139
+ only ? normalized.slice(*keys) : normalized.reject { |key, _value| keys.include?(key) }
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "contractable/acceptance"
4
+ require_relative "contractable/adapters"
5
+ require_relative "contractable/config"
6
+ require_relative "contractable/sugar_builder"
7
+ require_relative "contractable/runner"
8
+
9
+ module Igniter
10
+ module Embed
11
+ module Contractable
12
+ module_function
13
+
14
+ def build(name, &block)
15
+ config = Config.new(name: name)
16
+ evaluate_block(config, &block) if block
17
+ Runner.new(config: config)
18
+ end
19
+
20
+ def evaluate_block(config, &block)
21
+ if block.arity.zero?
22
+ SugarBuilder.new(config: config).instance_eval(&block)
23
+ else
24
+ block.call(config)
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Embed
5
+ class ContractsBuilder
6
+ def initialize(config:)
7
+ @config = config
8
+ end
9
+
10
+ def add(name_or_definition, definition = nil, as: nil, &block)
11
+ name, contract_definition = normalize_add_arguments(name_or_definition, definition, as: as)
12
+ config.contract(contract_definition, as: name)
13
+ build_contractable(name, contract_definition, &block) if block
14
+ self
15
+ end
16
+
17
+ private
18
+
19
+ attr_reader :config
20
+
21
+ def normalize_add_arguments(name_or_definition, definition, as:)
22
+ if ContractNaming.contract_class?(name_or_definition)
23
+ name = as && ContractNaming.normalize_contract_name(as)
24
+ return [name, name_or_definition]
25
+ end
26
+
27
+ name = ContractNaming.normalize_contract_name(as || name_or_definition)
28
+ raise InvalidContractRegistrationError, "contract definition is required" unless definition
29
+
30
+ [name, definition]
31
+ end
32
+
33
+ def build_contractable(name, contract_definition, &block)
34
+ contractable_config = Contractable::Config.new(name: contractable_name(name, contract_definition))
35
+ builder = Contractable::SugarBuilder.new(config: contractable_config)
36
+ if block.arity.zero?
37
+ builder.instance_eval(&block)
38
+ else
39
+ block.call(builder)
40
+ end
41
+ return unless builder.configured?
42
+
43
+ config.contractable(contractable_config)
44
+ end
45
+
46
+ def contractable_name(name, contract_definition)
47
+ return name if name
48
+ return ContractNaming.infer_contract_name(contract_definition) if ContractNaming.contract_class?(contract_definition)
49
+
50
+ raise InvalidContractRegistrationError, "contractable name is required"
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Embed
5
+ Error = Class.new(StandardError)
6
+ DiscoveryError = Class.new(Error)
7
+ DuplicateContractError = Class.new(Error)
8
+ InvalidContractRegistrationError = Class.new(Error)
9
+ SugarError = Class.new(Error)
10
+ UnknownContractError = Class.new(Error)
11
+ UnknownContractableError = Class.new(Error)
12
+ RailsIntegrationError = Class.new(Error)
13
+ end
14
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Embed
5
+ class ExecutionEnvelope
6
+ attr_reader :name, :inputs, :result, :outputs, :errors, :metadata
7
+
8
+ def initialize(name:, inputs:, result: nil, errors: nil, metadata: {})
9
+ @name = name.to_sym
10
+ @inputs = inputs.freeze
11
+ @result = result
12
+ @outputs = result ? result.outputs : Igniter::Contracts::NamedValues.new({})
13
+ @errors = Array(errors).freeze
14
+ @metadata = metadata.freeze
15
+ freeze
16
+ end
17
+
18
+ def success?
19
+ result && errors.empty?
20
+ end
21
+
22
+ def failure?
23
+ !success?
24
+ end
25
+
26
+ def output(name)
27
+ outputs.fetch(name.to_sym)
28
+ end
29
+
30
+ def to_h
31
+ {
32
+ name: name,
33
+ inputs: inputs,
34
+ success: success?,
35
+ outputs: outputs.to_h,
36
+ errors: errors.map { |error| normalize_error(error) },
37
+ metadata: metadata
38
+ }
39
+ end
40
+
41
+ private
42
+
43
+ def normalize_error(error)
44
+ return error.to_h if error.respond_to?(:to_h)
45
+
46
+ { class: error.class.name, message: error.message }
47
+ end
48
+ end
49
+ end
50
+ end