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,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module Core
5
+ module Execution
6
+ class Scope
7
+ EMPTY_HASH = {}.freeze
8
+ private_constant :EMPTY_HASH
9
+
10
+ attr_reader :finished_at
11
+
12
+ def initialize(type:, id: nil, execution: EMPTY_HASH, execution_owned: false, summary_event: nil, # rubocop:disable Metrics/ParameterLists
13
+ summary_severity: nil, summary_source: nil, attributes: EMPTY_HASH, carry: EMPTY_HASH,
14
+ context: EMPTY_HASH, labels: EMPTY_HASH, neutral: EMPTY_HASH, parent: nil, started_at: nil)
15
+ @identity = ScopeIdentity.new(
16
+ type: type,
17
+ id: id,
18
+ started_at: started_at,
19
+ parent: parent,
20
+ parent_reference: parent&.execution_reference_for_child
21
+ )
22
+ @summary_state = summary_state(summary_event, summary_severity, summary_source)
23
+ @execution = @identity.execution_fields(execution, owned: execution_owned)
24
+ initialize_state(context: context, attributes: attributes, labels: labels, carry: carry, neutral: neutral)
25
+ end
26
+
27
+ def id = @identity.id
28
+
29
+ def type = @identity.type
30
+
31
+ def started_at = @identity.started_at
32
+
33
+ def lineage = @identity.lineage
34
+
35
+ def parent = @identity.parent
36
+
37
+ def depth = @identity.depth
38
+
39
+ def execution_hash
40
+ Fields::FieldSet.deep_dup(frozen_execution_hash)
41
+ end
42
+
43
+ def frozen_execution_hash
44
+ @frozen_execution_hash ||= @identity.frozen_execution_hash(@execution)
45
+ end
46
+
47
+ def inheritable_execution_hash
48
+ Fields::FieldSet.deep_dup(@execution)
49
+ end
50
+
51
+ def context_hash = @fields.context_hash
52
+
53
+ def carry_hash = @fields.carry_hash
54
+
55
+ def attributes_hash = @fields.attributes_hash
56
+
57
+ def neutral_hash = @fields.neutral_hash
58
+
59
+ def field_stacks = @fields.stacks
60
+
61
+ def labels_hash = @fields.labels_hash
62
+
63
+ def frozen_labels_hash = @fields.frozen_labels_hash
64
+
65
+ def field_hash(section)
66
+ @fields.field_hash(section)
67
+ end
68
+
69
+ def field_stack(section)
70
+ @fields.field_stack(section)
71
+ end
72
+
73
+ def summary_hash
74
+ @summary_state.payload_hash
75
+ end
76
+
77
+ def metrics_hash
78
+ @summary_state.metrics_hash
79
+ end
80
+
81
+ def measure_summary(key)
82
+ measurement = @summary_state.measurement(key)
83
+ started = monotonic_time
84
+ begin
85
+ yield
86
+ ensure
87
+ @summary_state.record_measurement(measurement, ((monotonic_time - started) * 1000).round(3))
88
+ end
89
+ end
90
+
91
+ def measure_summary_start(key)
92
+ measurement = @summary_state.measurement(key)
93
+ started = monotonic_time
94
+ MeasurementHandle.new do
95
+ @summary_state.record_measurement(measurement, ((monotonic_time - started) * 1000).round(3))
96
+ end
97
+ end
98
+
99
+ def add_field(section, fields, owned: false)
100
+ @fields.add(section, fields, owned: owned)
101
+ end
102
+
103
+ def delete_carry(path)
104
+ path = Fields::Internal.normalize_path(path)
105
+ @fields.delete(:carry, path)
106
+ end
107
+
108
+ def with_field(section, fields, owned: false, &)
109
+ @fields.with(section, fields, owned: owned, &)
110
+ end
111
+
112
+ def with_context(fields, &)
113
+ with_field(:context, fields, &)
114
+ end
115
+
116
+ def with_carry(fields, &)
117
+ with_field(:carry, fields, &)
118
+ end
119
+
120
+ def without_carry(path, &)
121
+ normalized_path = Fields::Internal.normalize_path(path)
122
+ raise ArgumentError, "carry path is required" if normalized_path.empty?
123
+
124
+ @fields.without(:carry, normalized_path, &)
125
+ end
126
+
127
+ def add_summary(fields, owned: false)
128
+ @summary_state.add(fields, owned: owned)
129
+ end
130
+
131
+ def add_summary_attributes(fields, owned: false)
132
+ @summary_state.add_attributes(fields, owned: owned)
133
+ end
134
+
135
+ def add_summary_neutral(fields, owned: false)
136
+ @summary_state.add_neutral(fields, owned: owned)
137
+ end
138
+
139
+ def increment_summary_attribute(path, by: 1)
140
+ @summary_state.increment_attribute(path, by: by)
141
+ end
142
+
143
+ def increment_summary(key, by: 1)
144
+ @summary_state.increment(key, by: by)
145
+ end
146
+
147
+ def append_summary(key, value)
148
+ @summary_state.append(key, value)
149
+ end
150
+
151
+ def summary_record_input
152
+ @summary_state.record_input(**summary_record_fields(timestamp: finished_at || frozen_time(Time.now.utc)))
153
+ end
154
+
155
+ def owned_summary_record_input
156
+ @summary_state.owned_record_input(
157
+ **summary_record_fields(timestamp: finished_at || frozen_time(Time.now.utc))
158
+ )
159
+ end
160
+
161
+ def finished?
162
+ !finished_at.nil?
163
+ end
164
+
165
+ def finish_owned(error: nil, severity: nil, finished_at: Time.now.utc)
166
+ # The first completion snapshot wins; later finish calls are no-ops.
167
+ return owned_summary_record_input if finished?
168
+
169
+ record_error(error, severity: severity) if error
170
+ @finished_at = frozen_time(finished_at)
171
+ @summary_state.record_duration(((monotonic_time - @identity.started_monotonic) * 1000).round(3))
172
+ @summary_state.finalize_record_input(**summary_record_fields(timestamp: @finished_at))
173
+ end
174
+
175
+ def record_error(error, severity: nil)
176
+ @summary_state.record_error(error, severity: severity)
177
+ end
178
+
179
+ def non_standard_exception?
180
+ @summary_state.non_standard_exception?
181
+ end
182
+
183
+ private
184
+
185
+ def monotonic_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
186
+
187
+ def frozen_time(value) = @identity.frozen_time(value)
188
+
189
+ def initialize_state(context:, attributes:, labels:, carry: {}, neutral: {})
190
+ @fields = ScopeFields.new(
191
+ context: context,
192
+ carry: carry,
193
+ attributes: attributes,
194
+ labels: labels,
195
+ neutral: neutral
196
+ )
197
+ @finished_at = nil
198
+ end
199
+
200
+ def summary_state(event, severity, source)
201
+ SummaryState.new(
202
+ event: normalize_summary_event(event),
203
+ severity: normalize_summary_severity(severity),
204
+ source: normalize_summary_source(source)
205
+ )
206
+ end
207
+
208
+ def normalize_summary_event(event)
209
+ normalized = event.nil? ? "#{type}.completed" : event.to_s
210
+ raise ArgumentError, "summary event is required" if normalized.empty?
211
+
212
+ normalized
213
+ end
214
+
215
+ def normalize_summary_severity(severity)
216
+ Records::Severity.normalize(severity) unless severity.nil?
217
+ end
218
+
219
+ def normalize_summary_source(source)
220
+ normalized = source.nil? ? "julewire" : source.to_s
221
+ raise ArgumentError, "summary source is required" if normalized.empty?
222
+
223
+ normalized
224
+ end
225
+
226
+ def summary_record_fields(timestamp:)
227
+ {
228
+ timestamp: timestamp,
229
+ execution: execution_hash,
230
+ context: context_hash,
231
+ carry: carry_hash,
232
+ neutral: neutral_hash,
233
+ attributes: attributes_hash,
234
+ labels: labels_hash
235
+ }
236
+ end
237
+
238
+ protected
239
+
240
+ def execution_reference_for_child
241
+ @identity.reference
242
+ end
243
+ end
244
+ end
245
+ end
246
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module Core
5
+ module Execution
6
+ class ScopeFields
7
+ EMPTY_HASH = {}.freeze
8
+ private_constant :EMPTY_HASH
9
+
10
+ attr_reader :stacks
11
+
12
+ def initialize(context:, attributes:, labels:, carry:, neutral:)
13
+ @stacks = Fields::StackSet.new(
14
+ context: context,
15
+ carry: carry,
16
+ attributes: attributes,
17
+ neutral: neutral
18
+ )
19
+ @labels = normalized_static_hash(labels)
20
+ end
21
+
22
+ def context_hash = @stacks.snapshot(:context)
23
+
24
+ def carry_hash = @stacks.snapshot(:carry)
25
+
26
+ def attributes_hash = @stacks.snapshot(:attributes)
27
+
28
+ def neutral_hash = @stacks.snapshot(:neutral)
29
+
30
+ def field_hash(section)
31
+ @stacks.snapshot(section)
32
+ end
33
+
34
+ def field_stack(section)
35
+ @stacks.stack(section)
36
+ end
37
+
38
+ def labels_hash
39
+ return {} if @labels.empty?
40
+
41
+ Fields::FieldSet.deep_dup(@labels)
42
+ end
43
+
44
+ def frozen_labels_hash
45
+ return EMPTY_HASH if @labels.empty?
46
+
47
+ @frozen_labels_hash ||= Fields::Internal.frozen_copy(@labels)
48
+ end
49
+
50
+ def add(section, fields, owned: false)
51
+ @stacks.add(section, fields, owned: owned)
52
+ end
53
+
54
+ def delete(section, path)
55
+ @stacks.delete(section, path)
56
+ end
57
+
58
+ def with(section, fields, owned: false, &)
59
+ @stacks.with(section, fields, owned: owned, &)
60
+ end
61
+
62
+ def without(section, path, &)
63
+ @stacks.without(section, path, &)
64
+ end
65
+
66
+ private
67
+
68
+ def normalized_static_hash(value)
69
+ return EMPTY_HASH if value.is_a?(Hash) && value.empty?
70
+
71
+ Fields::FieldSet.deep_symbolize_keys(value)
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Julewire
6
+ module Core
7
+ module Execution
8
+ class ScopeIdentity
9
+ attr_reader :id, :lineage, :parent, :reference, :started_at, :started_monotonic, :type
10
+
11
+ def initialize(type:, id: nil, started_at: nil, parent: nil, parent_reference: nil)
12
+ @id = normalize_id(id || SecureRandom.uuid)
13
+ @type = normalize_type(type)
14
+ @started_at = frozen_time(started_at || Time.now.utc)
15
+ @started_monotonic = monotonic_time
16
+ @parent = parent
17
+ @reference = { type: @type, id: @id }.freeze
18
+ @lineage = Lineage.new(
19
+ reference: @reference,
20
+ parent_lineage: parent&.lineage,
21
+ parent_reference: parent_reference
22
+ )
23
+ end
24
+
25
+ def depth = @lineage.depth
26
+
27
+ def execution_fields(execution, owned:)
28
+ fields = owned ? Lineage.clean_owned_execution_hash(execution) : Lineage.clean_execution_hash(execution)
29
+ fields[:type] = @type
30
+ fields[:id] = @id
31
+ fields
32
+ end
33
+
34
+ def frozen_execution_hash(execution)
35
+ @lineage.merge_into_frozen(execution)
36
+ end
37
+
38
+ def frozen_time(value)
39
+ Serialization::ValueCopy.call(value, freeze_values: true)
40
+ end
41
+
42
+ private
43
+
44
+ def monotonic_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
45
+
46
+ def normalize_type(type)
47
+ normalized = type.to_s
48
+ raise ArgumentError, "execution type is required" if normalized.empty?
49
+
50
+ freeze_identity_value(normalized)
51
+ end
52
+
53
+ def normalize_id(id)
54
+ freeze_identity_value(id)
55
+ end
56
+
57
+ def freeze_identity_value(value)
58
+ case value
59
+ when String
60
+ copy = value.frozen? ? value : value.dup
61
+ copy.freeze
62
+ when Symbol, Numeric, true, false, nil
63
+ value
64
+ else
65
+ Serialization::ValueCopy.call(value, freeze_values: true)
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module Core
5
+ module Execution
6
+ class ScopeSnapshot
7
+ EMPTY_HASH = {}.freeze
8
+ private_constant :EMPTY_HASH
9
+
10
+ def initialize(execution: {}, carry: {}, attributes: {}, labels: {}, neutral: {}, lineage: nil)
11
+ @execution = normalized_hash(execution)
12
+ @carry = normalized_hash(carry)
13
+ @attributes = normalized_hash(attributes)
14
+ @neutral = normalized_hash(neutral)
15
+ @labels = normalized_hash(labels)
16
+ @lineage = lineage || Lineage.from_execution_hash(@execution)
17
+ end
18
+
19
+ def execution_hash
20
+ Fields::FieldSet.deep_dup(frozen_execution_hash)
21
+ end
22
+
23
+ def frozen_execution_hash
24
+ @frozen_execution_hash ||= Fields::Internal.frozen_copy(@execution)
25
+ end
26
+
27
+ attr_reader :lineage
28
+
29
+ def id = @execution[:id]
30
+
31
+ def type = @execution[:type]
32
+
33
+ def started_at = nil
34
+
35
+ def finished_at = nil
36
+
37
+ def parent = nil
38
+
39
+ def context_hash = {}
40
+
41
+ def carry_hash
42
+ return {} if @carry.empty?
43
+
44
+ Fields::FieldSet.deep_dup(@carry)
45
+ end
46
+
47
+ def attributes_hash
48
+ return {} if @attributes.empty?
49
+
50
+ Fields::FieldSet.deep_dup(@attributes)
51
+ end
52
+
53
+ def neutral_hash
54
+ return {} if @neutral.empty?
55
+
56
+ Fields::FieldSet.deep_dup(@neutral)
57
+ end
58
+
59
+ def labels_hash
60
+ return {} if @labels.empty?
61
+
62
+ Fields::FieldSet.deep_dup(@labels)
63
+ end
64
+
65
+ def summary_hash = {}
66
+
67
+ def metrics_hash = {}
68
+
69
+ def frozen_labels_hash
70
+ return EMPTY_HASH if @labels.empty?
71
+
72
+ @frozen_labels_hash ||= Fields::Internal.frozen_copy(@labels)
73
+ end
74
+
75
+ def execution_reference_for_child
76
+ reference = {}
77
+ reference[:type] = @execution[:type] if @execution.key?(:type)
78
+ reference[:id] = @execution[:id] if @execution.key?(:id)
79
+ reference.empty? ? nil : Fields::Internal.frozen_copy(reference)
80
+ end
81
+
82
+ private
83
+
84
+ def normalized_hash(value)
85
+ return EMPTY_HASH if value.is_a?(Hash) && value.empty?
86
+
87
+ Fields::FieldSet.deep_symbolize_keys(value)
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module Core
5
+ module Execution
6
+ class SummaryState
7
+ Measurement = Data.define(:count_key, :duration_key)
8
+ private_constant :Measurement
9
+
10
+ def initialize(event:, severity:, source:)
11
+ @event = event
12
+ @severity = severity
13
+ @source = source
14
+ @payload = {}
15
+ @neutral = {}
16
+ @attributes = {}
17
+ @metrics = {}
18
+ @errors = []
19
+ @error_severity = nil
20
+ @record_input = nil
21
+ end
22
+
23
+ def payload_hash
24
+ Fields::FieldSet.deep_dup(@payload)
25
+ end
26
+
27
+ def metrics_hash
28
+ Fields::FieldSet.deep_dup(@metrics)
29
+ end
30
+
31
+ def add(fields, owned: false)
32
+ merge_fields!(@payload, fields, owned: owned)
33
+ end
34
+
35
+ def add_attributes(fields, owned: false)
36
+ deep_merge_fields!(@attributes, fields, owned: owned)
37
+ end
38
+
39
+ def add_neutral(fields, owned: false)
40
+ deep_merge_fields!(@neutral, fields, owned: owned)
41
+ end
42
+
43
+ def increment_attribute(path, by: 1)
44
+ path = Fields::Internal.normalize_path(path)
45
+ raise ArgumentError, "attribute path is required" if path.empty?
46
+
47
+ container = attribute_container(path)
48
+ key = path.last
49
+ value = Fields::FieldSet.value_for(container, key, default: MISSING)
50
+ existing = !value.equal?(MISSING)
51
+ container[Fields::Internal.normalize_key(key)] = incremented_value(value, by, existing: existing)
52
+ end
53
+
54
+ def increment(key, by: 1)
55
+ key = Fields::Internal.normalize_key(key)
56
+ value = Fields::FieldSet.value_for(@payload, key, default: MISSING)
57
+ existing = !value.equal?(MISSING)
58
+ @payload[key] = incremented_value(value, by, existing: existing)
59
+ end
60
+
61
+ def append(key, value)
62
+ key = Fields::Internal.normalize_key(key)
63
+ current = Fields::FieldSet.value_for(@payload, key, default: MISSING)
64
+ values = array_value(current, existing: !current.equal?(MISSING))
65
+ @payload[key] = values
66
+ values << Fields::FieldSet.deep_dup(value)
67
+ end
68
+
69
+ def record_error(error, severity: nil)
70
+ @errors << error
71
+ @error_severity = Records::Severity.normalize(severity) unless severity.nil?
72
+ end
73
+
74
+ def non_standard_exception?
75
+ @errors.any? { !it.is_a?(StandardError) }
76
+ end
77
+
78
+ def record_duration(duration_ms)
79
+ @metrics[:duration_ms] = duration_ms
80
+ end
81
+
82
+ def measurement(key)
83
+ base = measurement_base(key)
84
+ Measurement.new(:"#{base}_count", :"#{base}_duration_ms")
85
+ end
86
+
87
+ def record_measurement(measurement, duration_ms)
88
+ increment(measurement.count_key)
89
+ increment_metric(measurement.duration_key, by: duration_ms)
90
+ end
91
+
92
+ def record_input(**fields)
93
+ Fields::FieldSet.deep_dup(owned_record_input(**fields))
94
+ end
95
+
96
+ def owned_record_input(**fields)
97
+ @record_input || build_record_input(**fields)
98
+ end
99
+
100
+ def finalize_record_input(**fields)
101
+ @record_input = build_record_input(**fields)
102
+ end
103
+
104
+ private
105
+
106
+ def merge_fields!(target, fields, owned:)
107
+ if owned
108
+ Fields::Internal.merge_owned!(target, fields)
109
+ else
110
+ Fields::FieldSet.merge!(target, fields)
111
+ end
112
+ end
113
+
114
+ def deep_merge_fields!(target, fields, owned:)
115
+ if owned
116
+ Fields::Internal.deep_merge_owned!(target, fields)
117
+ else
118
+ Fields::Internal.deep_merge!(target, fields)
119
+ end
120
+ end
121
+
122
+ def build_record_input(timestamp:, execution:, context:, carry:, neutral:, attributes:, labels:)
123
+ {
124
+ timestamp: timestamp,
125
+ kind: :summary,
126
+ event: @event,
127
+ source: @source,
128
+ execution: execution,
129
+ context: context,
130
+ carry: carry,
131
+ neutral: neutral_hash(neutral),
132
+ attributes: attributes_hash(attributes),
133
+ labels: labels,
134
+ metrics: metrics_hash,
135
+ payload: payload_hash,
136
+ error: @errors.last
137
+ }.tap do |record|
138
+ severity = summary_severity
139
+ record[:severity] = severity if severity
140
+ end
141
+ end
142
+
143
+ def attributes_hash(base_attributes)
144
+ return base_attributes if @attributes.empty?
145
+
146
+ Fields::Internal.deep_merge(base_attributes, @attributes)
147
+ end
148
+
149
+ def neutral_hash(base_neutral)
150
+ return base_neutral if @neutral.empty?
151
+
152
+ Fields::Internal.deep_merge(base_neutral, @neutral)
153
+ end
154
+
155
+ def array_value(value, existing:)
156
+ return [] unless existing
157
+ return value if value.is_a?(Array)
158
+
159
+ [value]
160
+ end
161
+
162
+ def incremented_value(value, by, existing:)
163
+ return Fields::FieldSet.deep_dup(by) unless existing
164
+ return value + by if value.is_a?(Numeric) && by.is_a?(Numeric)
165
+
166
+ array_value(value, existing: true).tap { it << Fields::FieldSet.deep_dup(by) }
167
+ end
168
+
169
+ def increment_metric(key, by:)
170
+ value = Fields::FieldSet.value_for(@metrics, key, default: MISSING)
171
+ existing = !value.equal?(MISSING)
172
+ @metrics[key] = incremented_value(value, by, existing: existing)
173
+ end
174
+
175
+ def measurement_base(key)
176
+ unless key.is_a?(String) || key.is_a?(Symbol)
177
+ raise ArgumentError, "measurement key must be a String or Symbol"
178
+ end
179
+
180
+ base = key.to_s
181
+ raise ArgumentError, "measurement key is required" if base.empty?
182
+
183
+ base
184
+ end
185
+
186
+ def attribute_container(path)
187
+ path[0...-1].reduce(@attributes) do |container, key|
188
+ normalized = Fields::Internal.normalize_key(key)
189
+ child = container[normalized]
190
+ unless child.is_a?(Hash)
191
+ child = {}
192
+ container[normalized] = child
193
+ end
194
+ child
195
+ end
196
+ end
197
+
198
+ def summary_severity
199
+ return @severity if @errors.empty?
200
+
201
+ @error_severity || :error
202
+ end
203
+ end
204
+ end
205
+ end
206
+ end