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.
- checksums.yaml +7 -0
- data/.rubocop.yml +44 -0
- data/CHANGELOG.md +7 -0
- data/CODE_OF_CONDUCT.md +55 -0
- data/LICENSE.txt +21 -0
- data/README.md +1215 -0
- data/Rakefile +12 -0
- data/examples/basic_usage.rb +142 -0
- data/examples/log_block_demo.rb +487 -0
- data/examples/stdout_logging_demo.rb +35 -0
- data/identifier.sqlite +0 -0
- data/lib/active_model_logger/block_logger.rb +133 -0
- data/lib/active_model_logger/generators/active_model_logger/install_generator.rb +30 -0
- data/lib/active_model_logger/generators/active_model_logger/templates/README +33 -0
- data/lib/active_model_logger/generators/active_model_logger/templates/create_active_model_logs.rb +34 -0
- data/lib/active_model_logger/json_query_helpers.rb +103 -0
- data/lib/active_model_logger/log.rb +86 -0
- data/lib/active_model_logger/loggable.rb +380 -0
- data/lib/active_model_logger/loggable_helpers.rb +78 -0
- data/lib/active_model_logger/railtie.rb +14 -0
- data/lib/active_model_logger/version.rb +5 -0
- data/lib/active_model_logger.rb +17 -0
- data/sig/ActiveModelLogger.rbs +4 -0
- metadata +211 -0
@@ -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.
|
data/lib/active_model_logger/generators/active_model_logger/templates/create_active_model_logs.rb
ADDED
@@ -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
|