whodunit-chronicles 0.1.0.pre
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.
- checksums.yaml +7 -0
- data/.rubocop.yml +92 -0
- data/.yardopts +12 -0
- data/CHANGELOG.md +106 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE +21 -0
- data/README.md +281 -0
- data/Rakefile +18 -0
- data/lib/.gitkeep +0 -0
- data/lib/whodunit/chronicles/adapters/postgresql.rb +278 -0
- data/lib/whodunit/chronicles/audit_processor.rb +270 -0
- data/lib/whodunit/chronicles/change_event.rb +201 -0
- data/lib/whodunit/chronicles/configuration.rb +101 -0
- data/lib/whodunit/chronicles/service.rb +205 -0
- data/lib/whodunit/chronicles/stream_adapter.rb +91 -0
- data/lib/whodunit/chronicles/version.rb +7 -0
- data/lib/whodunit/chronicles.rb +69 -0
- data/lib/whodunit-chronicles.rb +4 -0
- data/whodunit-chronicles.gemspec +61 -0
- metadata +290 -0
@@ -0,0 +1,201 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Whodunit
|
4
|
+
module Chronicles
|
5
|
+
# Represents a database change event in a common format
|
6
|
+
#
|
7
|
+
# This class normalizes database changes from different sources
|
8
|
+
# (PostgreSQL WAL, MariaDB binlog, etc.) into a consistent format
|
9
|
+
# for processing by audit systems.
|
10
|
+
class ChangeEvent
|
11
|
+
# Supported database actions
|
12
|
+
ACTIONS = %w[INSERT UPDATE DELETE].freeze
|
13
|
+
|
14
|
+
attr_reader :table_name, :schema_name, :action, :primary_key, :old_data, :new_data,
|
15
|
+
:timestamp, :transaction_id, :sequence_number, :metadata
|
16
|
+
|
17
|
+
# Initialize a new change event
|
18
|
+
#
|
19
|
+
# @param table_name [String] The name of the table that changed
|
20
|
+
# @param action [String] The type of change (INSERT, UPDATE, DELETE)
|
21
|
+
# @param primary_key [Hash] The primary key values for the changed row
|
22
|
+
# @param old_data [Hash, nil] The row data before the change (nil for INSERT)
|
23
|
+
# @param new_data [Hash, nil] The row data after the change (nil for DELETE)
|
24
|
+
# @param timestamp [Time] When the change occurred
|
25
|
+
# @param schema_name [String] The schema name (optional, defaults to 'public')
|
26
|
+
# @param transaction_id [String, Integer] Database transaction identifier
|
27
|
+
# @param sequence_number [Integer] Sequence number within the transaction
|
28
|
+
# @param metadata [Hash] Additional adapter-specific metadata
|
29
|
+
def initialize(
|
30
|
+
table_name:,
|
31
|
+
action:,
|
32
|
+
primary_key:,
|
33
|
+
old_data: nil,
|
34
|
+
new_data: nil,
|
35
|
+
timestamp: Time.now,
|
36
|
+
schema_name: 'public',
|
37
|
+
transaction_id: nil,
|
38
|
+
sequence_number: nil,
|
39
|
+
metadata: {}
|
40
|
+
)
|
41
|
+
@table_name = table_name.to_s
|
42
|
+
@schema_name = schema_name.to_s
|
43
|
+
@action = validate_action(action.to_s.upcase)
|
44
|
+
@primary_key = primary_key || {}
|
45
|
+
@old_data = old_data
|
46
|
+
@new_data = new_data
|
47
|
+
@timestamp = timestamp
|
48
|
+
@transaction_id = transaction_id
|
49
|
+
@sequence_number = sequence_number
|
50
|
+
@metadata = metadata || {}
|
51
|
+
|
52
|
+
validate_data_consistency
|
53
|
+
end
|
54
|
+
|
55
|
+
# Get the qualified table name (schema.table)
|
56
|
+
#
|
57
|
+
# @return [String]
|
58
|
+
def qualified_table_name
|
59
|
+
"#{schema_name}.#{table_name}"
|
60
|
+
end
|
61
|
+
|
62
|
+
# Check if this is a create event
|
63
|
+
#
|
64
|
+
# @return [Boolean]
|
65
|
+
def create?
|
66
|
+
action == 'INSERT'
|
67
|
+
end
|
68
|
+
|
69
|
+
# Check if this is an update event
|
70
|
+
#
|
71
|
+
# @return [Boolean]
|
72
|
+
def update?
|
73
|
+
action == 'UPDATE'
|
74
|
+
end
|
75
|
+
|
76
|
+
# Check if this is a delete event
|
77
|
+
#
|
78
|
+
# @return [Boolean]
|
79
|
+
def delete?
|
80
|
+
action == 'DELETE'
|
81
|
+
end
|
82
|
+
|
83
|
+
# Get the changed columns for UPDATE events
|
84
|
+
#
|
85
|
+
# @return [Array<String>] Array of column names that changed
|
86
|
+
def changed_columns
|
87
|
+
return [] unless update? && old_data && new_data
|
88
|
+
|
89
|
+
old_data.keys.reject { |key| old_data[key] == new_data[key] }
|
90
|
+
end
|
91
|
+
|
92
|
+
# Get a hash of changes in [old_value, new_value] format
|
93
|
+
#
|
94
|
+
# @return [Hash] Hash of column_name => [old_value, new_value]
|
95
|
+
def changes
|
96
|
+
return {} unless update? && old_data && new_data
|
97
|
+
|
98
|
+
changed_columns.each_with_object({}) do |column, changes_hash|
|
99
|
+
changes_hash[column] = [old_data[column], new_data[column]]
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
# Get the current data for this event
|
104
|
+
#
|
105
|
+
# @return [Hash] The new_data for INSERT/UPDATE, old_data for DELETE
|
106
|
+
def current_data
|
107
|
+
case action
|
108
|
+
when 'INSERT', 'UPDATE'
|
109
|
+
new_data
|
110
|
+
when 'DELETE'
|
111
|
+
old_data
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
# Get all available data for this event
|
116
|
+
#
|
117
|
+
# @return [Hash] Combined old and new data
|
118
|
+
def all_data
|
119
|
+
(old_data || {}).merge(new_data || {})
|
120
|
+
end
|
121
|
+
|
122
|
+
# Convert to hash representation
|
123
|
+
#
|
124
|
+
# @return [Hash]
|
125
|
+
def to_h
|
126
|
+
{
|
127
|
+
table_name: table_name,
|
128
|
+
schema_name: schema_name,
|
129
|
+
qualified_table_name: qualified_table_name,
|
130
|
+
action: action,
|
131
|
+
primary_key: primary_key,
|
132
|
+
old_data: old_data,
|
133
|
+
new_data: new_data,
|
134
|
+
current_data: current_data,
|
135
|
+
changes: changes,
|
136
|
+
changed_columns: changed_columns,
|
137
|
+
timestamp: timestamp,
|
138
|
+
transaction_id: transaction_id,
|
139
|
+
sequence_number: sequence_number,
|
140
|
+
metadata: metadata,
|
141
|
+
}
|
142
|
+
end
|
143
|
+
|
144
|
+
# String representation
|
145
|
+
#
|
146
|
+
# @return [String]
|
147
|
+
def to_s
|
148
|
+
pk_str = primary_key.map { |k, v| "#{k}=#{v}" }.join(', ')
|
149
|
+
"#{action} #{qualified_table_name}(#{pk_str}) at #{timestamp}"
|
150
|
+
end
|
151
|
+
|
152
|
+
# Detailed string representation
|
153
|
+
#
|
154
|
+
# @return [String]
|
155
|
+
def inspect
|
156
|
+
"#<#{self.class.name} #{self}>"
|
157
|
+
end
|
158
|
+
|
159
|
+
# Compare events for equality
|
160
|
+
#
|
161
|
+
# @param other [ChangeEvent]
|
162
|
+
# @return [Boolean]
|
163
|
+
def ==(other)
|
164
|
+
return false unless other.is_a?(ChangeEvent)
|
165
|
+
|
166
|
+
table_name == other.table_name &&
|
167
|
+
schema_name == other.schema_name &&
|
168
|
+
action == other.action &&
|
169
|
+
primary_key == other.primary_key &&
|
170
|
+
old_data == other.old_data &&
|
171
|
+
new_data == other.new_data &&
|
172
|
+
timestamp == other.timestamp &&
|
173
|
+
transaction_id == other.transaction_id &&
|
174
|
+
sequence_number == other.sequence_number
|
175
|
+
end
|
176
|
+
|
177
|
+
private
|
178
|
+
|
179
|
+
def validate_action(action)
|
180
|
+
unless ACTIONS.include?(action)
|
181
|
+
raise ArgumentError, "Invalid action: #{action}. Must be one of: #{ACTIONS.join(', ')}"
|
182
|
+
end
|
183
|
+
|
184
|
+
action
|
185
|
+
end
|
186
|
+
|
187
|
+
def validate_data_consistency
|
188
|
+
case action
|
189
|
+
when 'INSERT'
|
190
|
+
raise ArgumentError, 'INSERT events must have new_data' if new_data.nil? || new_data.empty?
|
191
|
+
raise ArgumentError, 'INSERT events should not have old_data' unless old_data.nil?
|
192
|
+
when 'UPDATE'
|
193
|
+
raise ArgumentError, 'UPDATE events must have both old_data and new_data' if old_data.nil? || new_data.nil?
|
194
|
+
when 'DELETE'
|
195
|
+
raise ArgumentError, 'DELETE events must have old_data' if old_data.nil? || old_data.empty?
|
196
|
+
raise ArgumentError, 'DELETE events should not have new_data' unless new_data.nil?
|
197
|
+
end
|
198
|
+
end
|
199
|
+
end
|
200
|
+
end
|
201
|
+
end
|
@@ -0,0 +1,101 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Whodunit
|
4
|
+
module Chronicles
|
5
|
+
# Configuration management for Chronicles
|
6
|
+
#
|
7
|
+
# Provides a centralized configuration system with sensible defaults
|
8
|
+
# and validation for all Chronicles settings.
|
9
|
+
class Configuration
|
10
|
+
attr_accessor :database_url, :audit_database_url, :adapter, :publication_name,
|
11
|
+
:replication_slot_name, :batch_size, :max_retry_attempts, :retry_delay,
|
12
|
+
:logger, :table_filter, :schema_filter
|
13
|
+
|
14
|
+
def initialize
|
15
|
+
@database_url = ENV.fetch('DATABASE_URL', nil)
|
16
|
+
@audit_database_url = ENV.fetch('AUDIT_DATABASE_URL', nil)
|
17
|
+
@adapter = :postgresql
|
18
|
+
@publication_name = 'whodunit_audit'
|
19
|
+
@replication_slot_name = 'whodunit_audit_slot'
|
20
|
+
@batch_size = 100
|
21
|
+
@max_retry_attempts = 3
|
22
|
+
@retry_delay = 5
|
23
|
+
@logger = Dry::Logger.new
|
24
|
+
@table_filter = nil
|
25
|
+
@schema_filter = nil
|
26
|
+
end
|
27
|
+
|
28
|
+
# Validate configuration settings
|
29
|
+
#
|
30
|
+
# @raise [ConfigurationError] if configuration is invalid
|
31
|
+
def validate!
|
32
|
+
raise ConfigurationError, 'database_url is required' if database_url.nil?
|
33
|
+
raise ConfigurationError, 'adapter must be :postgresql' unless adapter == :postgresql
|
34
|
+
raise ConfigurationError, 'batch_size must be positive' unless batch_size.positive?
|
35
|
+
raise ConfigurationError, 'max_retry_attempts must be positive' unless max_retry_attempts.positive?
|
36
|
+
raise ConfigurationError, 'retry_delay must be positive' unless retry_delay.positive?
|
37
|
+
|
38
|
+
validate_publication_name!
|
39
|
+
validate_slot_name!
|
40
|
+
end
|
41
|
+
|
42
|
+
# Check if a table should be audited based on filters
|
43
|
+
#
|
44
|
+
# @param table_name [String] The table name to check
|
45
|
+
# @param schema_name [String] The schema name to check
|
46
|
+
# @return [Boolean] true if the table should be audited
|
47
|
+
def audit_table?(table_name, schema_name = 'public')
|
48
|
+
return false if filtered_by_schema?(schema_name)
|
49
|
+
return false if filtered_by_table?(table_name)
|
50
|
+
|
51
|
+
true
|
52
|
+
end
|
53
|
+
|
54
|
+
private
|
55
|
+
|
56
|
+
def validate_publication_name!
|
57
|
+
return if /\A[a-zA-Z_][a-zA-Z0-9_]*\z/.match?(publication_name)
|
58
|
+
|
59
|
+
raise ConfigurationError, 'publication_name must be a valid PostgreSQL identifier'
|
60
|
+
end
|
61
|
+
|
62
|
+
def validate_slot_name!
|
63
|
+
return if /\A[a-zA-Z_][a-zA-Z0-9_]*\z/.match?(replication_slot_name)
|
64
|
+
|
65
|
+
raise ConfigurationError, 'replication_slot_name must be a valid PostgreSQL identifier'
|
66
|
+
end
|
67
|
+
|
68
|
+
def filtered_by_schema?(schema_name)
|
69
|
+
return false unless schema_filter
|
70
|
+
|
71
|
+
case schema_filter
|
72
|
+
when Array
|
73
|
+
!schema_filter.include?(schema_name)
|
74
|
+
when String, Symbol
|
75
|
+
schema_name != schema_filter.to_s
|
76
|
+
when Proc
|
77
|
+
!schema_filter.call(schema_name)
|
78
|
+
else
|
79
|
+
false
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
def filtered_by_table?(table_name)
|
84
|
+
return false unless table_filter
|
85
|
+
|
86
|
+
case table_filter
|
87
|
+
when Array
|
88
|
+
!table_filter.include?(table_name)
|
89
|
+
when String, Symbol
|
90
|
+
table_name != table_filter.to_s
|
91
|
+
when Regexp
|
92
|
+
!table_filter.match?(table_name)
|
93
|
+
when Proc
|
94
|
+
!table_filter.call(table_name)
|
95
|
+
else
|
96
|
+
false
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
@@ -0,0 +1,205 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'concurrent-ruby'
|
4
|
+
|
5
|
+
module Whodunit
|
6
|
+
module Chronicles
|
7
|
+
# Main service orchestrator for audit streaming
|
8
|
+
#
|
9
|
+
# Coordinates the stream adapter and audit processor to provide
|
10
|
+
# a complete audit streaming solution with error handling and monitoring.
|
11
|
+
class Service
|
12
|
+
attr_reader :adapter, :processor, :logger, :executor
|
13
|
+
|
14
|
+
def initialize(
|
15
|
+
adapter: nil,
|
16
|
+
processor: nil,
|
17
|
+
logger: Chronicles.logger
|
18
|
+
)
|
19
|
+
@adapter = adapter || build_adapter
|
20
|
+
@processor = processor || AuditProcessor.new(logger: logger)
|
21
|
+
@logger = logger
|
22
|
+
@executor = Concurrent::ThreadPoolExecutor.new(
|
23
|
+
min_threads: 1,
|
24
|
+
max_threads: 4,
|
25
|
+
max_queue: 100,
|
26
|
+
fallback_policy: :caller_runs,
|
27
|
+
)
|
28
|
+
@running = false
|
29
|
+
@retry_count = 0
|
30
|
+
end
|
31
|
+
|
32
|
+
# Start the audit streaming service
|
33
|
+
#
|
34
|
+
# @return [self]
|
35
|
+
def start
|
36
|
+
return self if running?
|
37
|
+
|
38
|
+
log(:info, 'Starting Chronicles audit streaming service')
|
39
|
+
|
40
|
+
validate_setup!
|
41
|
+
test_connections!
|
42
|
+
|
43
|
+
@running = true
|
44
|
+
@retry_count = 0
|
45
|
+
|
46
|
+
start_streaming_with_retry
|
47
|
+
|
48
|
+
log(:info, 'Chronicles audit streaming service started successfully')
|
49
|
+
self
|
50
|
+
rescue StandardError => e
|
51
|
+
log(:error, 'Failed to start service', error: e.message)
|
52
|
+
@running = false
|
53
|
+
raise
|
54
|
+
end
|
55
|
+
|
56
|
+
# Stop the audit streaming service
|
57
|
+
#
|
58
|
+
# @return [void]
|
59
|
+
def stop
|
60
|
+
return unless running?
|
61
|
+
|
62
|
+
log(:info, 'Stopping Chronicles audit streaming service')
|
63
|
+
@running = false
|
64
|
+
|
65
|
+
adapter.stop_streaming if adapter.streaming?
|
66
|
+
@executor.shutdown
|
67
|
+
@executor.wait_for_termination(timeout: 30)
|
68
|
+
|
69
|
+
processor.close
|
70
|
+
log(:info, 'Chronicles audit streaming service stopped')
|
71
|
+
end
|
72
|
+
|
73
|
+
# Check if service is running
|
74
|
+
#
|
75
|
+
# @return [Boolean]
|
76
|
+
def running?
|
77
|
+
@running
|
78
|
+
end
|
79
|
+
|
80
|
+
# Get service status information
|
81
|
+
#
|
82
|
+
# @return [Hash]
|
83
|
+
def status
|
84
|
+
{
|
85
|
+
running: running?,
|
86
|
+
adapter_streaming: adapter.streaming?,
|
87
|
+
adapter_position: adapter.current_position,
|
88
|
+
retry_count: @retry_count,
|
89
|
+
executor_status: {
|
90
|
+
active_count: @executor.active_count,
|
91
|
+
completed_task_count: @executor.completed_task_count,
|
92
|
+
queue_length: @executor.queue_length,
|
93
|
+
},
|
94
|
+
}
|
95
|
+
end
|
96
|
+
|
97
|
+
# Set up the audit streaming infrastructure
|
98
|
+
#
|
99
|
+
# @return [void]
|
100
|
+
def setup!
|
101
|
+
log(:info, 'Setting up audit streaming infrastructure')
|
102
|
+
adapter.setup
|
103
|
+
log(:info, 'Audit streaming infrastructure setup completed')
|
104
|
+
end
|
105
|
+
|
106
|
+
# Tear down the audit streaming infrastructure
|
107
|
+
#
|
108
|
+
# @return [void]
|
109
|
+
def teardown!
|
110
|
+
log(:info, 'Tearing down audit streaming infrastructure')
|
111
|
+
stop if running?
|
112
|
+
adapter.teardown
|
113
|
+
log(:info, 'Audit streaming infrastructure teardown completed')
|
114
|
+
end
|
115
|
+
|
116
|
+
private
|
117
|
+
|
118
|
+
def build_adapter
|
119
|
+
case Chronicles.config.adapter
|
120
|
+
when :postgresql
|
121
|
+
Adapters::PostgreSQL.new(logger: logger)
|
122
|
+
else
|
123
|
+
raise ConfigurationError, "Unsupported adapter: #{Chronicles.config.adapter}"
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
def validate_setup!
|
128
|
+
Chronicles.config.validate!
|
129
|
+
|
130
|
+
return if adapter.test_connection
|
131
|
+
|
132
|
+
raise AdapterError, 'Failed to connect to source database'
|
133
|
+
end
|
134
|
+
|
135
|
+
def test_connections!
|
136
|
+
adapter.test_connection
|
137
|
+
# Test audit processor connection by creating a dummy connection
|
138
|
+
processor.send(:ensure_audit_connection)
|
139
|
+
rescue StandardError => e
|
140
|
+
raise AdapterError, "Connection test failed: #{e.message}"
|
141
|
+
end
|
142
|
+
|
143
|
+
def start_streaming_with_retry
|
144
|
+
@executor.post do
|
145
|
+
loop do
|
146
|
+
break unless running?
|
147
|
+
|
148
|
+
begin
|
149
|
+
adapter.start_streaming do |change_event|
|
150
|
+
process_change_event(change_event)
|
151
|
+
end
|
152
|
+
rescue StandardError => e
|
153
|
+
handle_streaming_error(e)
|
154
|
+
break unless should_retry?
|
155
|
+
end
|
156
|
+
end
|
157
|
+
end
|
158
|
+
end
|
159
|
+
|
160
|
+
def process_change_event(change_event)
|
161
|
+
return unless change_event
|
162
|
+
return unless should_audit_table?(change_event)
|
163
|
+
|
164
|
+
log(:debug, 'Processing change event',
|
165
|
+
table: change_event.qualified_table_name,
|
166
|
+
action: change_event.action
|
167
|
+
)
|
168
|
+
|
169
|
+
processor.process(change_event)
|
170
|
+
rescue StandardError => e
|
171
|
+
log(:error, 'Failed to process change event',
|
172
|
+
error: e.message,
|
173
|
+
event: change_event.to_s
|
174
|
+
)
|
175
|
+
end
|
176
|
+
|
177
|
+
def should_audit_table?(change_event)
|
178
|
+
Chronicles.config.audit_table?(
|
179
|
+
change_event.table_name,
|
180
|
+
change_event.schema_name,
|
181
|
+
)
|
182
|
+
end
|
183
|
+
|
184
|
+
def handle_streaming_error(error)
|
185
|
+
@retry_count += 1
|
186
|
+
log(:error, 'Streaming error occurred',
|
187
|
+
error: error.message,
|
188
|
+
retry_count: @retry_count,
|
189
|
+
max_retries: Chronicles.config.max_retry_attempts
|
190
|
+
)
|
191
|
+
|
192
|
+
# Wait before retry
|
193
|
+
sleep(Chronicles.config.retry_delay) if should_retry?
|
194
|
+
end
|
195
|
+
|
196
|
+
def should_retry?
|
197
|
+
running? && @retry_count < Chronicles.config.max_retry_attempts
|
198
|
+
end
|
199
|
+
|
200
|
+
def log(level, message, context = {})
|
201
|
+
logger.public_send(level, message, service: 'Chronicles::Service', **context)
|
202
|
+
end
|
203
|
+
end
|
204
|
+
end
|
205
|
+
end
|
@@ -0,0 +1,91 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Whodunit
|
4
|
+
module Chronicles
|
5
|
+
# Abstract base class for database streaming adapters
|
6
|
+
#
|
7
|
+
# Defines the interface that all database-specific adapters must implement
|
8
|
+
# for streaming database changes into audit events.
|
9
|
+
class StreamAdapter
|
10
|
+
attr_reader :logger
|
11
|
+
|
12
|
+
def initialize(logger: Chronicles.logger)
|
13
|
+
@logger = logger
|
14
|
+
@running = false
|
15
|
+
@position = nil
|
16
|
+
end
|
17
|
+
|
18
|
+
# Start streaming database changes
|
19
|
+
#
|
20
|
+
# @param block [Proc] Block to call for each change event
|
21
|
+
# @return [void]
|
22
|
+
# @raise [NotImplementedError] Must be implemented by subclasses
|
23
|
+
def start_streaming(&)
|
24
|
+
raise NotImplementedError, "#{self.class} must implement #start_streaming"
|
25
|
+
end
|
26
|
+
|
27
|
+
# Stop streaming database changes
|
28
|
+
#
|
29
|
+
# @return [void]
|
30
|
+
# @raise [NotImplementedError] Must be implemented by subclasses
|
31
|
+
def stop_streaming
|
32
|
+
raise NotImplementedError, "#{self.class} must implement #stop_streaming"
|
33
|
+
end
|
34
|
+
|
35
|
+
# Get current replication position
|
36
|
+
#
|
37
|
+
# @return [String, nil] Current position or nil if not available
|
38
|
+
# @raise [NotImplementedError] Must be implemented by subclasses
|
39
|
+
def current_position
|
40
|
+
raise NotImplementedError, "#{self.class} must implement #current_position"
|
41
|
+
end
|
42
|
+
|
43
|
+
# Check if adapter is currently streaming
|
44
|
+
#
|
45
|
+
# @return [Boolean]
|
46
|
+
def streaming?
|
47
|
+
@running
|
48
|
+
end
|
49
|
+
|
50
|
+
# Set up the database for streaming (create publications, slots, etc.)
|
51
|
+
#
|
52
|
+
# @return [void]
|
53
|
+
# @raise [NotImplementedError] Must be implemented by subclasses
|
54
|
+
def setup
|
55
|
+
raise NotImplementedError, "#{self.class} must implement #setup"
|
56
|
+
end
|
57
|
+
|
58
|
+
# Tear down streaming setup (remove publications, slots, etc.)
|
59
|
+
#
|
60
|
+
# @return [void]
|
61
|
+
# @raise [NotImplementedError] Must be implemented by subclasses
|
62
|
+
def teardown
|
63
|
+
raise NotImplementedError, "#{self.class} must implement #teardown"
|
64
|
+
end
|
65
|
+
|
66
|
+
# Test connection to the database
|
67
|
+
#
|
68
|
+
# @return [Boolean] true if connection is successful
|
69
|
+
# @raise [NotImplementedError] Must be implemented by subclasses
|
70
|
+
def test_connection
|
71
|
+
raise NotImplementedError, "#{self.class} must implement #test_connection"
|
72
|
+
end
|
73
|
+
|
74
|
+
protected
|
75
|
+
|
76
|
+
attr_writer :running, :position
|
77
|
+
|
78
|
+
# Log a message with context
|
79
|
+
#
|
80
|
+
# @param level [Symbol] Log level (:info, :warn, :error, etc.)
|
81
|
+
# @param message [String] Log message
|
82
|
+
# @param context [Hash] Additional context
|
83
|
+
def log(level, message, context = {})
|
84
|
+
logger.public_send(level, message,
|
85
|
+
adapter: self.class.name.split('::').last,
|
86
|
+
position: current_position,
|
87
|
+
**context)
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
@@ -0,0 +1,69 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'concurrent-ruby'
|
4
|
+
require 'dry/configurable'
|
5
|
+
require 'dry/logger'
|
6
|
+
|
7
|
+
require_relative 'chronicles/version'
|
8
|
+
require_relative 'chronicles/configuration'
|
9
|
+
require_relative 'chronicles/change_event'
|
10
|
+
require_relative 'chronicles/stream_adapter'
|
11
|
+
require_relative 'chronicles/audit_processor'
|
12
|
+
require_relative 'chronicles/service'
|
13
|
+
|
14
|
+
# Adapters
|
15
|
+
require_relative 'chronicles/adapters/postgresql'
|
16
|
+
|
17
|
+
module Whodunit
|
18
|
+
# Chronicles - The complete historical record of `whodunit did what?` data
|
19
|
+
#
|
20
|
+
# While Whodunit tracks who made changes, Chronicles captures what changed
|
21
|
+
# by streaming database events into comprehensive audit trails with zero
|
22
|
+
# Rails application overhead.
|
23
|
+
module Chronicles
|
24
|
+
extend Dry::Configurable
|
25
|
+
|
26
|
+
# Configuration settings
|
27
|
+
setting :logger, default: Dry::Logger.new
|
28
|
+
setting :database_url, default: ENV.fetch('DATABASE_URL', nil)
|
29
|
+
setting :audit_database_url, default: ENV.fetch('AUDIT_DATABASE_URL', nil)
|
30
|
+
setting :adapter, default: :postgresql
|
31
|
+
setting :publication_name, default: 'whodunit_audit'
|
32
|
+
setting :replication_slot_name, default: 'whodunit_audit_slot'
|
33
|
+
setting :batch_size, default: 100
|
34
|
+
setting :max_retry_attempts, default: 3
|
35
|
+
setting :retry_delay, default: 5
|
36
|
+
|
37
|
+
class Error < StandardError; end
|
38
|
+
class ConfigurationError < Error; end
|
39
|
+
class AdapterError < Error; end
|
40
|
+
class ReplicationError < Error; end
|
41
|
+
|
42
|
+
# Configure Chronicles
|
43
|
+
#
|
44
|
+
# @example
|
45
|
+
# Whodunit::Chronicles.configure do |config|
|
46
|
+
# config.database_url = "postgresql://localhost/myapp"
|
47
|
+
# config.audit_database_url = "postgresql://localhost/myapp_audit"
|
48
|
+
# config.adapter = :postgresql
|
49
|
+
# end
|
50
|
+
def self.configure
|
51
|
+
yield(config) if block_given?
|
52
|
+
config
|
53
|
+
end
|
54
|
+
|
55
|
+
# Get the configured logger
|
56
|
+
#
|
57
|
+
# @return [Dry::Logger]
|
58
|
+
def self.logger
|
59
|
+
config.logger
|
60
|
+
end
|
61
|
+
|
62
|
+
# Start the audit streaming service
|
63
|
+
#
|
64
|
+
# @return [Service]
|
65
|
+
def self.start
|
66
|
+
Service.new.start
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|