julewire-core 1.0.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 (164) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +6 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +73 -0
  5. data/docs/advanced-configuration.md +66 -0
  6. data/docs/attribute-keys.md +74 -0
  7. data/docs/configuration.md +327 -0
  8. data/docs/context-and-propagation.md +353 -0
  9. data/docs/contracts.md +211 -0
  10. data/docs/development.md +49 -0
  11. data/docs/extensions-and-api.md +567 -0
  12. data/docs/health-schema.md +104 -0
  13. data/docs/instrumentation-cheatsheet.md +29 -0
  14. data/docs/internals.md +135 -0
  15. data/docs/outputs-and-lifecycle.md +206 -0
  16. data/docs/quickstart.md +133 -0
  17. data/docs/record-sources.md +17 -0
  18. data/docs/records-and-data-policy.md +230 -0
  19. data/docs/security-and-wire.md +45 -0
  20. data/docs/tail.md +91 -0
  21. data/exe/julewire +6 -0
  22. data/julewire-core.gemspec +41 -0
  23. data/lib/julewire/core/cli/doctor.rb +143 -0
  24. data/lib/julewire/core/cli/line_helpers.rb +77 -0
  25. data/lib/julewire/core/cli/log_formats/console_text.rb +25 -0
  26. data/lib/julewire/core/cli/log_formats/core_json_decoder.rb +46 -0
  27. data/lib/julewire/core/cli/log_formats/core_json_encoder.rb +21 -0
  28. data/lib/julewire/core/cli/log_formats/record_decoder.rb +39 -0
  29. data/lib/julewire/core/cli/log_formats.rb +123 -0
  30. data/lib/julewire/core/cli/tail.rb +153 -0
  31. data/lib/julewire/core/cli/transcode.rb +105 -0
  32. data/lib/julewire/core/cli.rb +73 -0
  33. data/lib/julewire/core/configuration.rb +99 -0
  34. data/lib/julewire/core/context_store.rb +384 -0
  35. data/lib/julewire/core/destinations/chaos_output.rb +91 -0
  36. data/lib/julewire/core/destinations/collection.rb +177 -0
  37. data/lib/julewire/core/destinations/definition.rb +125 -0
  38. data/lib/julewire/core/destinations/destination.rb +268 -0
  39. data/lib/julewire/core/destinations/registry.rb +81 -0
  40. data/lib/julewire/core/destinations/sink.rb +35 -0
  41. data/lib/julewire/core/destinations/synchronized_output.rb +57 -0
  42. data/lib/julewire/core/destinations/tail_sampling.rb +321 -0
  43. data/lib/julewire/core/destinations/write_step.rb +119 -0
  44. data/lib/julewire/core/destinations.rb +33 -0
  45. data/lib/julewire/core/diagnostics/callback_notifier.rb +63 -0
  46. data/lib/julewire/core/diagnostics/doctor.rb +114 -0
  47. data/lib/julewire/core/diagnostics/failure_snapshot.rb +39 -0
  48. data/lib/julewire/core/diagnostics/health.rb +144 -0
  49. data/lib/julewire/core/diagnostics/integration_health_store.rb +64 -0
  50. data/lib/julewire/core/diagnostics/internal_records.rb +61 -0
  51. data/lib/julewire/core/diagnostics/invalid_severity_reporter.rb +112 -0
  52. data/lib/julewire/core/diagnostics/meta_observer.rb +161 -0
  53. data/lib/julewire/core/diagnostics/process_integration_health.rb +26 -0
  54. data/lib/julewire/core/diagnostics/tail/renderer.rb +36 -0
  55. data/lib/julewire/core/diagnostics/tail.rb +168 -0
  56. data/lib/julewire/core/diagnostics.rb +8 -0
  57. data/lib/julewire/core/error.rb +7 -0
  58. data/lib/julewire/core/execution/boundary.rb +106 -0
  59. data/lib/julewire/core/execution/handle.rb +77 -0
  60. data/lib/julewire/core/execution/lineage.rb +192 -0
  61. data/lib/julewire/core/execution/measurement_handle.rb +28 -0
  62. data/lib/julewire/core/execution/no_current_error.rb +9 -0
  63. data/lib/julewire/core/execution/scope.rb +246 -0
  64. data/lib/julewire/core/execution/scope_fields.rb +76 -0
  65. data/lib/julewire/core/execution/scope_identity.rb +71 -0
  66. data/lib/julewire/core/execution/scope_snapshot.rb +92 -0
  67. data/lib/julewire/core/execution/summary_state.rb +206 -0
  68. data/lib/julewire/core/execution/view.rb +56 -0
  69. data/lib/julewire/core/facade_methods.rb +181 -0
  70. data/lib/julewire/core/fields/attribute_keys.rb +54 -0
  71. data/lib/julewire/core/fields/attributes_proxy.rb +11 -0
  72. data/lib/julewire/core/fields/bags.rb +123 -0
  73. data/lib/julewire/core/fields/carry_proxy.rb +22 -0
  74. data/lib/julewire/core/fields/context_proxy.rb +11 -0
  75. data/lib/julewire/core/fields/field_set.rb +78 -0
  76. data/lib/julewire/core/fields/field_stack.rb +269 -0
  77. data/lib/julewire/core/fields/internal/deletion.rb +68 -0
  78. data/lib/julewire/core/fields/internal.rb +87 -0
  79. data/lib/julewire/core/fields/lookup.rb +35 -0
  80. data/lib/julewire/core/fields/section_proxy.rb +88 -0
  81. data/lib/julewire/core/fields/stack_set.rb +69 -0
  82. data/lib/julewire/core/fields/static_labels.rb +43 -0
  83. data/lib/julewire/core/fields/summary_proxy.rb +62 -0
  84. data/lib/julewire/core/integration/configurable.rb +52 -0
  85. data/lib/julewire/core/integration/destination_health.rb +43 -0
  86. data/lib/julewire/core/integration/event_subscriber.rb +62 -0
  87. data/lib/julewire/core/integration/facade.rb +131 -0
  88. data/lib/julewire/core/integration/fork_hooks.rb +79 -0
  89. data/lib/julewire/core/integration/health.rb +41 -0
  90. data/lib/julewire/core/integration/ivar_state.rb +38 -0
  91. data/lib/julewire/core/integration/lifecycle.rb +22 -0
  92. data/lib/julewire/core/integration/scoped.rb +34 -0
  93. data/lib/julewire/core/integration/settings.rb +92 -0
  94. data/lib/julewire/core/integration/subscriber_install.rb +39 -0
  95. data/lib/julewire/core/integration/subscription.rb +29 -0
  96. data/lib/julewire/core/integration/values.rb +192 -0
  97. data/lib/julewire/core/lifecycle_error.rb +7 -0
  98. data/lib/julewire/core/local_storage.rb +91 -0
  99. data/lib/julewire/core/processing/level_threshold.rb +53 -0
  100. data/lib/julewire/core/processing/match.rb +74 -0
  101. data/lib/julewire/core/processing/pipeline.rb +360 -0
  102. data/lib/julewire/core/processing/processor_chain.rb +69 -0
  103. data/lib/julewire/core/processing/processor_registry.rb +115 -0
  104. data/lib/julewire/core/processing/processor_wrapper.rb +44 -0
  105. data/lib/julewire/core/processing/record_field_transform.rb +124 -0
  106. data/lib/julewire/core/processing/sampling.rb +109 -0
  107. data/lib/julewire/core/processing.rb +41 -0
  108. data/lib/julewire/core/propagation/carrier.rb +93 -0
  109. data/lib/julewire/core/propagation.rb +50 -0
  110. data/lib/julewire/core/records/console_formatter.rb +24 -0
  111. data/lib/julewire/core/records/deconstruct.rb +19 -0
  112. data/lib/julewire/core/records/display_message.rb +166 -0
  113. data/lib/julewire/core/records/draft.rb +576 -0
  114. data/lib/julewire/core/records/formatter.rb +14 -0
  115. data/lib/julewire/core/records/lazy_emit_input.rb +99 -0
  116. data/lib/julewire/core/records/metadata.rb +23 -0
  117. data/lib/julewire/core/records/public_projection.rb +51 -0
  118. data/lib/julewire/core/records/raw_input.rb +41 -0
  119. data/lib/julewire/core/records/record.rb +175 -0
  120. data/lib/julewire/core/records/severity.rb +44 -0
  121. data/lib/julewire/core/runtime.rb +515 -0
  122. data/lib/julewire/core/runtime_locator.rb +20 -0
  123. data/lib/julewire/core/runtime_registry.rb +48 -0
  124. data/lib/julewire/core/runtime_state.rb +39 -0
  125. data/lib/julewire/core/scheduling/deadline.rb +24 -0
  126. data/lib/julewire/core/scheduling/deadline_scheduler.rb +207 -0
  127. data/lib/julewire/core/scheduling/shared_scheduler.rb +48 -0
  128. data/lib/julewire/core/sentinel.rb +18 -0
  129. data/lib/julewire/core/serialization/backtrace_limiter.rb +50 -0
  130. data/lib/julewire/core/serialization/bounded_transform.rb +55 -0
  131. data/lib/julewire/core/serialization/bounded_traversal.rb +274 -0
  132. data/lib/julewire/core/serialization/deep_compact_empty.rb +67 -0
  133. data/lib/julewire/core/serialization/deep_freeze.rb +63 -0
  134. data/lib/julewire/core/serialization/encoding_sanitizer.rb +40 -0
  135. data/lib/julewire/core/serialization/exception_shape.rb +88 -0
  136. data/lib/julewire/core/serialization/json_encoder.rb +69 -0
  137. data/lib/julewire/core/serialization/serializer.rb +233 -0
  138. data/lib/julewire/core/serialization/serializer_pool.rb +21 -0
  139. data/lib/julewire/core/serialization/text_encoder.rb +147 -0
  140. data/lib/julewire/core/serialization/value_copy.rb +209 -0
  141. data/lib/julewire/core/serialization/value_traversal.rb +150 -0
  142. data/lib/julewire/core/testing/chaos/catalog.rb +72 -0
  143. data/lib/julewire/core/testing/chaos/core_runtime.rb +120 -0
  144. data/lib/julewire/core/testing/chaos/destination.rb +55 -0
  145. data/lib/julewire/core/testing/chaos/emitter.rb +20 -0
  146. data/lib/julewire/core/testing/chaos/raising_output.rb +42 -0
  147. data/lib/julewire/core/testing/chaos.rb +80 -0
  148. data/lib/julewire/core/testing/contracts/component.rb +162 -0
  149. data/lib/julewire/core/testing/contracts/deadline_scheduler.rb +59 -0
  150. data/lib/julewire/core/testing/contracts/integration.rb +166 -0
  151. data/lib/julewire/core/testing/contracts/integration_fields.rb +36 -0
  152. data/lib/julewire/core/testing/contracts/record_draft.rb +37 -0
  153. data/lib/julewire/core/testing/contracts/runtime.rb +178 -0
  154. data/lib/julewire/core/testing/contracts/wire.rb +60 -0
  155. data/lib/julewire/core/testing/contracts.rb +24 -0
  156. data/lib/julewire/core/testing/coverage.rb +58 -0
  157. data/lib/julewire/core/testing/test_reports.rb +78 -0
  158. data/lib/julewire/core/testing.rb +122 -0
  159. data/lib/julewire/core/validation.rb +69 -0
  160. data/lib/julewire/core/version.rb +7 -0
  161. data/lib/julewire/core.rb +80 -0
  162. data/lib/julewire/error.rb +5 -0
  163. data/lib/julewire-core.rb +3 -0
  164. metadata +237 -0
@@ -0,0 +1,360 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module Core
5
+ module Processing
6
+ class Pipeline # rubocop:disable Metrics/ClassLength -- Pipeline owns normalization, processors, and destinations.
7
+ COUNTER_KEYS = %i[
8
+ callback_error
9
+ entered
10
+ level_dropped
11
+ no_output_dropped
12
+ processor_dropped
13
+ processor_error
14
+ ].freeze
15
+ EMPTY_HASH = {}.freeze
16
+ private_constant :EMPTY_HASH
17
+
18
+ # @api integration_spi
19
+ # Integration-facing raw/normalized record pipeline.
20
+ def initialize(configuration:, invalid_severity_reporter: Diagnostics::InvalidSeverityReporter.counter)
21
+ @on_drop = configuration.on_drop
22
+ @on_failure = configuration.on_failure
23
+ @invalid_severity_reporter = invalid_severity_reporter
24
+ @destinations = build_destinations(configuration)
25
+ @labels = Fields::FieldSet.deep_symbolize_keys(configuration.labels.to_h)
26
+ @threshold = build_threshold(configuration)
27
+ @error_backtrace_lines = configuration.error_backtrace_lines
28
+ Validation.validate_callable!(configuration.on_failure, name: :on_failure, allow_nil: true)
29
+ @processor_chain = build_processor_chain(configuration)
30
+ @processors_empty = @processor_chain.empty?
31
+ @labels_empty = @labels.empty?
32
+ initialize_tracking
33
+ end
34
+
35
+ def emit(input = Core::UNSET, **fields, &)
36
+ emit_with_level_check(input, true, fields, &)
37
+ end
38
+
39
+ def emit_without_level(input = Core::UNSET, **fields, &)
40
+ emit_with_level_check(input, false, fields, &)
41
+ end
42
+
43
+ # Trusted integration path for adapter-owned input hashes.
44
+ def emit_integration(input, enforce_level: true)
45
+ emit_input_with_guard(input, enforce_level: enforce_level, lazy: false) do
46
+ build_draft(input, input_owned: true)
47
+ end
48
+ end
49
+
50
+ # Runtime summaries already carry their captured scope fields.
51
+ def emit_isolated_input(input, enforce_level: true)
52
+ emit_input_with_guard(input, enforce_level: enforce_level, lazy: false) do
53
+ build_isolated_draft(input)
54
+ end
55
+ end
56
+
57
+ # Trusted normalized-record path used by runtime summaries and bridge envelopes.
58
+ def emit_record(record, enforce_level: true)
59
+ return if no_output_dropped?
60
+
61
+ degradation_marker = @health.degradation_marker
62
+ emit_validated_record(record, enforce_level: enforce_level)
63
+ finish_emit_attempt(degradation_marker)
64
+ rescue StandardError => e
65
+ record_emit_failure(e, record)
66
+ end
67
+
68
+ def after_fork!
69
+ initialize_tracking
70
+ @destinations.after_fork!
71
+ self
72
+ end
73
+
74
+ def flush(timeout: nil)
75
+ @destinations.flush(timeout: timeout)
76
+ end
77
+
78
+ def close(timeout: nil, skip_resource_identities: nil)
79
+ @destinations.close(timeout: timeout, skip_resource_identities: skip_resource_identities)
80
+ end
81
+
82
+ def lifecycle_resource_identities
83
+ @destinations.lifecycle_resource_identities
84
+ end
85
+
86
+ def health
87
+ {
88
+ configured: !@destinations.empty?,
89
+ counts: pipeline_counts_snapshot,
90
+ destinations: @destinations.health,
91
+ last_callback_failure: @health.last_callback_failure,
92
+ last_failure: @health.last_failure,
93
+ status: pipeline_status
94
+ }
95
+ end
96
+
97
+ private
98
+
99
+ def build_destinations(configuration)
100
+ Destinations::Collection.build(
101
+ configuration: configuration,
102
+ defaults: destination_defaults(configuration),
103
+ on_drop: method(:record_destination_drop),
104
+ on_failure: method(:notify_failure)
105
+ )
106
+ end
107
+
108
+ def destination_defaults(configuration)
109
+ {
110
+ encoder: Serialization::JsonEncoder.new(max_backtrace_lines: configuration.error_backtrace_lines),
111
+ formatter: Records::Formatter.new,
112
+ error_backtrace_lines: configuration.error_backtrace_lines,
113
+ on_drop: configuration.on_drop,
114
+ on_failure: configuration.on_failure
115
+ }
116
+ end
117
+
118
+ def build_threshold(configuration)
119
+ LevelThreshold.new(
120
+ level: configuration.level,
121
+ invalid_severity_reporter: @invalid_severity_reporter
122
+ )
123
+ end
124
+
125
+ def build_processor_chain(configuration)
126
+ ProcessorChain.new(
127
+ processors: configuration.processors.to_a.freeze,
128
+ error_backtrace_lines: @error_backtrace_lines,
129
+ on_error: method(:record_processor_error)
130
+ )
131
+ end
132
+
133
+ def emit_with_level_check(input, enforce_level, fields, &)
134
+ input = Core.emit_input(input, fields)
135
+
136
+ emit_input_with_guard(input, enforce_level: enforce_level, lazy: block_given?) do
137
+ input = Records::LazyEmitInput.call(input, &) if block_given?
138
+ build_draft(input)
139
+ end
140
+ end
141
+
142
+ def emit_input_with_guard(input, enforce_level:, lazy:)
143
+ return if no_output_dropped?
144
+
145
+ degradation_marker = @health.degradation_marker
146
+ if raw_input_blocked?(input, enforce_level: enforce_level, lazy: lazy)
147
+ increment_pipeline_counter(:level_dropped)
148
+ else
149
+ emit_prepared_draft(yield, enforce_level: enforce_level, merge_static_labels: false)
150
+ end
151
+ finish_emit_attempt(degradation_marker)
152
+ rescue StandardError => e
153
+ notify_failure(e, phase: :emit)
154
+ emit_internal_error_record(e)
155
+ nil
156
+ end
157
+
158
+ def no_output_dropped?
159
+ return false unless @destinations.empty?
160
+
161
+ record_no_output_drop
162
+ true
163
+ end
164
+
165
+ def finish_emit_attempt(degradation_marker)
166
+ clear_degradation_if_unchanged(degradation_marker)
167
+ nil
168
+ end
169
+
170
+ def emit_validated_record(record, enforce_level:)
171
+ Records::Record.validate_normalized!(record)
172
+ return emit_fast_record(record, enforce_level: enforce_level) if fast_record_path?
173
+
174
+ emit_prepared_draft(Records::Draft.from_record(record, freeze_sections: false),
175
+ enforce_level: enforce_level)
176
+ nil
177
+ end
178
+
179
+ def fast_record_path?
180
+ @labels_empty && @processors_empty
181
+ end
182
+
183
+ def emit_fast_record(record, enforce_level:)
184
+ if enforce_level && !emit_record?(record)
185
+ increment_pipeline_counter(:level_dropped)
186
+ return
187
+ end
188
+
189
+ increment_pipeline_counter(:entered)
190
+ emit_to_destinations(record)
191
+ nil
192
+ end
193
+
194
+ def raw_input_blocked?(input, enforce_level:, lazy:)
195
+ enforce_level &&
196
+ (!lazy || Records::RawInput.explicit_severity?(input)) &&
197
+ !@threshold.raw_input_allowed?(input)
198
+ end
199
+
200
+ def build_draft(input, input_owned: false)
201
+ store = ContextStore.current
202
+ build_draft_from(
203
+ input,
204
+ input_owned: input_owned,
205
+ context: store.context_hash,
206
+ neutral: store.neutral_hash,
207
+ attributes: store.attributes_hash,
208
+ carry: store.carry_hash,
209
+ scope: store.current_scope_or_snapshot
210
+ )
211
+ end
212
+
213
+ def build_isolated_draft(input, input_owned: false)
214
+ build_draft_from(
215
+ input,
216
+ input_owned: input_owned,
217
+ context: EMPTY_HASH,
218
+ neutral: EMPTY_HASH,
219
+ attributes: EMPTY_HASH,
220
+ carry: EMPTY_HASH,
221
+ scope: nil
222
+ )
223
+ end
224
+
225
+ def build_draft_from(input, input_owned:, context:, neutral:, attributes:, carry:, scope:)
226
+ Records::Draft.build_pipeline_owned(
227
+ input,
228
+ context: context,
229
+ neutral: neutral,
230
+ attributes: attributes,
231
+ carry: carry,
232
+ static_labels: @labels,
233
+ input_owned: input_owned,
234
+ freeze_sections: @processors_empty,
235
+ scope: scope,
236
+ error_backtrace_lines: @error_backtrace_lines,
237
+ invalid_severity_reporter: @invalid_severity_reporter
238
+ )
239
+ end
240
+
241
+ def initialize_tracking
242
+ @health = Diagnostics::Health.new(
243
+ counter_keys: COUNTER_KEYS,
244
+ callback_metadata: {},
245
+ callback_failure_counter: :callback_error
246
+ )
247
+ end
248
+
249
+ def pipeline_status
250
+ return :unconfigured if @destinations.empty?
251
+
252
+ @health.degraded? ? :degraded : :ok
253
+ end
254
+
255
+ def clear_degradation_if_unchanged(marker)
256
+ @health.clear_degradation_if_unchanged(marker)
257
+ end
258
+
259
+ def merge_static_labels(draft)
260
+ return draft if @labels_empty
261
+
262
+ draft[:labels] = Fields::FieldSet.merge(@labels, draft.fetch(:labels))
263
+ draft
264
+ end
265
+
266
+ def emit_record?(record_or_draft)
267
+ @threshold.allow?(record_or_draft.fetch(:severity))
268
+ end
269
+
270
+ def emit_prepared_draft(draft, enforce_level:, merge_static_labels: true)
271
+ if enforce_level && !emit_record?(draft)
272
+ increment_pipeline_counter(:level_dropped)
273
+ return
274
+ end
275
+
276
+ draft = merge_static_labels(draft) if merge_static_labels
277
+ increment_pipeline_counter(:entered)
278
+ emit_processed_draft(draft, enforce_level: enforce_level)
279
+ rescue StandardError => e
280
+ record_emit_failure(e, draft)
281
+ end
282
+
283
+ def emit_processed_draft(draft, enforce_level:)
284
+ return emit_to_destinations(draft.to_record) if @processors_empty
285
+
286
+ processed = @processor_chain.call(draft)
287
+ if processed.equal?(ProcessorChain::DROP)
288
+ increment_pipeline_counter(:processor_dropped)
289
+ return
290
+ end
291
+
292
+ processed, enforce_processed_level = processed_draft_and_level(processed, enforce_level)
293
+
294
+ if enforce_processed_level && !emit_record?(processed)
295
+ increment_pipeline_counter(:level_dropped)
296
+ return
297
+ end
298
+
299
+ emit_to_destinations(processed.to_record)
300
+ end
301
+
302
+ def processed_draft_and_level(processed, default_enforce_level)
303
+ if processed.is_a?(ProcessorChain::ErrorResult)
304
+ [processed.draft, false]
305
+ else
306
+ [processed, default_enforce_level]
307
+ end
308
+ end
309
+
310
+ def record_processor_error(error, record_metadata)
311
+ increment_pipeline_counter(:processor_error)
312
+ notify_failure(error, phase: :processor, record_metadata: record_metadata)
313
+ end
314
+
315
+ def emit_internal_error_record(error)
316
+ emit_prepared_draft(
317
+ Diagnostics::InternalRecords.emit_error(error, error_backtrace_lines: @error_backtrace_lines),
318
+ enforce_level: false
319
+ )
320
+ rescue StandardError => e
321
+ notify_failure(e, phase: :internal_error_record)
322
+ nil
323
+ end
324
+
325
+ def emit_to_destinations(record)
326
+ @destinations.emit(record)
327
+ end
328
+
329
+ def record_no_output_drop
330
+ increment_pipeline_counter(:no_output_dropped)
331
+ nil
332
+ end
333
+
334
+ def notify_failure(error, **metadata)
335
+ @health.record_failure(error, callback: @on_failure, **metadata)
336
+ end
337
+
338
+ def record_destination_drop(reason, **metadata)
339
+ callback_result = Diagnostics::CallbackNotifier.call(@on_drop, reason, metadata.merge(reason: reason))
340
+ return unless Diagnostics::CallbackNotifier.failure?(callback_result)
341
+
342
+ @health.record_callback_failure(callback_result)
343
+ end
344
+
345
+ def record_emit_failure(error, record)
346
+ notify_failure(error, phase: :emit_record, record_metadata: Records::Metadata.call(record))
347
+ nil
348
+ end
349
+
350
+ def pipeline_counts_snapshot
351
+ @health.counts
352
+ end
353
+
354
+ def increment_pipeline_counter(key)
355
+ @health.increment(key)
356
+ end
357
+ end
358
+ end
359
+ end
360
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module Core
5
+ module Processing
6
+ class ProcessorChain
7
+ DROP = Core.sentinel(:drop)
8
+ ErrorResult = Data.define(:draft)
9
+
10
+ def initialize(processors:, error_backtrace_lines:, on_error:)
11
+ @processors = processors
12
+ @error_backtrace_lines = error_backtrace_lines
13
+ @on_error = on_error
14
+ end
15
+
16
+ def empty? = @processors.empty?
17
+
18
+ def call(draft)
19
+ current = draft
20
+
21
+ @processors.each do |processor|
22
+ current = apply_processor_result(current, processor.call(current))
23
+ break if current == :drop
24
+ rescue StandardError => e
25
+ action = handle_processor_error(processor, e, current)
26
+ case action
27
+ when :continue
28
+ next
29
+ when :drop
30
+ return DROP
31
+ else
32
+ return action
33
+ end
34
+ end
35
+
36
+ current == :drop ? DROP : current
37
+ end
38
+
39
+ private
40
+
41
+ def apply_processor_result(current, result)
42
+ return :drop if result == :drop
43
+ return result if result.is_a?(Records::Draft)
44
+
45
+ current
46
+ end
47
+
48
+ def handle_processor_error(processor, error, current)
49
+ record_metadata = Records::Metadata.call(current)
50
+ @on_error.call(error, record_metadata)
51
+
52
+ return :continue if processor.on_error == ProcessorWrapper::FAIL_OPEN
53
+ return :drop if processor.on_error == ProcessorWrapper::DROP
54
+
55
+ ErrorResult.new(processor_error_record(processor, error, record_metadata))
56
+ end
57
+
58
+ def processor_error_record(processor, error, record_metadata)
59
+ Diagnostics::InternalRecords.processor_error(
60
+ processor_name: processor.processor_name,
61
+ error: error,
62
+ record_metadata: record_metadata,
63
+ error_backtrace_lines: @error_backtrace_lines
64
+ )
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module Core
5
+ module Processing
6
+ class ProcessorRegistry
7
+ Entry = Data.define(:entry, :arguments, :options, :on_error, :factory)
8
+
9
+ def initialize(entries = [])
10
+ @entries = entries.map { normalize_entry(it) }
11
+ end
12
+
13
+ def use(processor = nil, *arguments, **options, &block)
14
+ add(resolve_processor(processor, block), arguments, options, prepend: false)
15
+ end
16
+
17
+ def prepend(processor = nil, *arguments, **options, &block)
18
+ add(resolve_processor(processor, block), arguments, options, prepend: true)
19
+ end
20
+
21
+ def clear
22
+ @entries.clear
23
+ self
24
+ end
25
+
26
+ def to_a
27
+ @entries.map { materialize(it) }
28
+ end
29
+
30
+ def copy
31
+ self.class.new(@entries)
32
+ end
33
+
34
+ def freeze
35
+ @entries.freeze
36
+ super
37
+ end
38
+
39
+ private
40
+
41
+ def resolve_processor(processor, block)
42
+ raise ArgumentError, "pass processor or block, not both" if processor && block
43
+
44
+ processor || block
45
+ end
46
+
47
+ def add(processor, arguments, options, prepend:)
48
+ raise ArgumentError, "processor or block is required" unless processor
49
+
50
+ entry = build_entry(processor, arguments, options)
51
+ prepend ? @entries.unshift(entry) : @entries << entry
52
+ self
53
+ end
54
+
55
+ def build_entry(processor, arguments = [], options = {})
56
+ options = options.dup
57
+ on_error = ProcessorWrapper.normalize_policy(
58
+ options.delete(:on_error) { ProcessorWrapper::FAIL_CLOSED }
59
+ )
60
+
61
+ if factory_processor?(processor)
62
+ Entry.new(
63
+ entry: processor.to_sym,
64
+ arguments: arguments.dup.freeze,
65
+ options: options.freeze,
66
+ on_error: on_error,
67
+ factory: true
68
+ )
69
+ elsif processor.is_a?(Class)
70
+ Entry.new(
71
+ entry: processor,
72
+ arguments: arguments.dup.freeze,
73
+ options: options.freeze,
74
+ on_error: on_error,
75
+ factory: false
76
+ )
77
+ else
78
+ validate_processor_object!(processor, arguments, options)
79
+ Entry.new(entry: processor, arguments: [].freeze, options: {}.freeze, on_error: on_error, factory: false)
80
+ end
81
+ end
82
+
83
+ def normalize_entry(entry)
84
+ entry.is_a?(Entry) ? entry : build_entry(entry)
85
+ end
86
+
87
+ def materialize(entry)
88
+ processor = if entry.factory
89
+ Processing.build(entry.entry, *entry.arguments, **entry.options)
90
+ elsif entry.entry.is_a?(Class)
91
+ entry.entry.new(*entry.arguments, **entry.options)
92
+ else
93
+ entry.entry
94
+ end
95
+ ProcessorWrapper.new(processor, on_error: entry.on_error)
96
+ end
97
+
98
+ def factory_processor?(processor)
99
+ processor.respond_to?(:to_sym) && Processing.factory_for(processor)
100
+ end
101
+
102
+ def validate_processor_object!(processor, arguments, options)
103
+ raise ArgumentError, "unknown processor kind #{processor.to_sym.inspect}" if processor.respond_to?(:to_sym)
104
+
105
+ raise ArgumentError, "processor constructor arguments require a class" unless arguments.empty?
106
+ raise ArgumentError, "processor options require a class" unless options.empty?
107
+
108
+ return if processor.respond_to?(:call)
109
+
110
+ raise ArgumentError, "processor must respond to call"
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module Core
5
+ module Processing
6
+ class ProcessorWrapper
7
+ FAIL_CLOSED = :fail_closed
8
+ FAIL_OPEN = :fail_open
9
+ DROP = :drop
10
+ POLICIES = [FAIL_CLOSED, FAIL_OPEN, DROP].freeze
11
+
12
+ attr_reader :on_error
13
+
14
+ class << self
15
+ def normalize_policy(value)
16
+ Validation.validate_symbol_choice!(value, name: "processor on_error", choices: POLICIES)
17
+ end
18
+ end
19
+
20
+ def initialize(processor, on_error: FAIL_CLOSED)
21
+ validate_processor!(processor)
22
+ @processor = processor
23
+ @on_error = self.class.normalize_policy(on_error)
24
+ end
25
+
26
+ def call(...)
27
+ @processor.call(...)
28
+ end
29
+
30
+ def processor_name
31
+ @processor.class.name
32
+ end
33
+
34
+ private
35
+
36
+ def validate_processor!(processor)
37
+ return if processor.respond_to?(:call)
38
+
39
+ raise ArgumentError, "processor must respond to call"
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module Core
5
+ module Processing
6
+ class RecordFieldTransform
7
+ CONTAINER_KEYS = Fields::Bags.transform_container_sections
8
+ SCALAR_KEYS = (Records::Record::REQUIRED_KEYS - CONTAINER_KEYS).freeze
9
+ CONTAINER_KEY_SET = CONTAINER_KEYS.to_h { [it, true] }.freeze
10
+ SCALAR_KEY_SET = SCALAR_KEYS.to_h { [it, true] }.freeze
11
+ private_constant :CONTAINER_KEYS
12
+ private_constant :SCALAR_KEYS
13
+ private_constant :CONTAINER_KEY_SET
14
+ private_constant :SCALAR_KEY_SET
15
+
16
+ class << self
17
+ def container_keys = CONTAINER_KEYS
18
+
19
+ def scalar_keys = SCALAR_KEYS
20
+
21
+ def container_key?(key) = CONTAINER_KEY_SET.key?(key)
22
+
23
+ def scalar_key?(key) = SCALAR_KEY_SET.key?(key)
24
+ end
25
+
26
+ def initialize(
27
+ max_array_items: nil,
28
+ max_depth: nil,
29
+ max_hash_keys: nil,
30
+ max_string_bytes: nil,
31
+ preserve_top_level_keys: nil,
32
+ track_paths: false
33
+ )
34
+ @bounded_options = bounded_options(
35
+ max_array_items: max_array_items,
36
+ max_depth: max_depth,
37
+ max_hash_keys: max_hash_keys,
38
+ max_string_bytes: max_string_bytes
39
+ )
40
+ @preserve_top_level_key_set = Array(preserve_top_level_keys).to_h { [it, true] }
41
+ @track_paths = track_paths
42
+ end
43
+
44
+ def call(record, &)
45
+ record.each_with_object({}) do |(key, value), result|
46
+ result[key] = transform_record_field(key, value, record, &)
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def transform_record_field(key, value, record, &)
53
+ return transform_container(key, value, record, &) if transform_container?(key, value)
54
+ return value unless transform_scalar?(key)
55
+
56
+ transform_scalar(key, value, record, &)
57
+ end
58
+
59
+ def transform_container?(key, value)
60
+ value.is_a?(Hash) && self.class.container_key?(key)
61
+ end
62
+
63
+ def transform_scalar?(key)
64
+ self.class.scalar_key?(key) && !@preserve_top_level_key_set.key?(key)
65
+ end
66
+
67
+ def transform_container(top_level_key, value, record, &)
68
+ Serialization::BoundedTransform.call(
69
+ value,
70
+ **@bounded_options,
71
+ track_paths: @track_paths
72
+ ) do |item, key:, path:, depth:, **|
73
+ yield(
74
+ item,
75
+ key: key,
76
+ path: path,
77
+ prefixed_path: prefixed_path(path, top_level_key),
78
+ original: record,
79
+ depth: depth,
80
+ top_level_key: top_level_key
81
+ )
82
+ end
83
+ end
84
+
85
+ def transform_scalar(top_level_key, value, record, &)
86
+ path = top_level_key.to_s if @track_paths
87
+ transformed = yield(
88
+ value,
89
+ key: top_level_key,
90
+ path: path,
91
+ prefixed_path: path,
92
+ original: record,
93
+ depth: 1,
94
+ top_level_key: top_level_key
95
+ )
96
+ transformed = value if transformed.equal?(Serialization::BoundedTransform::CONTINUE)
97
+ bound_value(transformed)
98
+ end
99
+
100
+ def bound_value(value)
101
+ Serialization::BoundedTransform.call(
102
+ value,
103
+ **@bounded_options
104
+ )
105
+ end
106
+
107
+ def prefixed_path(path, top_level_key)
108
+ return unless path
109
+
110
+ "#{top_level_key}.#{path}"
111
+ end
112
+
113
+ def bounded_options(max_array_items:, max_depth:, max_hash_keys:, max_string_bytes:)
114
+ {
115
+ max_array_items: max_array_items,
116
+ max_depth: max_depth,
117
+ max_hash_keys: max_hash_keys,
118
+ max_string_bytes: max_string_bytes
119
+ }.compact
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end