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,321 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module Core
5
+ module Destinations
6
+ # @api extension
7
+ class TailSampling # rubocop:disable Metrics/ClassLength -- Owns execution buffering plus destination lifecycle.
8
+ COUNTER_KEYS = %i[
9
+ buffered
10
+ emitted
11
+ failures
12
+ immediate
13
+ overflow_dropped
14
+ policy_dropped
15
+ received
16
+ ].freeze
17
+ DEFAULT_MAX_EXECUTIONS = 1024
18
+ DEFAULT_MAX_RECORDS_PER_EXECUTION = 1000
19
+ DEFAULT_SAMPLE_RATE = 0.1
20
+ ERROR_RANK = Records::Severity.rank(:error)
21
+ OPTION_KEYS = %i[
22
+ decider
23
+ max_executions
24
+ max_records_per_execution
25
+ name
26
+ on_drop
27
+ on_failure
28
+ sample_rate
29
+ slow_ms
30
+ ].freeze
31
+ private_constant :COUNTER_KEYS, :ERROR_RANK, :OPTION_KEYS
32
+
33
+ TailOptions = Data.define(:decider, :max_executions, :max_records_per_execution, :name, :on_drop, :on_failure,
34
+ :sample_rate, :slow_ms)
35
+ private_constant :TailOptions
36
+
37
+ attr_reader :name
38
+
39
+ def initialize(destination:, **options)
40
+ Validation.validate_options!(options, OPTION_KEYS, name: :tail_sampling)
41
+ options = tail_options(options)
42
+ @destination = Registry.validate!(destination)
43
+ @name = Destinations.normalize_name(options.name)
44
+ @sample_rate = options.sample_rate
45
+ Processing::Sampling.threshold_for(options.sample_rate)
46
+ @slow_ms = validate_slow_ms(options.slow_ms)
47
+ @max_executions = Validation.validate_integer_limit!(
48
+ options.max_executions,
49
+ name: :max_executions,
50
+ positive: true
51
+ )
52
+ @max_records_per_execution = Validation.validate_integer_limit!(
53
+ options.max_records_per_execution,
54
+ name: :max_records_per_execution,
55
+ positive: true
56
+ )
57
+ Validation.validate_callable!(options.decider, name: :decider, allow_nil: true)
58
+ Validation.validate_callable!(options.on_drop, name: :on_drop, allow_nil: true)
59
+ Validation.validate_callable!(options.on_failure, name: :on_failure, allow_nil: true)
60
+ @decider = options.decider
61
+ @on_drop = options.on_drop
62
+ @on_failure = options.on_failure
63
+ initialize_state
64
+ end
65
+
66
+ def emit(record)
67
+ result = @mutex.synchronize { accept_record(record) }
68
+ result.losses.compact.each { notify_drop(it) }
69
+ result.records.each { emit_target(it) }
70
+ nil
71
+ rescue StandardError => e
72
+ record_failure(e, record)
73
+ nil
74
+ end
75
+
76
+ def flush(timeout: nil)
77
+ drain_and_lifecycle(:flush, timeout: timeout)
78
+ end
79
+
80
+ def close(timeout: nil)
81
+ drain_and_lifecycle(:close, timeout: timeout)
82
+ end
83
+
84
+ def after_fork!
85
+ @mutex.synchronize { initialize_buffer }
86
+ @destination.after_fork! if @destination.respond_to?(:after_fork!)
87
+ self
88
+ rescue StandardError => e
89
+ record_failure(e, nil, phase: :after_fork)
90
+ self
91
+ end
92
+
93
+ def resource_identity = self
94
+
95
+ def health
96
+ buffered_executions = @mutex.synchronize { @buffers.length }
97
+ destination = destination_health
98
+ status = health_status
99
+ @health.snapshot(
100
+ buffered_executions: buffered_executions,
101
+ destination: destination,
102
+ max_executions: @max_executions,
103
+ max_records_per_execution: @max_records_per_execution,
104
+ sample_rate: @sample_rate,
105
+ slow_ms: @slow_ms,
106
+ status: status
107
+ )
108
+ end
109
+
110
+ private
111
+
112
+ EmitResult = Data.define(:records, :losses)
113
+ Buffer = Data.define(:records)
114
+ private_constant :Buffer, :EmitResult
115
+
116
+ def tail_options(options)
117
+ TailOptions.new(
118
+ decider: options[:decider],
119
+ max_executions: options.fetch(:max_executions, DEFAULT_MAX_EXECUTIONS),
120
+ max_records_per_execution: options.fetch(:max_records_per_execution, DEFAULT_MAX_RECORDS_PER_EXECUTION),
121
+ name: options.fetch(:name, :tail_sampling),
122
+ on_drop: options[:on_drop],
123
+ on_failure: options[:on_failure],
124
+ sample_rate: options.fetch(:sample_rate, DEFAULT_SAMPLE_RATE),
125
+ slow_ms: options[:slow_ms]
126
+ )
127
+ end
128
+
129
+ def initialize_state
130
+ @mutex = Mutex.new
131
+ initialize_buffer
132
+ @health = Integration::DestinationHealth.new(counter_keys: COUNTER_KEYS)
133
+ end
134
+
135
+ def initialize_buffer
136
+ @buffers = {}
137
+ @order = []
138
+ end
139
+
140
+ def accept_record(record)
141
+ @health.increment(:received)
142
+ key = execution_key(record)
143
+ return immediate(record) unless key
144
+
145
+ if summary_record?(record)
146
+ finish_execution(key, record)
147
+ else
148
+ buffer_record(key, record)
149
+ end
150
+ end
151
+
152
+ def immediate(record)
153
+ @health.increment(:immediate)
154
+ EmitResult.new([record], [])
155
+ end
156
+
157
+ def buffer_record(key, record)
158
+ losses = ensure_execution_capacity(key)
159
+ buffer = (@buffers[key] ||= begin
160
+ @order << key
161
+ Buffer.new([])
162
+ end)
163
+ if buffer.records.length >= @max_records_per_execution
164
+ dropped = buffer.records.shift
165
+ losses << record_loss(:overflow_dropped, dropped)
166
+ end
167
+ buffer.records << record
168
+ @health.increment(:buffered)
169
+ EmitResult.new([], losses)
170
+ end
171
+
172
+ def ensure_execution_capacity(key)
173
+ return [] if @buffers.key?(key)
174
+ return [] if @buffers.length < @max_executions
175
+
176
+ oldest = @order.shift
177
+ buffer = @buffers.delete(oldest)
178
+ Array(buffer&.records).map { record_loss(:overflow_dropped, it) }
179
+ end
180
+
181
+ def finish_execution(key, summary)
182
+ buffer = @buffers.delete(key)
183
+ @order.delete(key)
184
+ records = Array(buffer&.records)
185
+ records << summary
186
+ if keep_execution?(summary, key)
187
+ @health.increment(:emitted, by: records.length)
188
+ EmitResult.new(records, [])
189
+ else
190
+ EmitResult.new([], records.map { record_loss(:policy_dropped, it) })
191
+ end
192
+ end
193
+
194
+ def drain_buffered_records
195
+ records = @order.flat_map { @buffers.fetch(it).records }
196
+ initialize_buffer
197
+ @health.increment(:emitted, by: records.length)
198
+ records
199
+ end
200
+
201
+ def drain_and_lifecycle(method_name, timeout:)
202
+ records = @mutex.synchronize { drain_buffered_records }
203
+ records.each { emit_target(it) }
204
+ lifecycle(method_name, timeout: timeout)
205
+ end
206
+
207
+ def keep_execution?(record, key)
208
+ return !!@decider.call(record, key: key) if @decider
209
+
210
+ default_keep_execution?(record, key)
211
+ rescue StandardError => e
212
+ record_failure(e, record, phase: :tail_sampling_decider)
213
+ false
214
+ end
215
+
216
+ def default_keep_execution?(record, key)
217
+ error_record?(record) || slow_record?(record) || Processing::Sampling.keep?(rate: @sample_rate, key: key)
218
+ end
219
+
220
+ def error_record?(record)
221
+ return true if record[:error]
222
+
223
+ Records::Severity.rank(record[:severity]) >= ERROR_RANK
224
+ rescue StandardError
225
+ false
226
+ end
227
+
228
+ def slow_record?(record)
229
+ return false unless @slow_ms
230
+
231
+ duration = field_value(record[:metrics], :duration_ms)
232
+ duration.is_a?(Numeric) && duration >= @slow_ms
233
+ end
234
+
235
+ def summary_record?(record) = record[:kind] == :summary
236
+
237
+ def execution_key(record)
238
+ reference = record.respond_to?(:lineage) ? record.lineage.root_reference : nil
239
+ reference = record[:execution] unless reference.is_a?(Hash)
240
+ id = field_value(reference, :id)
241
+ return unless id
242
+
243
+ [field_value(reference, :type), id].freeze
244
+ rescue StandardError
245
+ nil
246
+ end
247
+
248
+ def field_value(hash, key)
249
+ Fields::FieldSet.value_for(hash, key)
250
+ end
251
+
252
+ def emit_target(record)
253
+ @destination.emit(record)
254
+ rescue StandardError => e
255
+ record_failure(e, record, phase: :tail_sampling_destination)
256
+ end
257
+
258
+ def lifecycle(method_name, timeout:)
259
+ @destination.public_send(method_name, timeout: timeout) != false
260
+ rescue StandardError => e
261
+ record_failure(e, nil, phase: :tail_sampling_lifecycle, action: method_name)
262
+ false
263
+ end
264
+
265
+ def destination_health
266
+ @destination.health
267
+ rescue StandardError => e
268
+ Diagnostics::FailureSnapshot.build(e, destination: @destination.name, phase: :tail_sampling_health)
269
+ end
270
+
271
+ def record_loss(reason, record)
272
+ @health.record_loss(reason: reason, record_metadata: Records::Metadata.call(record))
273
+ rescue StandardError => e
274
+ record_failure(e, record, phase: :tail_sampling_drop)
275
+ end
276
+
277
+ def notify_drop(loss)
278
+ @on_drop&.call(loss.fetch(:reason), loss)
279
+ rescue StandardError => e
280
+ record_failure(e, nil, phase: :tail_sampling_drop_callback)
281
+ end
282
+
283
+ def record_failure(error, record, phase: :tail_sampling, **metadata)
284
+ failure_metadata = if @mutex.owned?
285
+ record_failure_state(error, record, phase: phase, **metadata)
286
+ else
287
+ @mutex.synchronize { record_failure_state(error, record, phase: phase, **metadata) }
288
+ end
289
+ @on_failure&.call(error, **failure_metadata)
290
+ rescue StandardError
291
+ nil
292
+ end
293
+
294
+ def record_failure_state(error, record, phase:, **metadata)
295
+ @health.record_failure(
296
+ error,
297
+ **metadata,
298
+ destination: @name,
299
+ phase: phase,
300
+ record_metadata: record ? Records::Metadata.call(record) : nil
301
+ )
302
+ metadata.merge(destination: @name, phase: phase)
303
+ end
304
+
305
+ def health_status
306
+ return :degraded if @health.last_failure || @health.last_loss&.fetch(:reason) == :overflow_dropped
307
+
308
+ :ok
309
+ end
310
+
311
+ def validate_slow_ms(value)
312
+ return if value.nil?
313
+ raise ArgumentError, "slow_ms must be a non-negative Numeric" unless value.is_a?(Numeric) && value.finite?
314
+ raise ArgumentError, "slow_ms must be non-negative" if value.negative?
315
+
316
+ value
317
+ end
318
+ end
319
+ end
320
+ end
321
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module Core
5
+ module Destinations
6
+ # @api integration_spi
7
+ class WriteStep
8
+ def initialize(
9
+ formatter:,
10
+ encoder:,
11
+ output:,
12
+ max_record_bytes:,
13
+ increment:,
14
+ failure:,
15
+ loss:,
16
+ output_class_name:
17
+ )
18
+ @formatter = formatter
19
+ @encoder = encoder
20
+ @output = output
21
+ @max_record_bytes = max_record_bytes
22
+ @increment = increment
23
+ @failure = failure
24
+ @loss = loss
25
+ @output_class_name = output_class_name
26
+ end
27
+
28
+ def call(record)
29
+ increment(:received)
30
+ payload = format_record(record)
31
+ return :dropped unless payload
32
+
33
+ increment(:formatted)
34
+ encoded = encode_payload(payload, record)
35
+ return :dropped unless encoded
36
+ return :dropped unless within_limit?(encoded, record)
37
+ return :dropped unless write(encoded, record)
38
+
39
+ increment(:output_accepted)
40
+ :accepted
41
+ end
42
+
43
+ private
44
+
45
+ def format_record(record)
46
+ payload = @formatter.call(record)
47
+ raise TypeError, "formatter must return a payload object" if payload.nil?
48
+
49
+ payload
50
+ rescue StandardError => e
51
+ transform_error(e, phase: :formatter, counter: :formatter_error, record: record)
52
+ end
53
+
54
+ def encode_payload(payload, record)
55
+ encoded = @encoder.call(payload)
56
+ raise TypeError, "encoder must return a String" unless encoded.is_a?(String)
57
+
58
+ encoded
59
+ rescue StandardError => e
60
+ transform_error(e, phase: :encode, counter: :encode_error, record: record)
61
+ end
62
+
63
+ def transform_error(error, phase:, counter:, record:)
64
+ increment(counter)
65
+ failure(error, phase: phase, record: record)
66
+ loss(counter, record: record)
67
+ nil
68
+ end
69
+
70
+ def within_limit?(encoded, record)
71
+ return true unless @max_record_bytes
72
+
73
+ bytesize = encoded.bytesize
74
+ return true if bytesize <= @max_record_bytes
75
+
76
+ increment(:record_too_large)
77
+ loss(:record_too_large, bytesize: bytesize, max_record_bytes: @max_record_bytes, record: record)
78
+ false
79
+ end
80
+
81
+ def write(encoded, record)
82
+ result = @output.write(encoded)
83
+ return true unless result == false
84
+
85
+ increment(:output_rejected)
86
+ output_error
87
+ loss(:output_rejected, record: record)
88
+ false
89
+ rescue StandardError => e
90
+ increment(:output_exception)
91
+ output_error
92
+ failure(e, phase: :output, action: :write, output_class: output_class_name, record: record)
93
+ loss(:output_exception, action: :write, output_class: output_class_name, record: record)
94
+ false
95
+ end
96
+
97
+ def output_error
98
+ increment(:output_error)
99
+ end
100
+
101
+ def increment(counter)
102
+ @increment.call(counter)
103
+ end
104
+
105
+ def failure(error, **metadata)
106
+ @failure.call(error, metadata)
107
+ end
108
+
109
+ def loss(reason, **metadata)
110
+ @loss.call(reason, metadata)
111
+ end
112
+
113
+ def output_class_name
114
+ @output_class_name.call
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module Core
5
+ module Destinations
6
+ @factories = {}
7
+
8
+ class << self
9
+ def register(kind, &factory)
10
+ raise ArgumentError, "destination factory block required" unless factory
11
+
12
+ @factories[normalize_name(kind)] = factory
13
+ nil
14
+ end
15
+
16
+ # Private testing seam for `Julewire::Testing.unregister_destination`.
17
+ def unregister(kind)
18
+ @factories.delete(normalize_name(kind))
19
+ nil
20
+ end
21
+ private :unregister
22
+
23
+ def factory_for(kind)
24
+ @factories[normalize_name(kind)]
25
+ end
26
+
27
+ def normalize_name(value)
28
+ Core.normalize_name(value, name: "destination name")
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module Core
5
+ module Diagnostics
6
+ module CallbackNotifier
7
+ ACTIVE_KEY = :__julewire_core_callback_active__
8
+ private_constant :ACTIVE_KEY
9
+
10
+ class NestedCallback < StandardError; end
11
+
12
+ private_constant :NestedCallback
13
+
14
+ Failure = Data.define(:at, :class_name, :metadata) do
15
+ def to_h
16
+ {
17
+ action: metadata[:action],
18
+ at: at,
19
+ class: class_name,
20
+ destination: metadata[:destination],
21
+ phase: metadata[:phase],
22
+ reason: metadata[:reason]
23
+ }.compact.freeze
24
+ end
25
+ end
26
+
27
+ class << self
28
+ def call(callback, first_argument, metadata)
29
+ return unless callback
30
+ return nested_callback_result(metadata) if callback_active?
31
+
32
+ previous = Fiber[ACTIVE_KEY]
33
+ begin
34
+ Fiber[ACTIVE_KEY] = true
35
+ callback.call(first_argument, metadata)
36
+ true
37
+ rescue StandardError => e
38
+ failure(e.class.name, metadata)
39
+ ensure
40
+ Fiber[ACTIVE_KEY] = previous
41
+ end
42
+ end
43
+
44
+ def failure?(result)
45
+ result.is_a?(Failure)
46
+ end
47
+
48
+ def nested_callback_result(metadata)
49
+ failure(NestedCallback.name, metadata)
50
+ end
51
+
52
+ def callback_active?
53
+ Fiber[ACTIVE_KEY] == true
54
+ end
55
+
56
+ def failure(class_name, metadata)
57
+ Failure.new(at: Time.now.utc, class_name: class_name, metadata: metadata || {})
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module Core
5
+ module Diagnostics
6
+ class Doctor
7
+ class << self
8
+ def call(runtime)
9
+ new(runtime).call
10
+ end
11
+ end
12
+
13
+ def initialize(runtime)
14
+ @runtime = runtime
15
+ end
16
+
17
+ def call
18
+ health = @runtime.health
19
+ config = @runtime.config
20
+ {
21
+ status: health.fetch(:status),
22
+ runtime: runtime_info(health, config),
23
+ pipeline: pipeline_info(health.fetch(:pipeline)),
24
+ integrations: integration_info(health.fetch(:integrations)),
25
+ process_integrations: integration_info(health.fetch(:process_integrations)),
26
+ warnings: warnings(health)
27
+ }
28
+ end
29
+
30
+ private
31
+
32
+ def runtime_info(health, config)
33
+ {
34
+ closed: health.fetch(:closed),
35
+ counts: health.fetch(:counts),
36
+ generation: health.fetch(:generation),
37
+ level: config.level,
38
+ last_failure: health[:last_failure],
39
+ status: health.fetch(:status)
40
+ }
41
+ end
42
+
43
+ def pipeline_info(pipeline)
44
+ {
45
+ configured: pipeline.fetch(:configured),
46
+ counts: pipeline.fetch(:counts),
47
+ destinations: destination_info(pipeline.fetch(:destinations)),
48
+ last_failure: pipeline[:last_failure],
49
+ status: pipeline.fetch(:status)
50
+ }
51
+ end
52
+
53
+ def destination_info(destinations)
54
+ destinations.transform_values do |destination|
55
+ {
56
+ counts: destination[:counts],
57
+ last_failure: destination[:last_failure],
58
+ last_loss: destination[:last_loss],
59
+ status: destination[:status]
60
+ }.compact
61
+ end
62
+ end
63
+
64
+ def integration_info(integrations)
65
+ integrations.transform_values do |integration|
66
+ {
67
+ counts: integration[:counts],
68
+ last_failure: integration[:last_failure],
69
+ status: integration[:status]
70
+ }.compact
71
+ end
72
+ end
73
+
74
+ def warnings(health)
75
+ [].tap do |items|
76
+ items << warning(:runtime_closed, "runtime is closed") if health.fetch(:closed)
77
+ pipeline_warnings(health.fetch(:pipeline), items)
78
+ integration_warnings(health.fetch(:integrations), items, label: :integration)
79
+ integration_warnings(health.fetch(:process_integrations), items, label: :process_integration)
80
+ end
81
+ end
82
+
83
+ def pipeline_warnings(pipeline, items)
84
+ items << warning(:no_destinations, "pipeline has no destinations") unless pipeline.fetch(:configured)
85
+ if pipeline.fetch(:configured) && pipeline.fetch(:status) != :ok
86
+ items << warning(:pipeline_degraded, "pipeline is #{pipeline.fetch(:status)}")
87
+ end
88
+ component_warnings(
89
+ pipeline.fetch(:destinations),
90
+ items,
91
+ code: :destination_degraded,
92
+ label: :destination
93
+ )
94
+ end
95
+
96
+ def integration_warnings(integrations, items, label:)
97
+ component_warnings(integrations, items, code: :integration_degraded, label: label)
98
+ end
99
+
100
+ def component_warnings(components, items, code:, label:)
101
+ components.each do |name, component|
102
+ next if component[:status] == :ok
103
+
104
+ items << warning(code, "#{label} #{name} is #{component[:status]}")
105
+ end
106
+ end
107
+
108
+ def warning(code, message)
109
+ { code: code, message: message }.freeze
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module Core
5
+ module Diagnostics
6
+ module FailureSnapshot
7
+ class << self
8
+ def build(error, **metadata)
9
+ {
10
+ at: Time.now.utc,
11
+ action: metadata[:action],
12
+ class: error.class.name,
13
+ component: metadata[:component],
14
+ destination: metadata[:destination],
15
+ event: metadata[:event],
16
+ integration: metadata[:integration],
17
+ output_class: metadata[:output_class],
18
+ phase: metadata[:phase],
19
+ record: record_metadata(metadata[:record_metadata])
20
+ }.compact.freeze
21
+ end
22
+
23
+ private
24
+
25
+ def record_metadata(value)
26
+ return unless value.is_a?(Hash)
27
+
28
+ {
29
+ event: value[:event],
30
+ labels: value[:labels].is_a?(Hash) ? Fields::FieldSet.deep_dup(value[:labels]) : nil,
31
+ severity: value[:severity],
32
+ source: value[:source]
33
+ }.compact
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end