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,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveModelLogger
4
+ # BlockLogger is a helper class used by the log_block method to collect log entries
5
+ # during block execution and save them all at once when the block exits.
6
+ class BlockLogger
7
+ attr_reader :loggable, :log_chain, :visible_to, :log_level, :base_metadata, :log_entries
8
+
9
+ def initialize(loggable, log_chain, visible_to, log_level, base_metadata = {})
10
+ @loggable = loggable
11
+ @log_chain = log_chain
12
+ @visible_to = visible_to
13
+ @log_level = log_level
14
+ @base_metadata = base_metadata
15
+ @log_entries = []
16
+ end
17
+
18
+ # Add a log entry to the collection
19
+ #
20
+ # @param message [String] The log message (required)
21
+ # @param visible_to [String] Override visibility for this specific log (optional)
22
+ # @param log_level [String] Override log level for this specific log (optional)
23
+ # @param metadata [Hash] Additional metadata for this specific log
24
+ # @return [Hash] The log entry hash that was added to the collection
25
+ #
26
+ # @example
27
+ # logger.log("Process started")
28
+ # logger.log("Step completed", status: "success", category: "processing")
29
+ # logger.log("Error occurred", log_level: "error", error_code: "E001")
30
+ #
31
+ def log(message, visible_to: nil, log_level: nil, **metadata)
32
+ # Use block defaults if not provided
33
+ effective_visible_to = visible_to || @visible_to
34
+ effective_log_level = log_level || @log_level
35
+
36
+ # Validate inputs
37
+ validate_log_inputs(message, effective_visible_to, effective_log_level, metadata)
38
+
39
+ # Normalize inputs
40
+ normalized_log_level = effective_log_level.downcase
41
+ normalized_metadata = @base_metadata.merge(metadata.transform_keys(&:to_s))
42
+
43
+ # Create log entry hash
44
+ log_entry = {
45
+ loggable_type: @loggable.class.name,
46
+ loggable_id: @loggable.id,
47
+ message: message.to_s,
48
+ metadata: {
49
+ log_chain: @log_chain,
50
+ status: normalized_metadata["status"],
51
+ category: normalized_metadata["category"],
52
+ type: normalized_metadata["type"],
53
+ title: normalized_metadata["title"],
54
+ visible_to: effective_visible_to.to_s,
55
+ log_level: normalized_log_level,
56
+ data: normalized_metadata["data"],
57
+ },
58
+ created_at: Time.current,
59
+ updated_at: Time.current,
60
+ }
61
+
62
+ # Add to collection
63
+ @log_entries << log_entry
64
+
65
+ # Send to stdout if enabled
66
+ log_to_stdout_block(log_entry, message, effective_visible_to, normalized_log_level, normalized_metadata)
67
+
68
+ log_entry
69
+ end
70
+
71
+ # Save all collected log entries to the database
72
+ #
73
+ # @return [Array<ActiveModelLogger::Log>] Array of created log entries
74
+ def save_logs
75
+ return [] if @log_entries.empty?
76
+
77
+ # Insert all logs at once using insert_all (requires ActiveRecord 6.0+)
78
+ ActiveModelLogger::Log.insert_all(@log_entries)
79
+
80
+ # Reload the association to include new logs
81
+ @loggable.active_model_logs.reload
82
+
83
+ # Return the created log entries - query directly to avoid ordering conflicts
84
+ # Always query by the loggable and order by created_at to ensure chronological order
85
+ # Filter by log_chain in Ruby to avoid database-specific JSON query issues
86
+ all_logs = ActiveModelLogger::Log.where(loggable_type: @loggable.class.name, loggable_id: @loggable.id)
87
+ .order(:created_at)
88
+ all_logs.select { |log| log.metadata["log_chain"] == @log_chain }
89
+ end
90
+
91
+ private
92
+
93
+ def validate_log_inputs(message, visible_to, log_level, metadata)
94
+ raise ArgumentError, "Message cannot be nil" if message.nil?
95
+ raise ArgumentError, "Visible_to cannot be nil or empty" if visible_to.nil? || visible_to.empty?
96
+ raise ArgumentError, "Log level cannot be nil or empty" if log_level.nil? || log_level.empty?
97
+ raise ArgumentError, "Metadata must be a hash" unless metadata.is_a?(Hash)
98
+
99
+ valid_levels = %w[debug info warn error fatal]
100
+ return if valid_levels.include?(log_level.downcase)
101
+
102
+ raise ArgumentError, "Invalid log level '#{log_level}'. Must be one of: #{valid_levels.join(', ')}"
103
+ end
104
+
105
+ def log_to_stdout_block(log_entry, message, visible_to, log_level, metadata)
106
+ return unless @loggable.loggable_config[:stdout_logging]
107
+
108
+ logger = @loggable.loggable_config[:stdout_logger] || @loggable.default_stdout_logger
109
+ formatted_message = format_stdout_message_block(log_entry, message, visible_to, log_level, metadata)
110
+
111
+ @loggable.log_to_stdout_with_level(logger, log_level, formatted_message)
112
+ end
113
+
114
+ def format_stdout_message_block(log_entry, message, _visible_to, log_level, metadata)
115
+ timestamp = log_entry[:created_at].strftime("%Y-%m-%d %H:%M:%S")
116
+ model_info = "#{@loggable.class.name}##{@loggable.id}"
117
+ chain_info = @log_chain ? " [chain:#{@log_chain}]" : ""
118
+ metadata_info = format_metadata_for_stdout_block(metadata)
119
+
120
+ "[#{timestamp}] #{log_level.upcase} #{model_info}#{chain_info} - #{message}#{metadata_info}"
121
+ end
122
+
123
+ def format_metadata_for_stdout_block(metadata)
124
+ return "" if metadata.nil? || metadata.empty?
125
+
126
+ relevant_metadata = metadata.except("log_level", "visible_to", "log_chain")
127
+ return "" if relevant_metadata.empty?
128
+
129
+ formatted = relevant_metadata.map { |k, v| "#{k}=#{v}" }.join(", ")
130
+ " (#{formatted})"
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveModelLogger
4
+ module Generators
5
+ class InstallGenerator < Rails::Generators::Base
6
+ include Rails::Generators::Migration
7
+ source_root File.expand_path("templates", __dir__)
8
+
9
+ def self.next_migration_number(dirname)
10
+ next_migration_number = current_migration_number(dirname) + 1
11
+ ActiveRecord::Migration.next_migration_number(next_migration_number)
12
+ end
13
+
14
+ def create_migration
15
+ migration_file = "db/migrate/#{migration_file_name}.rb"
16
+ template "create_active_model_logs.rb", migration_file
17
+ end
18
+
19
+ private
20
+
21
+ def migration_file_name
22
+ "create_active_model_logs"
23
+ end
24
+
25
+ def show_readme
26
+ readme "README" if behavior == :invoke
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,33 @@
1
+ # ActiveModelLogger Installation
2
+
3
+ This generator has created a migration to add the `active_model_logs` table to your database.
4
+
5
+ ## Next Steps
6
+
7
+ 1. Run the migration:
8
+ ```bash
9
+ rails db:migrate
10
+ ```
11
+
12
+ 2. Include the Loggable concern in your models:
13
+ ```ruby
14
+ class User < ApplicationRecord
15
+ include ActiveModelLogger::Loggable
16
+ end
17
+ ```
18
+
19
+ 3. Start logging:
20
+ ```ruby
21
+ user = User.find(1)
22
+ user.log("User logged in", log_level: "info")
23
+ user.log("Payment processed", log_level: "info", metadata: { status: "success", category: "payment" })
24
+ ```
25
+
26
+ ## Usage
27
+
28
+ The Loggable concern provides several methods:
29
+
30
+ - `log(message, options)` - Create a new log entry
31
+ - `logs(log_chain: nil)` - Get all logs or logs with a specific log_chain
32
+
33
+ For more information, see the gem documentation.
@@ -0,0 +1,34 @@
1
+ class CreateActiveModelLogs < ActiveRecord::Migration[<%= Rails::VERSION::MAJOR %>.<%= Rails::VERSION::MINOR %>]
2
+ def change
3
+ # Detect database type for compatibility
4
+ adapter_name = ActiveRecord::Base.connection.adapter_name.downcase
5
+
6
+ case adapter_name
7
+ when 'postgresql'
8
+ create_table :active_model_logs, id: :uuid do |t|
9
+ t.references :loggable, null: false, polymorphic: true, index: true
10
+ t.text :message, default: ""
11
+ t.jsonb :metadata
12
+ t.timestamps
13
+ end
14
+ when 'mysql2', 'mysql'
15
+ create_table :active_model_logs do |t|
16
+ t.references :loggable, null: false, polymorphic: true, index: true
17
+ t.text :message, default: ""
18
+ t.json :metadata
19
+ t.timestamps
20
+ end
21
+ else
22
+ # SQLite and other databases
23
+ create_table :active_model_logs do |t|
24
+ t.string :loggable_type, null: false
25
+ t.integer :loggable_id, null: false
26
+ t.text :message, default: ""
27
+ t.text :metadata
28
+ t.timestamps
29
+ end
30
+
31
+ add_index :active_model_logs, [:loggable_type, :loggable_id]
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveModelLogger
4
+ # Helper methods for database-agnostic JSON queries
5
+ module JsonQueryHelpers
6
+ extend ActiveSupport::Concern
7
+
8
+ class_methods do
9
+ # Helper method to generate database-agnostic JSON queries
10
+ def json_query_scope(field, value)
11
+ case connection.adapter_name.downcase
12
+ when "postgresql"
13
+ where("metadata->>? = ?", field, value).order(created_at: :desc)
14
+ else
15
+ # MySQL, SQLite and others - use JSON_EXTRACT
16
+ where("JSON_EXTRACT(metadata, ?) = ?", "$.#{field}", value).order(created_at: :desc)
17
+ end
18
+ end
19
+
20
+ # Helper method for data field queries (checks for non-null and non-empty)
21
+ def json_data_scope
22
+ case connection.adapter_name.downcase
23
+ when "postgresql"
24
+ where("metadata->>'data' IS NOT NULL AND metadata->>'data' != '{}'")
25
+ .order(created_at: :desc)
26
+ else
27
+ # MySQL, SQLite and others - use JSON_EXTRACT
28
+ where("JSON_EXTRACT(metadata, '$.data') IS NOT NULL AND JSON_EXTRACT(metadata, '$.data') != '{}'")
29
+ .order(created_at: :desc)
30
+ end
31
+ end
32
+
33
+ # Helper method for querying specific data keys and values
34
+ def json_data_query_scope(data_hash)
35
+ return none if data_hash.nil? || data_hash.empty?
36
+
37
+ conditions = []
38
+ values = []
39
+
40
+ data_hash.each do |key, value|
41
+ case connection.adapter_name.downcase
42
+ when "postgresql"
43
+ conditions << "metadata->'data'->>? = ?"
44
+ values << key.to_s
45
+ values << value.to_s
46
+ else
47
+ # MySQL, SQLite and others - use JSON_EXTRACT
48
+ # Handle both string and numeric comparisons
49
+ conditions << "JSON_EXTRACT(metadata, '$.data.#{key}') = ?"
50
+ values << if value.is_a?(Numeric)
51
+ value
52
+ else
53
+ value.to_s
54
+ end
55
+ end
56
+ end
57
+
58
+ # Add condition to ensure data field exists and is not empty
59
+ conditions << case connection.adapter_name.downcase
60
+ when "postgresql"
61
+ "metadata->>'data' IS NOT NULL AND metadata->>'data' != '{}'"
62
+ else
63
+ "JSON_EXTRACT(metadata, '$.data') IS NOT NULL AND JSON_EXTRACT(metadata, '$.data') != '{}'"
64
+ end
65
+
66
+ where(conditions.join(" AND "), *values).order(created_at: :desc)
67
+ end
68
+
69
+ # Helper method for querying logs that contain specific keys anywhere in metadata
70
+ def json_keys_scope(keys)
71
+ return none if keys.empty?
72
+
73
+ conditions = keys.map do |_key|
74
+ case connection.adapter_name.downcase
75
+ when "postgresql"
76
+ # Check if key exists anywhere in the JSON structure using LIKE
77
+ "metadata::text LIKE ?"
78
+ when "mysql"
79
+ # MySQL has JSON_SEARCH function
80
+ "JSON_SEARCH(metadata, 'one', ?) IS NOT NULL"
81
+ else
82
+ # SQLite and others - use LIKE with JSON text
83
+ "metadata LIKE ?"
84
+ end
85
+ end
86
+
87
+ # Prepare values for the query
88
+ values = keys.map do |key|
89
+ case connection.adapter_name.downcase
90
+ when "mysql"
91
+ # JSON_SEARCH looks for the key value anywhere in the JSON
92
+ key.to_s
93
+ else
94
+ # PostgreSQL, SQLite and others - use LIKE with JSON key pattern
95
+ "%\"#{key}\":%"
96
+ end
97
+ end
98
+
99
+ where(conditions.join(" AND "), *values).order(created_at: :desc)
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require_relative "json_query_helpers"
5
+
6
+ module ActiveModelLogger
7
+ # == Schema Information
8
+ #
9
+ # Table name: active_model_logs
10
+ #
11
+ # id :uuid not null, primary key
12
+ # loggable_type :string not null
13
+ # message :text default("")
14
+ # metadata :jsonb
15
+ # created_at :datetime not null
16
+ # updated_at :datetime not null
17
+ # loggable_id :uuid not null
18
+ #
19
+ # Indexes
20
+ #
21
+ # index_active_model_logs_on_loggable (loggable_type,loggable_id)
22
+ #
23
+ class Log < ActiveRecord::Base
24
+ include JsonQueryHelpers
25
+
26
+ self.table_name = "active_model_logs"
27
+
28
+ belongs_to :loggable, polymorphic: true
29
+
30
+ # Configure metadata as a store for newer ActiveRecord versions
31
+ if ActiveRecord::VERSION::MAJOR >= 7
32
+ store :metadata, accessors: %i[status type category log_chain title visible_to log_level data], coder: JSON
33
+ else
34
+ store_accessor :metadata, %i[status type category log_chain title visible_to log_level data]
35
+ end
36
+
37
+ # Scopes for better query performance
38
+ # Note: These scopes query the metadata JSON field since log_level, visible_to, etc. are stored there
39
+ scope :by_level, lambda { |level|
40
+ json_query_scope("log_level", level.to_s.downcase)
41
+ }
42
+
43
+ scope :by_visibility, lambda { |visibility|
44
+ json_query_scope("visible_to", visibility.to_s)
45
+ }
46
+
47
+ scope :in_range, ->(start_time, end_time) { where(created_at: start_time..end_time).order(created_at: :desc) }
48
+ scope :oldest, ->(limit = 1) { order(created_at: :asc).limit(limit) }
49
+ scope :newest, ->(limit = 1) { order(created_at: :desc).limit(limit) }
50
+
51
+ scope :by_status, lambda { |status|
52
+ json_query_scope("status", status.to_s)
53
+ }
54
+
55
+ scope :by_category, lambda { |category|
56
+ json_query_scope("category", category.to_s)
57
+ }
58
+
59
+ scope :by_type, lambda { |type|
60
+ json_query_scope("type", type.to_s)
61
+ }
62
+
63
+ scope :with_data, lambda { |data_hash = nil|
64
+ if data_hash.nil?
65
+ json_data_scope
66
+ else
67
+ json_data_query_scope(data_hash)
68
+ end
69
+ }
70
+
71
+ scope :with_keys, lambda { |*keys|
72
+ return none if keys.empty?
73
+
74
+ json_keys_scope(keys.flatten)
75
+ }
76
+
77
+ scope :by_log_chain, lambda { |log_chain|
78
+ json_query_scope("log_chain", log_chain.to_s)
79
+ }
80
+
81
+ scope :error_logs, -> { by_level("error") }
82
+ scope :info_logs, -> { by_level("info") }
83
+ scope :warning_logs, -> { by_level("warn") }
84
+ scope :debug_logs, -> { by_level("debug") }
85
+ end
86
+ end