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,23 @@
1
+ module Lyra
2
+ class ApplicationController < ActionController::Base
3
+ # Base controller for Lyra engine
4
+ protect_from_forgery with: :exception, unless: -> { request.format.json? }
5
+
6
+ helper_method :pam_dsl_available?, :user_tracking_configured?, :petri_flow_available?
7
+
8
+ # Check if PAM DSL is available for privacy features
9
+ def pam_dsl_available?
10
+ PAM_DSL_AVAILABLE
11
+ end
12
+
13
+ # Check if PetriFlow is available for formal verification
14
+ def petri_flow_available?
15
+ PETRI_FLOW_AVAILABLE
16
+ end
17
+
18
+ # Check if user tracking is configured via Rails CurrentAttributes
19
+ def user_tracking_configured?
20
+ defined?(::Current) && ::Current.respond_to?(:user)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,624 @@
1
+ module Lyra
2
+ class DashboardController < ApplicationController
3
+ helper_method :user_display_name
4
+ before_action :ensure_models_loaded
5
+
6
+ # GET /lyra/dashboard
7
+ def index
8
+ @monitored_models = Lyra.config.monitored_models
9
+ @mode = Lyra.config.mode
10
+ end
11
+
12
+ # GET /lyra/dashboard/model/:model_class
13
+ def model_overview
14
+ @model_class = params[:model_class].constantize
15
+ @records_count = @model_class.count
16
+ @events_count = count_events_for_model(@model_class)
17
+ end
18
+
19
+ # GET /lyra/dashboard/compare/:model_class/:id
20
+ def compare
21
+ model_class = params[:model_class].constantize
22
+ model_id = params[:id]
23
+
24
+ @comparison = DualView.new(model_class, model_id).compare
25
+ @audit_trail = DualView.new(model_class, model_id).audit_trail
26
+ @analysis = StateAnalyzer.analyze(model_class, model_id)
27
+
28
+ render json: {
29
+ comparison: @comparison,
30
+ audit_trail: @audit_trail,
31
+ analysis: @analysis
32
+ }
33
+ end
34
+
35
+ # GET /lyra/dashboard/discrepancies/:model_class
36
+ def discrepancies
37
+ @model_class = params[:model_class].constantize
38
+ @discrepancies = DualView.find_discrepancies(@model_class)
39
+
40
+ respond_to do |format|
41
+ format.html
42
+ format.json { render json: { discrepancies: @discrepancies } }
43
+ end
44
+ end
45
+
46
+ # GET /lyra/config/projections
47
+ def projections
48
+ @mode = Lyra.config.mode
49
+ @projection_mode = Lyra.config.projection_mode
50
+ @strict_projections = Lyra.config.strict_projections
51
+ @monitored_models = Lyra.config.monitored_models
52
+ @async_inline = Lyra.config.async_projections_inline
53
+
54
+ # Projection types available in Lyra
55
+ @projection_types = [
56
+ {
57
+ name: "StateProjection",
58
+ description: "Rebuilds current state by replaying events. Used for dual-view comparison between DB and event-sourced state.",
59
+ use_case: "Consistency checking, debugging, state recovery"
60
+ },
61
+ {
62
+ name: "AuditProjection",
63
+ description: "Generates chronological audit trail from events. Shows who changed what and when.",
64
+ use_case: "Compliance reporting, change history, forensics"
65
+ },
66
+ {
67
+ name: "ModelProjection",
68
+ description: "Synchronizes ActiveRecord model tables from events using raw SQL (bypasses callbacks).",
69
+ use_case: "Event sourcing mode - keeps read models in sync"
70
+ },
71
+ {
72
+ name: "AsyncProjectionJob",
73
+ description: "Background job for eventual consistency. Retries with exponential backoff on failures.",
74
+ use_case: "High-throughput systems, async projection mode",
75
+ queue: "lyra_projections",
76
+ retry_attempts: 5
77
+ }
78
+ ]
79
+
80
+ # Get model configurations
81
+ @model_configs = {}
82
+ @monitored_models.each do |model_class|
83
+ config = Lyra.config.model_config(model_class)
84
+ @model_configs[model_class.name] = {
85
+ event_prefix: config.event_prefix,
86
+ aggregate_class: config.aggregate_class,
87
+ command_handler: config.command_handler,
88
+ privacy_policy: config.privacy_policy
89
+ }
90
+ end
91
+
92
+ # Get event counts per model with operation breakdown
93
+ @model_stats = {}
94
+ @monitored_models.each do |model_class|
95
+ events = collect_events_for_model(model_class)
96
+ record_count = model_class.count rescue 0
97
+
98
+ # Group by operation
99
+ by_operation = events.group_by { |e| e.data[:operation] || e.data["operation"] }
100
+
101
+ @model_stats[model_class.name] = {
102
+ events: events.count,
103
+ records: record_count,
104
+ avg_events_per_record: record_count > 0 ? (events.count.to_f / record_count).round(2) : 0,
105
+ created: by_operation[:created]&.count || by_operation["created"]&.count || 0,
106
+ updated: by_operation[:updated]&.count || by_operation["updated"]&.count || 0,
107
+ destroyed: by_operation[:destroyed]&.count || by_operation["destroyed"]&.count || 0,
108
+ last_event_at: events.max_by { |e| e.metadata[:timestamp] || e.timestamp rescue Time.at(0) }&.then { |e| e.metadata[:timestamp] || e.timestamp rescue nil }
109
+ }
110
+ end
111
+ end
112
+
113
+ # GET /lyra/dashboard/audit_trail
114
+ def audit_trail
115
+ @monitored_models = Lyra.config.monitored_models
116
+ @selected_model = params[:model_class]
117
+ @selected_id = params[:record_id]
118
+ @search_query = params[:search]
119
+
120
+ # If a model is selected, load available records for the picker
121
+ if @selected_model.present?
122
+ @model_class = @selected_model.constantize
123
+ @available_records = fetch_available_records(@model_class, @search_query)
124
+ end
125
+
126
+ if @selected_model.present? && @selected_id.present?
127
+ @record = @model_class.find_by(id: @selected_id)
128
+ @audit_entries = AuditProjection.audit_trail(@model_class, @selected_id)
129
+ end
130
+ end
131
+
132
+ # GET /lyra/dashboard/audit_trail/:model_class/:id
133
+ def audit_trail_for_record
134
+ @model_class = params[:model_class].constantize
135
+ @model_id = params[:id]
136
+ @record = @model_class.find_by(id: @model_id)
137
+ @audit_entries = AuditProjection.audit_trail(@model_class, @model_id)
138
+ @monitored_models = Lyra.config.monitored_models
139
+
140
+ respond_to do |format|
141
+ format.html { render :audit_trail }
142
+ format.json { render json: { audit_trail: @audit_entries, record_id: @model_id, model: @model_class.name } }
143
+ end
144
+ end
145
+
146
+ # GET /lyra/dashboard/visualizations/timeline
147
+ def timeline
148
+ events = fetch_recent_events(limit: params[:limit]&.to_i || 100)
149
+ timeline = Visualization::Timeline.new(events)
150
+
151
+ render json: timeline.to_data
152
+ end
153
+
154
+ # GET /lyra/dashboard/visualizations/event_graph
155
+ # Returns filtered events based on query params
156
+ def event_graph
157
+ events = fetch_filtered_events(
158
+ model_class: params[:model_class],
159
+ operation: params[:operation],
160
+ limit: params[:limit]&.to_i || 50
161
+ )
162
+ graph = Visualization::EventGraph.new(events)
163
+
164
+ render json: graph.to_data
165
+ end
166
+
167
+ # GET /lyra/dashboard/visualizations/entity_graph/:model_class/:id
168
+ # Returns focused graph for a specific entity's lifecycle
169
+ def entity_graph
170
+ model_class = params[:model_class]
171
+ model_id = params[:id]
172
+ depth = params[:depth]&.to_i || 1 # How many related entities to include
173
+
174
+ events = fetch_entity_events(model_class, model_id, depth: depth)
175
+ graph = Visualization::EventGraph.new(events)
176
+
177
+ render json: graph.to_data.merge(
178
+ focused_entity: { model_class: model_class, model_id: model_id }
179
+ )
180
+ end
181
+
182
+ # GET /lyra/dashboard/visualizations/event_list
183
+ # Returns list of entities with event counts for the picker UI
184
+ def event_list
185
+ model_filter = params[:model_class]
186
+ limit = params[:limit]&.to_i || 20
187
+
188
+ entities = []
189
+ models = model_filter.present? ? [model_filter.constantize] : Lyra.config.monitored_models
190
+
191
+ models.each do |model_class|
192
+ model_class.order(updated_at: :desc).limit(limit).each do |record|
193
+ stream_name = "#{model_class.name}$#{record.id}"
194
+ begin
195
+ event_count = Lyra.config.event_store.read.stream(stream_name).count
196
+ next if event_count == 0
197
+
198
+ # Get display name for the record
199
+ display_name = record_display_name(record)
200
+
201
+ entities << {
202
+ model_class: model_class.name,
203
+ model_id: record.id,
204
+ display_name: display_name,
205
+ event_count: event_count,
206
+ updated_at: record.updated_at
207
+ }
208
+ rescue
209
+ # Skip if stream doesn't exist
210
+ end
211
+ end
212
+ end
213
+
214
+ # Sort by event_count descending, then updated_at descending
215
+ entities.sort_by! { |e| [-e[:event_count], -e[:updated_at].to_i] }
216
+ entities = entities.first(limit)
217
+
218
+ render json: {
219
+ entities: entities,
220
+ model_classes: Lyra.config.monitored_models.map(&:name)
221
+ }
222
+ end
223
+
224
+ # GET /lyra/dashboard/visualizations/heatmap
225
+ def heatmap
226
+ events = fetch_events_for_period(days: params[:days]&.to_i || 7)
227
+ heatmap = Visualization::ActivityHeatmap.new(events)
228
+
229
+ render json: heatmap.to_data
230
+ end
231
+
232
+ # GET /lyra/visualizations/event_graph (HTML view)
233
+ def event_graph_view
234
+ @limit = params[:limit]&.to_i || 50
235
+ events = fetch_recent_events(limit: @limit)
236
+ graph = Visualization::EventGraph.new(events)
237
+ @graph_data = graph.to_data
238
+ @mermaid = graph.to_mermaid
239
+ end
240
+
241
+ # GET /lyra/visualizations/heatmap (HTML view)
242
+ def heatmap_view
243
+ @days = params[:days]&.to_i || 7
244
+ events = fetch_events_for_period(days: @days)
245
+ heatmap = Visualization::ActivityHeatmap.new(events)
246
+ @heatmap_data = heatmap.to_data
247
+ @hourly = heatmap.hourly_breakdown
248
+ @daily = heatmap.daily_breakdown
249
+ @operation_heatmap = heatmap.operation_heatmap
250
+ end
251
+
252
+ # GET /lyra/verification (HTML view)
253
+ def verification
254
+ @petri_flow_available = Lyra.petri_flow_available?
255
+
256
+ if @petri_flow_available
257
+ verifier = Lyra::Verification::CrudVerifier.new
258
+ @report = verifier.verify_all
259
+ @summary = @report[:summary]
260
+ @lifecycle = @report[:details][:lifecycle]
261
+ @modes = @report[:details][:modes]
262
+ @generated_workflows = @report[:details][:generated_workflows] || {}
263
+ else
264
+ @report = nil
265
+ @summary = nil
266
+ @generated_workflows = {}
267
+ end
268
+ end
269
+
270
+ # GET /lyra/verification.json
271
+ def verification_data
272
+ if Lyra.petri_flow_available?
273
+ verifier = Lyra::Verification::CrudVerifier.new
274
+ render json: verifier.verify_all
275
+ else
276
+ render json: { error: "PetriFlow not available", available: false }, status: :service_unavailable
277
+ end
278
+ end
279
+
280
+ # GET /lyra/dashboard/schema
281
+ def schema
282
+ @schema = Schema::Store.load_current || Schema::Generator.generate
283
+ @report = Schema::Reporter.new.generate
284
+
285
+ # Parse schema for view
286
+ @version = @schema[:version]
287
+ @fingerprint = @schema[:fingerprint]
288
+ @created_at = @schema[:created_at]
289
+ @lyra_version = @schema[:lyra_version]
290
+ @configuration = @schema[:configuration] || {}
291
+ @models = @schema[:models] || {}
292
+ @summary = @schema[:summary] || {}
293
+
294
+ # Load version history
295
+ @history = Schema::Store.history.reverse # Most recent first
296
+
297
+ # Check for uncommitted changes
298
+ if @version
299
+ current_schema = Schema::Generator.generate
300
+ @pending_changes = Schema::Diff.compare(@schema, current_schema)
301
+ @has_pending_changes = @pending_changes.any?
302
+ else
303
+ @pending_changes = []
304
+ @has_pending_changes = false
305
+ end
306
+
307
+ respond_to do |format|
308
+ format.html
309
+ format.json { render json: @schema }
310
+ end
311
+ end
312
+
313
+ # GET /lyra/dashboard/schema/:version
314
+ def schema_version
315
+ version = params[:version].to_i
316
+ @schema = Schema::Store.load_version(version)
317
+
318
+ unless @schema
319
+ redirect_to schema_path, alert: "Schema version #{version} not found"
320
+ return
321
+ end
322
+
323
+ # Schema metadata
324
+ @version = @schema[:version]
325
+ @fingerprint = @schema[:fingerprint]
326
+ @created_at = @schema[:created_at]
327
+ @lyra_version = @schema[:lyra_version]
328
+ @rails_version = @schema[:rails_version]
329
+ @configuration = @schema[:configuration] || {}
330
+ @models = @schema[:models] || {}
331
+ @summary = @schema[:summary] || {}
332
+
333
+ # Load version history for navigation
334
+ @history = Schema::Store.history
335
+ @current_index = @history.find_index { |h| h[:version] == version }
336
+ @prev_version = @current_index && @current_index > 0 ? @history[@current_index - 1][:version] : nil
337
+ @next_version = @current_index && @current_index < @history.size - 1 ? @history[@current_index + 1][:version] : nil
338
+
339
+ # Compare with previous version if available
340
+ if @prev_version
341
+ prev_schema = Schema::Store.load_version(@prev_version)
342
+ @diff_from_previous = Schema::Diff.compare(prev_schema, @schema) if prev_schema
343
+ end
344
+
345
+ respond_to do |format|
346
+ format.html
347
+ format.json { render json: @schema }
348
+ end
349
+ end
350
+
351
+ # GET /lyra/dashboard/schema/history
352
+ def schema_history
353
+ @history = Schema::Store.history.reverse # Most recent first
354
+ @current_version = Schema::Store.load_current&.dig(:version)
355
+
356
+ respond_to do |format|
357
+ format.html
358
+ format.json { render json: { history: @history, current_version: @current_version } }
359
+ end
360
+ end
361
+
362
+ # GET /lyra/dashboard/visualizations/model_heatmap/:model_class
363
+ def model_heatmap
364
+ model_class = params[:model_class].constantize
365
+ events = fetch_events_for_model(model_class, days: params[:days]&.to_i || 7)
366
+ heatmap = Visualization::ActivityHeatmap.new(events)
367
+
368
+ render json: {
369
+ heatmap: heatmap.to_data,
370
+ model_specific: heatmap.model_heatmap,
371
+ operation_breakdown: heatmap.operation_heatmap
372
+ }
373
+ end
374
+
375
+ private
376
+
377
+ # Ensure models are loaded so Lyra.config.monitored_models is populated
378
+ # In development mode, Rails uses lazy loading so models with monitor_with_lyra
379
+ # may not be loaded yet when the dashboard is accessed
380
+ def ensure_models_loaded
381
+ return if Rails.application.config.eager_load
382
+
383
+ # Only eager load the models directory to avoid loading the entire app
384
+ models_path = Rails.root.join("app", "models")
385
+ if models_path.exist?
386
+ Dir[models_path.join("**", "*.rb")].each do |file|
387
+ require_dependency file
388
+ rescue StandardError
389
+ # Skip files that can't be loaded
390
+ end
391
+ end
392
+ end
393
+
394
+ def count_events_for_model(model_class)
395
+ collect_events_for_model(model_class).count
396
+ end
397
+
398
+ def collect_events_for_model(model_class)
399
+ # Collect all events for a model class
400
+ events = []
401
+ model_class.pluck(:id).each do |id|
402
+ stream_name = "#{model_class.name}$#{id}"
403
+ begin
404
+ events.concat(Lyra.config.event_store.read.stream(stream_name).to_a)
405
+ rescue
406
+ # Skip if stream doesn't exist
407
+ end
408
+ end
409
+ events
410
+ end
411
+
412
+ def fetch_available_records(model_class, search_query = nil)
413
+ # Determine display fields for this model
414
+ display_fields = [:name, :title, :email, :firstname, :lastname, :description, :label].select do |field|
415
+ model_class.column_names.include?(field.to_s)
416
+ end
417
+
418
+ scope = model_class.order(updated_at: :desc)
419
+
420
+ # Apply search if provided
421
+ if search_query.present? && display_fields.any?
422
+ conditions = display_fields.map { |f| "#{f} LIKE ?" }.join(' OR ')
423
+ search_term = "%#{search_query}%"
424
+ scope = scope.where(conditions, *([search_term] * display_fields.size))
425
+ end
426
+
427
+ # Limit results
428
+ records = scope.limit(50)
429
+
430
+ # Build display info for each record
431
+ records.map do |record|
432
+ display_name = display_fields.map { |f| record.send(f) }.compact.first
433
+ display_name ||= "Record ##{record.id}"
434
+
435
+ # Add more context if available
436
+ if model_class.column_names.include?('firstname') && model_class.column_names.include?('lastname')
437
+ full_name = [record.firstname, record.lastname].compact.join(' ')
438
+ display_name = full_name if full_name.present?
439
+ end
440
+
441
+ {
442
+ id: record.id,
443
+ display_name: display_name,
444
+ updated_at: record.updated_at,
445
+ extra_info: build_extra_info(record, display_fields)
446
+ }
447
+ end
448
+ end
449
+
450
+ def build_extra_info(record, display_fields)
451
+ info = []
452
+ info << record.email if record.respond_to?(:email) && record.email.present? && !display_fields.include?(:email)
453
+ info << record.phone if record.respond_to?(:phone) && record.phone.present?
454
+ info << "Amount: #{record.amount}" if record.respond_to?(:amount) && record.amount.present?
455
+ info << record.status.to_s.titleize if record.respond_to?(:status) && record.status.present?
456
+ info.first(2).join(' | ')
457
+ end
458
+
459
+ def fetch_recent_events(limit: 100)
460
+ events = []
461
+ Lyra.config.monitored_models.each do |model_class|
462
+ model_class.order(updated_at: :desc).limit(limit / Lyra.config.monitored_models.size).each do |record|
463
+ stream_name = "#{model_class.name}$#{record.id}"
464
+ begin
465
+ stream_events = Lyra.config.event_store.read.stream(stream_name).to_a
466
+ events.concat(stream_events)
467
+ rescue
468
+ # Skip if stream doesn't exist
469
+ end
470
+ end
471
+ end
472
+ events.sort_by { |e| event_timestamp(e) }.last(limit)
473
+ end
474
+
475
+ def fetch_filtered_events(model_class: nil, operation: nil, limit: 50)
476
+ events = []
477
+ models = model_class.present? ? [model_class.constantize] : Lyra.config.monitored_models
478
+
479
+ models.each do |klass|
480
+ klass.order(updated_at: :desc).limit(limit).each do |record|
481
+ stream_name = "#{klass.name}$#{record.id}"
482
+ begin
483
+ stream_events = Lyra.config.event_store.read.stream(stream_name).to_a
484
+ events.concat(stream_events)
485
+ rescue
486
+ # Skip if stream doesn't exist
487
+ end
488
+ end
489
+ end
490
+
491
+ # Filter by operation if specified
492
+ if operation.present?
493
+ events = events.select do |e|
494
+ op = e.data[:operation] || e.data["operation"]
495
+ op.to_s == operation.to_s
496
+ end
497
+ end
498
+
499
+ events.sort_by { |e| event_timestamp(e) }.last(limit)
500
+ end
501
+
502
+ def fetch_entity_events(model_class, model_id, depth: 1)
503
+ events = []
504
+ stream_name = "#{model_class}$#{model_id}"
505
+
506
+ begin
507
+ # Get all events for the primary entity
508
+ primary_events = Lyra.config.event_store.read.stream(stream_name).to_a
509
+ events.concat(primary_events)
510
+
511
+ # If depth > 0, also include related entities (via correlation_id)
512
+ if depth > 0 && primary_events.any?
513
+ correlation_ids = primary_events.map { |e| e.metadata[:correlation_id] }.compact.uniq
514
+
515
+ # Find events sharing correlation IDs
516
+ correlation_ids.each do |corr_id|
517
+ Lyra.config.monitored_models.each do |klass|
518
+ klass.order(updated_at: :desc).limit(20).each do |record|
519
+ other_stream = "#{klass.name}$#{record.id}"
520
+ next if other_stream == stream_name
521
+
522
+ begin
523
+ other_events = Lyra.config.event_store.read.stream(other_stream).to_a
524
+ related = other_events.select { |e| e.metadata[:correlation_id] == corr_id }
525
+ events.concat(related)
526
+ rescue
527
+ # Skip
528
+ end
529
+ end
530
+ end
531
+ end
532
+ end
533
+ rescue
534
+ # Stream doesn't exist
535
+ end
536
+
537
+ events.uniq { |e| e.event_id }.sort_by { |e| event_timestamp(e) }
538
+ end
539
+
540
+ def record_display_name(record)
541
+ # Try common display name fields
542
+ [:name, :title, :email, :display_name].each do |field|
543
+ return record.send(field) if record.respond_to?(field) && record.send(field).present?
544
+ end
545
+
546
+ # Try firstname + lastname
547
+ if record.respond_to?(:firstname) && record.respond_to?(:lastname)
548
+ full_name = [record.firstname, record.lastname].compact.join(' ')
549
+ return full_name if full_name.present?
550
+ end
551
+
552
+ # Fallback
553
+ "#{record.class.name}##{record.id}"
554
+ end
555
+
556
+ def fetch_events_for_period(days: 7)
557
+ cutoff = days.days.ago
558
+ events = []
559
+
560
+ Lyra.config.monitored_models.each do |model_class|
561
+ model_class.where("updated_at >= ?", cutoff).find_each do |record|
562
+ stream_name = "#{model_class.name}$#{record.id}"
563
+ begin
564
+ stream_events = Lyra.config.event_store.read.stream(stream_name).to_a
565
+ events.concat(stream_events.select { |e| event_timestamp(e) >= cutoff })
566
+ rescue
567
+ # Skip if stream doesn't exist
568
+ end
569
+ end
570
+ end
571
+
572
+ events.sort_by { |e| event_timestamp(e) }
573
+ end
574
+
575
+ def fetch_events_for_model(model_class, days: 7)
576
+ cutoff = days.days.ago
577
+ events = []
578
+
579
+ model_class.where("updated_at >= ?", cutoff).find_each do |record|
580
+ stream_name = "#{model_class.name}$#{record.id}"
581
+ begin
582
+ stream_events = Lyra.config.event_store.read.stream(stream_name).to_a
583
+ events.concat(stream_events.select { |e| event_timestamp(e) >= cutoff })
584
+ rescue
585
+ # Skip if stream doesn't exist
586
+ end
587
+ end
588
+
589
+ events.sort_by { |e| event_timestamp(e) }
590
+ end
591
+
592
+ def event_timestamp(event)
593
+ return event.timestamp if event.respond_to?(:timestamp) && event.timestamp
594
+ event.data[:timestamp] || event.data["timestamp"] || event.metadata[:timestamp]
595
+ end
596
+
597
+ # Look up user display name from user_id
598
+ # Tries to find a User model with name/display_name method
599
+ # Falls back to "User #ID" if lookup fails
600
+ def user_display_name(user_id)
601
+ return nil unless user_id
602
+
603
+ begin
604
+ # Try to find User model in the host app
605
+ if defined?(::User) && ::User.respond_to?(:find_by)
606
+ user = ::User.find_by(id: user_id)
607
+ if user
608
+ # Prefer display_name, then name, then username
609
+ return user.display_name if user.respond_to?(:display_name)
610
+ return user.name if user.respond_to?(:name)
611
+ return user.username if user.respond_to?(:username)
612
+ return user.email if user.respond_to?(:email)
613
+ end
614
+ end
615
+ rescue => e
616
+ # Log but don't fail if user lookup has issues
617
+ Rails.logger.debug { "Lyra: Could not look up user ##{user_id}: #{e.message}" }
618
+ end
619
+
620
+ # Fallback to showing the ID
621
+ "User ##{user_id}"
622
+ end
623
+ end
624
+ end