orfeas_lyra 0.6.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 (81) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +222 -0
  3. data/LICENSE +21 -0
  4. data/README.md +1165 -0
  5. data/Rakefile +728 -0
  6. data/app/controllers/lyra/application_controller.rb +23 -0
  7. data/app/controllers/lyra/dashboard_controller.rb +624 -0
  8. data/app/controllers/lyra/flow_controller.rb +224 -0
  9. data/app/controllers/lyra/privacy_controller.rb +182 -0
  10. data/app/views/lyra/dashboard/audit_trail.html.erb +324 -0
  11. data/app/views/lyra/dashboard/discrepancies.html.erb +125 -0
  12. data/app/views/lyra/dashboard/event_graph_view.html.erb +525 -0
  13. data/app/views/lyra/dashboard/heatmap_view.html.erb +155 -0
  14. data/app/views/lyra/dashboard/index.html.erb +119 -0
  15. data/app/views/lyra/dashboard/model_overview.html.erb +115 -0
  16. data/app/views/lyra/dashboard/projections.html.erb +302 -0
  17. data/app/views/lyra/dashboard/schema.html.erb +283 -0
  18. data/app/views/lyra/dashboard/schema_history.html.erb +78 -0
  19. data/app/views/lyra/dashboard/schema_version.html.erb +340 -0
  20. data/app/views/lyra/dashboard/verification.html.erb +370 -0
  21. data/app/views/lyra/flow/crud_mapping.html.erb +125 -0
  22. data/app/views/lyra/flow/timeline.html.erb +260 -0
  23. data/app/views/lyra/privacy/pii_detection.html.erb +148 -0
  24. data/app/views/lyra/privacy/policy.html.erb +188 -0
  25. data/app/workflows/es_async_mode_workflow.rb +80 -0
  26. data/app/workflows/es_sync_mode_workflow.rb +64 -0
  27. data/app/workflows/hijack_mode_workflow.rb +54 -0
  28. data/app/workflows/lifecycle_workflow.rb +43 -0
  29. data/app/workflows/monitor_mode_workflow.rb +39 -0
  30. data/config/privacy_policies.rb +273 -0
  31. data/config/routes.rb +48 -0
  32. data/lib/lyra/aggregate.rb +131 -0
  33. data/lib/lyra/associations/event_aware.rb +225 -0
  34. data/lib/lyra/command.rb +81 -0
  35. data/lib/lyra/command_handler.rb +155 -0
  36. data/lib/lyra/configuration.rb +124 -0
  37. data/lib/lyra/consistency/read_your_writes.rb +91 -0
  38. data/lib/lyra/correlation.rb +144 -0
  39. data/lib/lyra/dual_view.rb +231 -0
  40. data/lib/lyra/engine.rb +67 -0
  41. data/lib/lyra/event.rb +71 -0
  42. data/lib/lyra/event_analyzer.rb +135 -0
  43. data/lib/lyra/event_flow.rb +449 -0
  44. data/lib/lyra/event_mapper.rb +106 -0
  45. data/lib/lyra/event_store_adapter.rb +72 -0
  46. data/lib/lyra/id_generator.rb +137 -0
  47. data/lib/lyra/interceptors/association_interceptor.rb +169 -0
  48. data/lib/lyra/interceptors/crud_interceptor.rb +543 -0
  49. data/lib/lyra/privacy/gdpr_compliance.rb +161 -0
  50. data/lib/lyra/privacy/pii_detector.rb +85 -0
  51. data/lib/lyra/privacy/pii_masker.rb +66 -0
  52. data/lib/lyra/privacy/policy_integration.rb +253 -0
  53. data/lib/lyra/projection.rb +94 -0
  54. data/lib/lyra/projections/async_projection_job.rb +63 -0
  55. data/lib/lyra/projections/cached_projection.rb +322 -0
  56. data/lib/lyra/projections/cached_relation.rb +757 -0
  57. data/lib/lyra/projections/event_store_reader.rb +127 -0
  58. data/lib/lyra/projections/model_projection.rb +143 -0
  59. data/lib/lyra/schema/diff.rb +331 -0
  60. data/lib/lyra/schema/event_class_registrar.rb +63 -0
  61. data/lib/lyra/schema/generator.rb +190 -0
  62. data/lib/lyra/schema/reporter.rb +188 -0
  63. data/lib/lyra/schema/store.rb +156 -0
  64. data/lib/lyra/schema/validator.rb +100 -0
  65. data/lib/lyra/strict_data_access.rb +363 -0
  66. data/lib/lyra/verification/crud_lifecycle_workflow.rb +456 -0
  67. data/lib/lyra/verification/workflow_generator.rb +540 -0
  68. data/lib/lyra/version.rb +3 -0
  69. data/lib/lyra/visualization/activity_heatmap.rb +215 -0
  70. data/lib/lyra/visualization/event_graph.rb +310 -0
  71. data/lib/lyra/visualization/timeline.rb +398 -0
  72. data/lib/lyra.rb +150 -0
  73. data/lib/tasks/dist.rake +391 -0
  74. data/lib/tasks/gems.rake +185 -0
  75. data/lib/tasks/lyra_schema.rake +231 -0
  76. data/lib/tasks/lyra_workflows.rake +452 -0
  77. data/lib/tasks/public_release.rake +351 -0
  78. data/lib/tasks/stats.rake +175 -0
  79. data/lib/tasks/testbed.rake +479 -0
  80. data/lib/tasks/version.rake +159 -0
  81. metadata +221 -0
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lyra
4
+ module Consistency
5
+ # Read-Your-Writes consistency helper for event_sourcing mode.
6
+ #
7
+ # When using async projections, there's a window where a record might
8
+ # exist in the event store but not yet in the database. This helper
9
+ # ensures that within a block, any writes are immediately projected
10
+ # before reads happen.
11
+ #
12
+ # Usage:
13
+ # Lyra::Consistency::ReadYourWrites.with_guaranteed_read do
14
+ # @registration = Registration.create!(params)
15
+ # redirect_to @registration # Guaranteed to find it
16
+ # end
17
+ #
18
+ module ReadYourWrites
19
+ class << self
20
+ # Execute block with read-your-writes guarantee
21
+ #
22
+ # Tracks any writes within the block and ensures they are
23
+ # projected before the block returns.
24
+ def with_guaranteed_read
25
+ return yield unless Lyra.event_sourcing_mode?
26
+
27
+ # Store pending writes in thread-local storage
28
+ Thread.current[:lyra_pending_writes] = []
29
+
30
+ begin
31
+ result = yield
32
+ ensure_projected
33
+ result
34
+ ensure
35
+ Thread.current[:lyra_pending_writes] = nil
36
+ end
37
+ end
38
+
39
+ # Record a write that needs projection (called by interceptor)
40
+ def record_write(model_class, operation, result)
41
+ pending = Thread.current[:lyra_pending_writes]
42
+ return unless pending
43
+
44
+ pending << { model_class: model_class, operation: operation, result: result }
45
+ end
46
+
47
+ # Check if we're in a guaranteed read block
48
+ def in_guaranteed_block?
49
+ !Thread.current[:lyra_pending_writes].nil?
50
+ end
51
+
52
+ private
53
+
54
+ # Ensure all pending writes are projected
55
+ def ensure_projected
56
+ pending = Thread.current[:lyra_pending_writes] || []
57
+
58
+ pending.each do |write|
59
+ Lyra::Projections::ModelProjection.project(
60
+ write[:model_class],
61
+ write[:operation],
62
+ write[:result]
63
+ )
64
+ rescue => e
65
+ Rails.logger.error("Lyra: Failed to ensure projection - #{e.message}")
66
+ raise if Lyra.config.strict_projections
67
+ end
68
+ end
69
+ end
70
+ end
71
+
72
+ # Controller concern for automatic read-your-writes handling
73
+ module ControllerConcern
74
+ extend ActiveSupport::Concern
75
+
76
+ included do
77
+ around_action :with_lyra_consistency, if: :lyra_consistency_enabled?
78
+ end
79
+
80
+ private
81
+
82
+ def with_lyra_consistency
83
+ ReadYourWrites.with_guaranteed_read { yield }
84
+ end
85
+
86
+ def lyra_consistency_enabled?
87
+ Lyra.event_sourcing_mode? && Lyra.config.projection_mode == :async
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,144 @@
1
+ module Lyra
2
+ # Correlation system for grouping related operations and events
3
+ # correlation_id: Groups all events from the same user action (e.g., all events from one HTTP request)
4
+ class Correlation
5
+ class << self
6
+ # Generate a unique correlation ID for a group of operations
7
+ def generate_id
8
+ "corr_#{Time.now.to_i}_#{SecureRandom.hex(8)}"
9
+ end
10
+
11
+ # Current correlation ID (thread-safe)
12
+ def current_id
13
+ Thread.current[:lyra_correlation_id]
14
+ end
15
+
16
+ # Set correlation ID for current context
17
+ def with_id(correlation_id = nil)
18
+ correlation_id ||= generate_id
19
+ previous_id = Thread.current[:lyra_correlation_id]
20
+ Thread.current[:lyra_correlation_id] = correlation_id
21
+
22
+ yield correlation_id if block_given?
23
+ ensure
24
+ Thread.current[:lyra_correlation_id] = previous_id
25
+ end
26
+ end
27
+ end
28
+
29
+ # Causation system for tracking event chains
30
+ # causation_id: Points to the specific event that directly caused this event (parent-child relationship)
31
+ #
32
+ # Example:
33
+ # OrderPlaced (causation_id: nil) <- root event
34
+ # └── PaymentProcessed (causation_id: OrderPlaced.id)
35
+ # └── InventoryReserved (causation_id: PaymentProcessed.id)
36
+ #
37
+ class Causation
38
+ class << self
39
+ # Current causation ID (the event that caused the current operation)
40
+ def current_id
41
+ Thread.current[:lyra_causation_id]
42
+ end
43
+
44
+ # Set causation ID for current context (when processing/handling an event)
45
+ def with_id(causation_id)
46
+ previous_id = Thread.current[:lyra_causation_id]
47
+ Thread.current[:lyra_causation_id] = causation_id
48
+
49
+ yield causation_id if block_given?
50
+ ensure
51
+ Thread.current[:lyra_causation_id] = previous_id
52
+ end
53
+
54
+ # Clear causation (for root events)
55
+ def clear
56
+ Thread.current[:lyra_causation_id] = nil
57
+ end
58
+
59
+ # Track causation relationship (for building causation chains)
60
+ def track(cause_event_id, effect_event_id)
61
+ causation_store[effect_event_id] = cause_event_id
62
+ end
63
+
64
+ # Get the full causation chain for an event (root -> ... -> event)
65
+ def chain_for(event_id)
66
+ chain = [event_id]
67
+ current = event_id
68
+
69
+ while (parent = causation_store[current])
70
+ chain.unshift(parent)
71
+ current = parent
72
+ break if chain.size > 100 # Prevent infinite loops
73
+ end
74
+
75
+ chain
76
+ end
77
+
78
+ # Get direct cause of an event
79
+ def cause_of(event_id)
80
+ causation_store[event_id]
81
+ end
82
+
83
+ private
84
+
85
+ def causation_store
86
+ @causation_store ||= {}
87
+ end
88
+ end
89
+ end
90
+
91
+ # User action context for tracking what user action triggered events
92
+ class UserActionContext
93
+ attr_reader :action_id, :user_id, :action_type, :controller, :action_name, :params
94
+
95
+ def initialize(action_type:, user_id: nil, controller: nil, action_name: nil, params: {})
96
+ @action_id = "action_#{Time.now.to_i}_#{SecureRandom.hex(6)}"
97
+ @action_type = action_type # :web_request, :api_call, :background_job, :console
98
+ @user_id = user_id
99
+ @controller = controller
100
+ @action_name = action_name
101
+ @params = sanitize_params(params)
102
+ @started_at = Time.current
103
+ end
104
+
105
+ def to_h
106
+ {
107
+ action_id: action_id,
108
+ action_type: action_type,
109
+ user_id: user_id,
110
+ controller: controller,
111
+ action_name: action_name,
112
+ params: params,
113
+ started_at: @started_at
114
+ }
115
+ end
116
+
117
+ class << self
118
+ def current
119
+ Thread.current[:lyra_user_action_context]
120
+ end
121
+
122
+ def with_context(action_type:, user_id: nil, **options)
123
+ context = new(action_type: action_type, user_id: user_id, **options)
124
+ previous = Thread.current[:lyra_user_action_context]
125
+ Thread.current[:lyra_user_action_context] = context
126
+
127
+ # Also set correlation ID
128
+ Lyra::Correlation.with_id(context.action_id) do
129
+ yield context if block_given?
130
+ end
131
+ ensure
132
+ Thread.current[:lyra_user_action_context] = previous
133
+ end
134
+ end
135
+
136
+ private
137
+
138
+ def sanitize_params(params)
139
+ # Remove sensitive parameters
140
+ sensitive_keys = [:password, :password_confirmation, :token, :secret, :api_key]
141
+ params.except(*sensitive_keys)
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,231 @@
1
+ module Lyra
2
+ # Provides dual view comparison between CRUD state and Event-sourced state
3
+ class DualView
4
+ attr_reader :model_class, :model_id
5
+
6
+ def initialize(model_class, model_id)
7
+ @model_class = model_class
8
+ @model_id = model_id
9
+ end
10
+
11
+ # Get both CRUD and Event-sourced views
12
+ def compare
13
+ {
14
+ crud_view: crud_state,
15
+ event_sourced_view: event_sourced_state,
16
+ differences: calculate_differences,
17
+ metadata: {
18
+ model_class: model_class.name,
19
+ model_id: model_id,
20
+ timestamp: Time.current,
21
+ mode: Lyra.config.mode
22
+ }
23
+ }
24
+ end
25
+
26
+ # CRUD view - current database state
27
+ def crud_state
28
+ record = model_class.find_by(id: model_id)
29
+
30
+ return { exists: false } unless record
31
+
32
+ {
33
+ exists: true,
34
+ attributes: record.attributes,
35
+ timestamps: {
36
+ created_at: record.created_at,
37
+ updated_at: record.updated_at
38
+ }
39
+ }
40
+ end
41
+
42
+ # Event-sourced view - state rebuilt from events
43
+ def event_sourced_state
44
+ stream_name = "#{model_class.name}$#{model_id}"
45
+
46
+ begin
47
+ events = Lyra.config.event_store.read.stream(stream_name).to_a
48
+ rescue => e
49
+ return {
50
+ exists: false,
51
+ error: e.message,
52
+ events_count: 0
53
+ }
54
+ end
55
+
56
+ return { exists: false, events_count: 0 } if events.empty?
57
+
58
+ state = StateProjection.new.rebuild_from_events(events)
59
+
60
+ {
61
+ exists: true,
62
+ state: state,
63
+ events_count: events.count,
64
+ first_event_at: event_timestamp(events.first),
65
+ last_event_at: event_timestamp(events.last),
66
+ events_summary: events.map { |e| { type: e.class.name, operation: event_operation(e), timestamp: event_timestamp(e) } }
67
+ }
68
+ end
69
+
70
+ # Helper methods to extract data from events (works with both Lyra::Event and RubyEventStore::Event)
71
+ def event_timestamp(event)
72
+ return event.timestamp if event.respond_to?(:timestamp) && event.timestamp
73
+ event.data[:timestamp] || event.data["timestamp"] || event.metadata[:timestamp]
74
+ end
75
+
76
+ def event_operation(event)
77
+ return event.operation if event.respond_to?(:operation)
78
+ op = event.data[:operation] || event.data["operation"]
79
+ op.is_a?(String) ? op.to_sym : op
80
+ end
81
+
82
+ # Calculate differences between CRUD and Event-sourced states
83
+ def calculate_differences
84
+ crud = crud_state
85
+ es = event_sourced_state
86
+
87
+ return { exists_mismatch: true } if crud[:exists] != es[:exists]
88
+ return { no_differences: true } if !crud[:exists] && !es[:exists]
89
+
90
+ crud_attrs = crud[:attributes] || {}
91
+ es_attrs = es[:state] || {}
92
+
93
+ differences = {}
94
+
95
+ # Build lookup tables with both string and symbol keys
96
+ crud_lookup = build_key_lookup(crud_attrs)
97
+ es_lookup = build_key_lookup(es_attrs)
98
+
99
+ # Compare union of all keys from both sources, excluding DB-managed timestamps
100
+ ignored_keys = %i[created_at updated_at]
101
+ all_keys = (crud_lookup.keys + es_lookup.keys).uniq - ignored_keys
102
+
103
+ all_keys.each do |key|
104
+ crud_val = crud_lookup[key]
105
+ es_val = es_lookup[key]
106
+
107
+ unless values_equal?(crud_val, es_val)
108
+ differences[key] = {
109
+ crud: crud_val,
110
+ event_sourced: es_val
111
+ }
112
+ end
113
+ end
114
+
115
+ differences.empty? ? { no_differences: true } : differences
116
+ end
117
+
118
+ # Get audit trail from events
119
+ def audit_trail
120
+ AuditProjection.audit_trail(model_class, model_id)
121
+ end
122
+
123
+ private
124
+
125
+ # Build a normalized lookup table from attributes hash
126
+ # Converts all keys to symbols for consistent access
127
+ def build_key_lookup(attrs)
128
+ result = {}
129
+ attrs.each do |key, val|
130
+ result[key.to_sym] = val
131
+ end
132
+ result
133
+ end
134
+
135
+ # Compare values with type coercion for common mismatches
136
+ def values_equal?(crud_val, es_val)
137
+ return true if crud_val == es_val
138
+ return true if crud_val.nil? && es_val.nil?
139
+ return false if crud_val.nil? || es_val.nil?
140
+
141
+ # Normalize for comparison
142
+ normalized_crud = normalize_value(crud_val)
143
+ normalized_es = normalize_value(es_val)
144
+
145
+ normalized_crud == normalized_es
146
+ end
147
+
148
+ def normalize_value(val)
149
+ case val
150
+ when Time, DateTime, ActiveSupport::TimeWithZone
151
+ val.utc.iso8601
152
+ when Date
153
+ val.iso8601
154
+ when BigDecimal
155
+ val.to_f.round(6)
156
+ when Float
157
+ val.round(6)
158
+ when Integer
159
+ val.to_f
160
+ when String
161
+ # Try to parse as time if it looks like a timestamp
162
+ if val =~ /^\d{4}-\d{2}-\d{2}(T|\s)/
163
+ begin
164
+ Time.parse(val).utc.iso8601
165
+ rescue
166
+ val
167
+ end
168
+ # Try to parse as number if it looks numeric
169
+ elsif val =~ /^-?\d+\.?\d*$/
170
+ val.to_f.round(6)
171
+ else
172
+ val
173
+ end
174
+ when TrueClass, FalseClass
175
+ val
176
+ when NilClass
177
+ nil
178
+ else
179
+ val.to_s
180
+ end
181
+ end
182
+
183
+ # Class methods for batch comparison
184
+ class << self
185
+ def compare_all(model_class)
186
+ model_class.find_each.map do |record|
187
+ new(model_class, record.id).compare
188
+ end
189
+ end
190
+
191
+ def find_discrepancies(model_class)
192
+ compare_all(model_class).select do |comparison|
193
+ comparison[:differences] != { no_differences: true }
194
+ end
195
+ end
196
+ end
197
+ end
198
+
199
+ # Analysis tools
200
+ class StateAnalyzer
201
+ def self.analyze(model_class, model_id)
202
+ view = DualView.new(model_class, model_id)
203
+
204
+ {
205
+ comparison: view.compare,
206
+ audit_trail: view.audit_trail,
207
+ recommendations: generate_recommendations(view)
208
+ }
209
+ end
210
+
211
+ def self.generate_recommendations(view)
212
+ comparison = view.compare
213
+ recommendations = []
214
+
215
+ if comparison[:differences][:exists_mismatch]
216
+ recommendations << "State existence mismatch - investigate data consistency"
217
+ end
218
+
219
+ if comparison[:differences] != { no_differences: true } && !comparison[:differences][:exists_mismatch]
220
+ recommendations << "Attribute differences detected - consider which source is authoritative"
221
+ recommendations << "Differences: #{comparison[:differences].keys.join(', ')}"
222
+ end
223
+
224
+ if comparison[:event_sourced_view][:events_count] == 0 && comparison[:crud_view][:exists]
225
+ recommendations << "CRUD record exists but no events found - may have been created before Lyra was enabled"
226
+ end
227
+
228
+ recommendations
229
+ end
230
+ end
231
+ end
@@ -0,0 +1,67 @@
1
+ module Lyra
2
+ # Only define Engine when Rails is available
3
+ if defined?(Rails)
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace Lyra
6
+
7
+ config.generators do |g|
8
+ g.test_framework :rspec
9
+ end
10
+
11
+ initializer "lyra.configure_rails_initialization" do
12
+ # Initialize Event Store
13
+ Lyra.event_store ||= RailsEventStore::Client.new
14
+ end
15
+
16
+ initializer "lyra.inject_interceptors", after: :load_config_initializers do
17
+ ActiveSupport.on_load(:active_record) do
18
+ require "lyra/interceptors/crud_interceptor"
19
+ include Lyra::Interceptors::CrudInterceptor
20
+ end
21
+ end
22
+
23
+ # Install association interceptor for disabled projections mode
24
+ # Must run after active_record is loaded
25
+ initializer "lyra.install_association_interceptor", after: "lyra.inject_interceptors" do
26
+ ActiveSupport.on_load(:active_record) do
27
+ require "lyra/interceptors/association_interceptor"
28
+ Lyra::Interceptors::AssociationInterceptor.install!
29
+ end
30
+ end
31
+
32
+ # Install strict data access relation extension for update_all/delete_all
33
+ # This intercepts bulk operations on relations (Model.where(...).update_all)
34
+ initializer "lyra.install_strict_data_access", after: "lyra.inject_interceptors" do
35
+ ActiveSupport.on_load(:active_record) do
36
+ ActiveRecord::Relation.prepend(Lyra::StrictDataAccessRelation)
37
+ end
38
+ end
39
+
40
+ # Register event classes at startup for RailsEventStore deserialization
41
+ # This ensures Object.const_get() works when reading events from the store
42
+ initializer "lyra.register_event_classes", after: :load_config_initializers do
43
+ config.after_initialize do
44
+ next unless Lyra.config.monitored_models.any?
45
+
46
+ Lyra::Schema::EventClassRegistrar.register_all
47
+ end
48
+ end
49
+
50
+ # Schema validation on startup (after event classes are registered)
51
+ initializer "lyra.verify_schema", after: "lyra.register_event_classes" do
52
+ config.after_initialize do
53
+ next unless Lyra.config.monitored_models.any?
54
+ next unless Lyra.config.strict_schema
55
+
56
+ validator = Lyra::Schema::Validator.new
57
+ validator.enforce! if validator.schema_exists?
58
+ end
59
+ end
60
+
61
+ # Load rake tasks
62
+ rake_tasks do
63
+ load "tasks/lyra_schema.rake"
64
+ end
65
+ end
66
+ end
67
+ end
data/lib/lyra/event.rb ADDED
@@ -0,0 +1,71 @@
1
+ module Lyra
2
+ # Base event class for all Lyra events
3
+ class Event < RailsEventStore::Event
4
+ def self.inherited(subclass)
5
+ super
6
+ # Auto-register event types
7
+ Lyra::EventRegistry.register(subclass)
8
+ end
9
+
10
+ # Accessor methods that handle both symbol and string keys
11
+ # (JSON serializer converts symbols to strings)
12
+ def model_class
13
+ data[:model_class] || data["model_class"]
14
+ end
15
+
16
+ def model_id
17
+ data[:model_id] || data["model_id"]
18
+ end
19
+
20
+ def operation
21
+ op = data[:operation] || data["operation"]
22
+ op.is_a?(String) ? op.to_sym : op
23
+ end
24
+
25
+ def attributes
26
+ data[:attributes] || data["attributes"] || {}
27
+ end
28
+
29
+ def changes
30
+ data[:changes] || data["changes"] || {}
31
+ end
32
+
33
+ def timestamp
34
+ data[:timestamp] || data["timestamp"] || metadata[:timestamp]
35
+ end
36
+
37
+ def user_id
38
+ # user_id is stored in data[:metadata] (nested), not top-level metadata
39
+ nested = data[:metadata] || data["metadata"] || {}
40
+ nested[:user_id] || nested["user_id"] || metadata[:user_id]
41
+ end
42
+
43
+ def request_id
44
+ # request_id is stored in data[:metadata] (nested), not top-level metadata
45
+ nested = data[:metadata] || data["metadata"] || {}
46
+ nested[:request_id] || nested["request_id"] || metadata[:request_id]
47
+ end
48
+ end
49
+
50
+ module Events
51
+ # Dynamically created events will be placed here
52
+ end
53
+
54
+ class EventRegistry
55
+ @events = []
56
+
57
+ class << self
58
+ def register(event_class)
59
+ @events << event_class unless @events.include?(event_class)
60
+ end
61
+
62
+ def all
63
+ @events
64
+ end
65
+
66
+ def find_by_name(name)
67
+ @events.find { |e| e.name == name }
68
+ end
69
+ end
70
+ end
71
+ end