active_model_logger 0.2.1

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.
@@ -0,0 +1,380 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require "active_model_logger/block_logger"
5
+ require "active_model_logger/loggable_helpers"
6
+
7
+ module ActiveModelLogger
8
+ # The Loggable concern provides comprehensive logging functionality to ActiveRecord models.
9
+ # It allows models to create, query, and manage log entries with various levels of detail.
10
+ #
11
+ # @example Basic usage
12
+ # class User < ApplicationRecord
13
+ # include ActiveModelLogger::Loggable
14
+ # end
15
+ #
16
+ # user = User.create!(name: "John")
17
+ # user.log("User created successfully")
18
+ # user.log("User updated", log_level: "info", metadata: { action: "update" })
19
+ #
20
+ # @example Configuration
21
+ # class User < ApplicationRecord
22
+ # include ActiveModelLogger::Loggable
23
+ #
24
+ # configure_loggable(
25
+ # default_visible_to: "user",
26
+ # default_log_level: "debug"
27
+ # )
28
+ # end
29
+ #
30
+ # @example Batch logging
31
+ # user.log_batch([
32
+ # { message: "Step 1 completed", status: "success" },
33
+ # { message: "Step 2 completed", status: "success" },
34
+ # { message: "Process finished", status: "completed" }
35
+ # ])
36
+ #
37
+ module Loggable
38
+ extend ActiveSupport::Concern
39
+ include LoggableHelpers
40
+
41
+ included do
42
+ has_many :active_model_logs, as: :loggable, dependent: :destroy, class_name: "ActiveModelLogger::Log"
43
+
44
+ # Configuration options
45
+ class_attribute :loggable_config, default: {
46
+ default_visible_to: "admin",
47
+ default_log_level: "info",
48
+ auto_log_chain: true,
49
+ log_chain_method: :log_chain,
50
+ stdout_logging: true,
51
+ stdout_logger: nil,
52
+ }
53
+
54
+ # Creates a new log entry for this object.
55
+ #
56
+ # @param message [String] The log message (required)
57
+ # @param visible_to [String] Who can see this log (defaults to configured default)
58
+ # @param log_level [String] The log level: debug, info, warn, error, fatal (defaults to configured default)
59
+ # @param log_chain [String] Custom log chain for grouping related logs (optional)
60
+ # @param metadata [Hash] Additional metadata to store with the log entry
61
+ # @option metadata [String] :status Status of the logged event
62
+ # @option metadata [String] :category Category of the logged event
63
+ # @option metadata [String] :type Type of the logged event
64
+ # @option metadata [String] :title Title of the logged event
65
+ # @return [ActiveModelLogger::Log] The created log entry
66
+ # @raise [ArgumentError] If message is nil or log_level is invalid
67
+ # @raise [ActiveModelLogger::LogCreationError] If the log entry cannot be created
68
+ #
69
+ # @example Basic logging
70
+ # user.log("User logged in")
71
+ #
72
+ # @example Logging with log_chain
73
+ # user.log("Payment processed", log_chain: "payment_123")
74
+ #
75
+ # @example Advanced logging with metadata
76
+ # user.log("Payment processed",
77
+ # visible_to: "admin",
78
+ # log_level: "info",
79
+ # log_chain: "payment_123",
80
+ # metadata: {
81
+ # status: "success",
82
+ # category: "payment",
83
+ # amount: 100.00
84
+ # })
85
+ #
86
+ def log(message, visible_to: nil, log_level: nil, log_chain: nil, metadata: {})
87
+ # Use configuration defaults if not provided
88
+ visible_to ||= loggable_config[:default_visible_to]
89
+ log_level ||= loggable_config[:default_log_level]
90
+
91
+ # Validate inputs
92
+ validate_log_inputs(message, visible_to, log_level, metadata)
93
+
94
+ # Normalize inputs
95
+ normalized_log_level = log_level.downcase
96
+ normalized_metadata = metadata.transform_keys(&:to_s)
97
+
98
+ # Generate log chain (use provided log_chain or generate one)
99
+ log_chain_value = log_chain || generate_log_chain(normalized_metadata)
100
+
101
+ # If a new log_chain was provided, update the cached log_chain
102
+ @log_chain = log_chain if log_chain
103
+
104
+ # Create log attributes
105
+ log_attributes = build_log_attributes(
106
+ message, visible_to, normalized_log_level, normalized_metadata, log_chain_value
107
+ )
108
+
109
+ # Create log entry
110
+ log_entry = create_log_entry(log_attributes)
111
+
112
+ # Send to stdout if enabled
113
+ stdout_params = {
114
+ log_entry: log_entry,
115
+ message: message,
116
+ visible_to: visible_to,
117
+ log_level: normalized_log_level,
118
+ metadata: normalized_metadata,
119
+ log_chain_value: log_chain_value,
120
+ }
121
+ log_to_stdout(stdout_params)
122
+
123
+ log_entry
124
+ rescue ActiveRecord::RecordInvalid => e
125
+ raise ActiveModelLogger::LogCreationError, "Failed to create log entry: #{e.message}"
126
+ end
127
+
128
+ # Batch logging for performance
129
+ def log_batch(entries)
130
+ return [] if entries.empty?
131
+
132
+ log_entries = build_batch_log_entries(entries)
133
+
134
+ # Use insert_all for efficient batch insertion
135
+ ActiveModelLogger::Log.insert_all(log_entries)
136
+
137
+ # Send to stdout if enabled
138
+ log_entries.each do |entry|
139
+ log_to_stdout_batch(entry)
140
+ end
141
+
142
+ active_model_logs.reload
143
+ end
144
+
145
+ # Block-based logging that collects logs during block execution and saves them when the block exits.
146
+ # This is useful for grouping related operations and ensuring all logs are saved together.
147
+ #
148
+ # @param log_chain [String] Optional log chain to use for all logs in this block
149
+ # @param visible_to [String] Who can see these logs (defaults to configured default)
150
+ # @param log_level [String] The log level for all logs in this block (defaults to configured default)
151
+ # @param metadata [Hash] Base metadata to include with all logs in this block
152
+ # @yield [logger] The block to execute, yielding a logger object
153
+ # @yieldparam logger [ActiveModelLogger::BlockLogger] A logger object that collects log entries
154
+ # @return [Array<ActiveModelLogger::Log>] Array of created log entries
155
+ # @raise [ArgumentError] If no block is provided
156
+ #
157
+ # @example Basic usage
158
+ # user.log_block do |logger|
159
+ # logger.log("Process started")
160
+ # logger.log("Step 1 completed", status: "success")
161
+ # logger.log("Step 2 completed", status: "success")
162
+ # logger.log("Process finished", status: "complete")
163
+ # end
164
+ #
165
+ # @example With custom log chain and metadata
166
+ # user.log_block(log_chain: "order_123", metadata: { category: "order" }) do |logger|
167
+ # logger.log("Order processing started")
168
+ # logger.log("Payment processed", status: "success", amount: 99.99)
169
+ # logger.log("Order completed", status: "complete")
170
+ # end
171
+ #
172
+ # @example With custom visibility and log level
173
+ # user.log_block(visible_to: "admin", log_level: "debug") do |logger|
174
+ # logger.log("Admin action started")
175
+ # logger.log("Sensitive operation performed", type: "admin_action")
176
+ # logger.log("Admin action completed")
177
+ # end
178
+ #
179
+ def log_block(log_chain: nil, visible_to: nil, log_level: nil, metadata: {}, &block)
180
+ raise ArgumentError, "Block required for log_block" unless block_given?
181
+
182
+ # Use configuration defaults if not provided
183
+ visible_to ||= loggable_config[:default_visible_to]
184
+ log_level ||= loggable_config[:default_log_level]
185
+
186
+ # Normalize inputs
187
+ normalized_log_level = log_level.downcase
188
+ normalized_metadata = metadata.transform_keys(&:to_s)
189
+
190
+ # Generate log chain (use provided log_chain or generate one)
191
+ log_chain_value = log_chain || generate_log_chain(normalized_metadata)
192
+
193
+ # Create a block logger that collects entries
194
+ block_logger = BlockLogger.new(
195
+ self,
196
+ log_chain_value,
197
+ visible_to,
198
+ normalized_log_level,
199
+ normalized_metadata
200
+ )
201
+
202
+ # Execute the block with the logger
203
+ block.call(block_logger)
204
+
205
+ # Save all collected logs
206
+ block_logger.save_logs
207
+ end
208
+
209
+ #
210
+ # returns the logs attached to this object or the logs associated
211
+ # with the log_chain passed in
212
+ #
213
+ # @param log_chain [String] Optional log chain to filter by
214
+ # @param limit [Integer] Maximum number of logs to return (default: 10)
215
+ # @return [ActiveRecord::Relation] Collection of log entries
216
+ #
217
+ # @example Get all recent logs
218
+ # user.logs
219
+ #
220
+ # @example Get recent logs with custom limit
221
+ # user.logs(limit: 5)
222
+ #
223
+ # @example Get logs for specific chain
224
+ # user.logs(log_chain: "process_123")
225
+ #
226
+ # @example Get logs for specific chain with limit
227
+ # user.logs(log_chain: "process_123", limit: 3)
228
+ #
229
+ def logs(log_chain: nil, limit: 10)
230
+ if log_chain
231
+ active_model_logs.by_log_chain(log_chain).newest.limit(limit)
232
+ else
233
+ active_model_logs.newest.limit(limit)
234
+ end
235
+ end
236
+
237
+ #
238
+ # The root log on the object will have a log_chain assigned to it. By
239
+ # default this is a uuid, but the value can be overridden.
240
+ #
241
+ # The purpose of a log chain is to connect a series of logs together.
242
+ # By default, it will use the log_chain from the most recent log entry.
243
+ # If this is the first log entry, it will generate a new UUID.
244
+ #
245
+ # Defaults to either the most recent log_chain or a new UUID. This can be
246
+ # set specifically by providing the log_chain with the log.
247
+ #
248
+ # The default behavior will preserve the log_chain between any services
249
+ # that use the same database. This is its primary purpose. For this to
250
+ # work successfully, you will need to set the log_chain value at the
251
+ # start of any chain of events you want to track. If you have asyncronous
252
+ # processes using the same table row, you will need to pass the log_chain
253
+ # value to the other services so they can set the log_chain value and keep
254
+ # the logs grouped together.
255
+ #
256
+ def log_chain
257
+ @log_chain ||= most_recent_log_chain || SecureRandom.uuid
258
+ end
259
+
260
+ # Get the log_chain from the most recent log entry for this object
261
+ def most_recent_log_chain
262
+ return nil unless persisted?
263
+
264
+ recent_log = active_model_logs.newest(1).first
265
+ return nil unless recent_log&.log_chain
266
+
267
+ recent_log.log_chain
268
+ end
269
+
270
+ # Send log entry to stdout
271
+ def log_to_stdout(params)
272
+ return unless loggable_config[:stdout_logging]
273
+
274
+ logger = loggable_config[:stdout_logger] || default_stdout_logger
275
+ formatted_message = format_stdout_message(params)
276
+
277
+ log_to_stdout_with_level(logger, params[:log_level], formatted_message)
278
+ end
279
+
280
+ # Helper method to log with appropriate level
281
+ def log_to_stdout_with_level(logger, log_level, formatted_message)
282
+ case log_level.downcase
283
+ when "debug"
284
+ logger.debug(formatted_message)
285
+ when "warn"
286
+ logger.warn(formatted_message)
287
+ when "error"
288
+ logger.error(formatted_message)
289
+ when "fatal"
290
+ logger.fatal(formatted_message)
291
+ else # "info" and unknown levels default to info
292
+ logger.info(formatted_message)
293
+ end
294
+ end
295
+
296
+ # Send batch log entry to stdout
297
+ def log_to_stdout_batch(entry)
298
+ return unless loggable_config[:stdout_logging]
299
+
300
+ logger = loggable_config[:stdout_logger] || default_stdout_logger
301
+ formatted_message = format_stdout_message_batch(entry)
302
+ log_level = entry[:metadata]["log_level"] || "info"
303
+
304
+ log_to_stdout_with_level(logger, log_level, formatted_message)
305
+ end
306
+
307
+ # Get default stdout logger
308
+ def default_stdout_logger
309
+ @default_stdout_logger ||= begin
310
+ require "logger"
311
+ Logger.new($stdout)
312
+ end
313
+ end
314
+
315
+ # Format message for stdout output
316
+ def format_stdout_message(params)
317
+ log_entry = params[:log_entry]
318
+ message = params[:message]
319
+ log_level = params[:log_level]
320
+ metadata = params[:metadata]
321
+ log_chain_value = params[:log_chain_value]
322
+
323
+ timestamp = log_entry.created_at.strftime("%Y-%m-%d %H:%M:%S")
324
+ model_info = "#{self.class.name}##{id}"
325
+ chain_info = log_chain_value ? " [chain:#{log_chain_value}]" : ""
326
+ metadata_info = format_metadata_for_stdout(metadata)
327
+
328
+ "[#{timestamp}] #{log_level.upcase} #{model_info}#{chain_info} - #{message}#{metadata_info}"
329
+ end
330
+
331
+ # Format batch message for stdout output
332
+ def format_stdout_message_batch(entry)
333
+ timestamp = entry[:created_at].strftime("%Y-%m-%d %H:%M:%S")
334
+ model_info = "#{entry[:loggable_type]}##{entry[:loggable_id]}"
335
+ log_level = entry[:metadata]["log_level"] || "info"
336
+ chain_info = entry[:metadata]["log_chain"] ? " [chain:#{entry[:metadata]['log_chain']}]" : ""
337
+ metadata_info = format_metadata_for_stdout(entry[:metadata])
338
+
339
+ "[#{timestamp}] #{log_level.upcase} #{model_info}#{chain_info} - #{entry[:message]}#{metadata_info}"
340
+ end
341
+
342
+ # Format metadata for stdout output
343
+ def format_metadata_for_stdout(metadata)
344
+ return "" if metadata.nil? || metadata.empty?
345
+
346
+ relevant_metadata = metadata.except("log_level", "visible_to", "log_chain")
347
+ return "" if relevant_metadata.empty?
348
+
349
+ formatted = relevant_metadata.map { |k, v| "#{k}=#{v}" }.join(", ")
350
+ " (#{formatted})"
351
+ end
352
+
353
+ # Configuration methods
354
+ def self.configure_loggable(options = {})
355
+ self.loggable_config = loggable_config.merge(options)
356
+ end
357
+ end
358
+
359
+ class_methods do
360
+ # Class-level configuration
361
+ def configure_loggable(options = {})
362
+ self.loggable_config = loggable_config.merge(options)
363
+ end
364
+
365
+ # Find models with recent activity
366
+ def with_recent_logs(since: 1.hour.ago)
367
+ joins(:active_model_logs)
368
+ .where(active_model_logs: { created_at: since.. })
369
+ .distinct
370
+ end
371
+
372
+ # Find models by log level
373
+ def with_logs_at_level(level)
374
+ joins(:active_model_logs)
375
+ .where(active_model_logs: { log_level: level.downcase })
376
+ .distinct
377
+ end
378
+ end
379
+ end
380
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveModelLogger
4
+ # Helper methods for the Loggable concern
5
+ module LoggableHelpers
6
+ def validate_log_inputs(message, visible_to, log_level, metadata)
7
+ raise ArgumentError, "Message cannot be nil" if message.nil?
8
+ raise ArgumentError, "Visible_to cannot be nil or empty" if visible_to.nil? || visible_to.empty?
9
+ raise ArgumentError, "Log level cannot be nil or empty" if log_level.nil? || log_level.empty?
10
+ raise ArgumentError, "Metadata must be a hash" unless metadata.is_a?(Hash)
11
+
12
+ valid_levels = %w[debug info warn error fatal]
13
+ return if valid_levels.include?(log_level.downcase)
14
+
15
+ raise ArgumentError, "Invalid log level '#{log_level}'. Must be one of: #{valid_levels.join(', ')}"
16
+ end
17
+
18
+ def generate_log_chain(normalized_metadata)
19
+ if loggable_config[:auto_log_chain] && normalized_metadata["log_chain"].nil?
20
+ send(loggable_config[:log_chain_method])
21
+ else
22
+ normalized_metadata["log_chain"] || send(loggable_config[:log_chain_method])
23
+ end
24
+ end
25
+
26
+ def build_log_attributes(message, visible_to, log_level, normalized_metadata, log_chain_value)
27
+ {
28
+ message: message.to_s,
29
+ log_chain: log_chain_value,
30
+ status: normalized_metadata["status"],
31
+ category: normalized_metadata["category"],
32
+ type: normalized_metadata["type"],
33
+ title: normalized_metadata["title"],
34
+ visible_to: visible_to.to_s,
35
+ log_level: log_level,
36
+ data: normalized_metadata["data"],
37
+ }
38
+ end
39
+
40
+ def create_log_entry(log_attributes)
41
+ if respond_to?(:create_log!)
42
+ create_log!(log_attributes)
43
+ else
44
+ active_model_logs.create!(log_attributes)
45
+ end
46
+ end
47
+
48
+ def build_batch_log_entries(entries)
49
+ entries.map do |entry|
50
+ {
51
+ loggable_type: self.class.name,
52
+ loggable_id: id,
53
+ message: entry[:message].to_s,
54
+ metadata: {
55
+ log_chain: entry[:log_chain] || log_chain,
56
+ status: entry[:status],
57
+ category: entry[:category],
58
+ type: entry[:type],
59
+ title: entry[:title],
60
+ visible_to: entry[:visible_to] || loggable_config[:default_visible_to],
61
+ log_level: (entry[:log_level] || loggable_config[:default_log_level]).downcase,
62
+ data: entry[:data],
63
+ },
64
+ created_at: Time.current,
65
+ updated_at: Time.current,
66
+ }
67
+ end
68
+ end
69
+
70
+ def cleanup_logs(older_than: 30.days, keep_recent: 100)
71
+ old_logs = active_model_logs.where("created_at < ?", older_than.ago)
72
+ recent_logs = active_model_logs.newest(keep_recent)
73
+
74
+ logs_to_delete = old_logs.where.not(id: recent_logs.select(:id))
75
+ logs_to_delete.delete_all
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveModelLogger
4
+ if defined?(Rails)
5
+ class Railtie < Rails::Railtie
6
+ initializer "active_model_logger.setup" do
7
+ # Include the Loggable concern in ApplicationRecord by default
8
+ ActiveSupport.on_load(:active_record) do
9
+ ApplicationRecord.include ActiveModelLogger::Loggable if defined?(ApplicationRecord)
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveModelLogger
4
+ VERSION = "0.2.1"
5
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_model_logger/version"
4
+ require "active_model_logger/loggable"
5
+ require "active_model_logger/log"
6
+ require "active_model_logger/block_logger"
7
+ require "active_model_logger/loggable_helpers"
8
+ require "active_model_logger/railtie"
9
+
10
+ # Load generators if Rails is available and fully loaded
11
+ require "active_model_logger/generators/active_model_logger/install_generator" if defined?(Rails::Generators)
12
+
13
+ module ActiveModelLogger
14
+ class Error < StandardError; end
15
+ class LogCreationError < Error; end
16
+ class ConfigurationError < Error; end
17
+ end
@@ -0,0 +1,4 @@
1
+ module ActiveModelLogger
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end