pigeon-rb 0.1.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.
- checksums.yaml +7 -0
- data/README.md +343 -0
- data/lib/pigeon/active_job_integration.rb +32 -0
- data/lib/pigeon/api.rb +200 -0
- data/lib/pigeon/configuration.rb +161 -0
- data/lib/pigeon/core.rb +104 -0
- data/lib/pigeon/encryption.rb +213 -0
- data/lib/pigeon/generators/hanami/migration_generator.rb +89 -0
- data/lib/pigeon/generators/rails/install_generator.rb +32 -0
- data/lib/pigeon/generators/rails/migration_generator.rb +20 -0
- data/lib/pigeon/generators/rails/templates/create_outbox_messages.rb.erb +34 -0
- data/lib/pigeon/generators/rails/templates/initializer.rb.erb +88 -0
- data/lib/pigeon/hanami_integration.rb +78 -0
- data/lib/pigeon/health_check/kafka.rb +37 -0
- data/lib/pigeon/health_check/processor.rb +70 -0
- data/lib/pigeon/health_check/queue.rb +69 -0
- data/lib/pigeon/health_check.rb +63 -0
- data/lib/pigeon/logging/structured_logger.rb +181 -0
- data/lib/pigeon/metrics/collector.rb +200 -0
- data/lib/pigeon/mock_producer.rb +18 -0
- data/lib/pigeon/models/adapters/active_record_adapter.rb +133 -0
- data/lib/pigeon/models/adapters/rom_adapter.rb +150 -0
- data/lib/pigeon/models/outbox_message.rb +182 -0
- data/lib/pigeon/monitoring.rb +113 -0
- data/lib/pigeon/outbox.rb +61 -0
- data/lib/pigeon/processor/background_processor.rb +109 -0
- data/lib/pigeon/processor.rb +798 -0
- data/lib/pigeon/publisher.rb +524 -0
- data/lib/pigeon/railtie.rb +29 -0
- data/lib/pigeon/schema.rb +35 -0
- data/lib/pigeon/security.rb +30 -0
- data/lib/pigeon/serializer.rb +77 -0
- data/lib/pigeon/tasks/pigeon.rake +64 -0
- data/lib/pigeon/trace_api.rb +37 -0
- data/lib/pigeon/tracing/core.rb +119 -0
- data/lib/pigeon/tracing/messaging.rb +144 -0
- data/lib/pigeon/tracing.rb +107 -0
- data/lib/pigeon/version.rb +5 -0
- data/lib/pigeon.rb +52 -0
- metadata +127 -0
@@ -0,0 +1,88 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Pigeon configuration
|
4
|
+
Pigeon.configure do |config|
|
5
|
+
# Kafka client ID (defaults to application name)
|
6
|
+
config.client_id = Rails.application.class.module_parent_name.underscore
|
7
|
+
|
8
|
+
# Kafka broker configuration
|
9
|
+
config.kafka_brokers = ENV.fetch("KAFKA_BROKERS", "localhost:9092").split(",")
|
10
|
+
|
11
|
+
# Maximum number of retries for failed messages
|
12
|
+
config.max_retries = ENV.fetch("PIGEON_MAX_RETRIES", 10).to_i
|
13
|
+
|
14
|
+
# Base retry delay (will be used with exponential backoff)
|
15
|
+
config.retry_delay = ENV.fetch("PIGEON_RETRY_DELAY", 30).to_i # seconds
|
16
|
+
|
17
|
+
# Maximum retry delay
|
18
|
+
config.max_retry_delay = ENV.fetch("PIGEON_MAX_RETRY_DELAY", 86_400).to_i # 24 hours
|
19
|
+
|
20
|
+
# Whether to encrypt message payloads
|
21
|
+
config.encrypt_payload = ActiveModel::Type::Boolean.new.cast(ENV.fetch("PIGEON_ENCRYPT_PAYLOAD", false))
|
22
|
+
|
23
|
+
# Encryption key for payload encryption (if enabled)
|
24
|
+
config.encryption_key = ENV["PIGEON_ENCRYPTION_KEY"]
|
25
|
+
|
26
|
+
# Retention period for processed messages
|
27
|
+
config.retention_period = ENV.fetch("PIGEON_RETENTION_PERIOD", 7).to_i # days
|
28
|
+
|
29
|
+
# Use Rails logger
|
30
|
+
config.logger = Rails.logger
|
31
|
+
|
32
|
+
# Register sensitive fields for masking in logs
|
33
|
+
# config.register_sensitive_fields(%i[password email credit_card])
|
34
|
+
|
35
|
+
# Dead letter queue configuration
|
36
|
+
config.dead_letter_queue_enabled = ActiveModel::Type::Boolean.new.cast(ENV.fetch("PIGEON_DLQ_ENABLED", true))
|
37
|
+
config.dead_letter_queue_suffix = ENV.fetch("PIGEON_DLQ_SUFFIX", ".dlq")
|
38
|
+
|
39
|
+
# Schema validation configuration
|
40
|
+
# config.schema_validation_enabled = true
|
41
|
+
# config.register_schema(:user, Rails.root.join("app/schemas/user.json").read)
|
42
|
+
end
|
43
|
+
|
44
|
+
# Start background processing in production environments
|
45
|
+
if Rails.env.production? && ActiveModel::Type::Boolean.new.cast(ENV.fetch("PIGEON_AUTO_START", false))
|
46
|
+
Rails.application.config.after_initialize do
|
47
|
+
# Don't start in Rails console or rake tasks
|
48
|
+
unless defined?(Rails::Console) || File.basename($PROGRAM_NAME) == "rake"
|
49
|
+
batch_size = ENV.fetch("PIGEON_BATCH_SIZE", 100).to_i
|
50
|
+
interval = ENV.fetch("PIGEON_INTERVAL", 5).to_i
|
51
|
+
thread_count = ENV.fetch("PIGEON_THREAD_COUNT", 2).to_i
|
52
|
+
|
53
|
+
Rails.logger.info("Starting Pigeon background processing")
|
54
|
+
Pigeon.start_processing(
|
55
|
+
batch_size: batch_size,
|
56
|
+
interval: interval,
|
57
|
+
thread_count: thread_count
|
58
|
+
)
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
# Schedule periodic jobs if ActiveJob is available and enabled
|
64
|
+
if defined?(ActiveJob) && ActiveModel::Type::Boolean.new.cast(ENV.fetch("PIGEON_SCHEDULE_JOBS", false))
|
65
|
+
Rails.application.config.after_initialize do
|
66
|
+
# Don't schedule in Rails console or rake tasks
|
67
|
+
unless defined?(Rails::Console) || File.basename($PROGRAM_NAME) == "rake"
|
68
|
+
# Use a job scheduler like Sidekiq-Scheduler, Whenever, or Rails' own scheduler
|
69
|
+
# This is just a placeholder - implement according to your job scheduling system
|
70
|
+
Rails.logger.info("Scheduling Pigeon periodic jobs")
|
71
|
+
|
72
|
+
# Example for Sidekiq-Scheduler (if available)
|
73
|
+
if defined?(Sidekiq) && defined?(Sidekiq::Scheduler)
|
74
|
+
Sidekiq.set_schedule("pigeon_processor", {
|
75
|
+
"class" => "Pigeon::ActiveJobIntegration::ProcessorJob",
|
76
|
+
"every" => ENV.fetch("PIGEON_PROCESS_INTERVAL", "1m"),
|
77
|
+
"args" => [ENV.fetch("PIGEON_BATCH_SIZE", 100).to_i]
|
78
|
+
})
|
79
|
+
|
80
|
+
Sidekiq.set_schedule("pigeon_cleanup", {
|
81
|
+
"class" => "Pigeon::ActiveJobIntegration::CleanupJob",
|
82
|
+
"every" => ENV.fetch("PIGEON_CLEANUP_INTERVAL", "1h"),
|
83
|
+
"args" => [ENV.fetch("PIGEON_RETENTION_PERIOD", 7).to_i]
|
84
|
+
})
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
@@ -0,0 +1,78 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "pigeon"
|
4
|
+
|
5
|
+
module Pigeon
|
6
|
+
# Hanami integration for Pigeon
|
7
|
+
module HanamiIntegration
|
8
|
+
# Register the Pigeon plugin with Hanami
|
9
|
+
class Plugin
|
10
|
+
def self.install(app, **_options)
|
11
|
+
# Configure Pigeon with Hanami logger
|
12
|
+
Pigeon.configure do |config|
|
13
|
+
config.logger = app["logger"]
|
14
|
+
config.client_id = app.name.to_s.underscore
|
15
|
+
end
|
16
|
+
|
17
|
+
# Register repositories and relations
|
18
|
+
register_repositories(app)
|
19
|
+
end
|
20
|
+
|
21
|
+
# Register repositories with Hanami container
|
22
|
+
def self.register_repositories(app)
|
23
|
+
# Register the outbox messages repository
|
24
|
+
app.register_provider :repositories, namespace: true do
|
25
|
+
prepare do
|
26
|
+
require "pigeon/models/adapters/rom_adapter"
|
27
|
+
end
|
28
|
+
|
29
|
+
start do
|
30
|
+
# Define the repository class
|
31
|
+
repository = Class.new(ROM::Repository[:outbox_messages]) do
|
32
|
+
commands :create, update: :by_pk, delete: :by_pk
|
33
|
+
|
34
|
+
def find(id)
|
35
|
+
outbox_messages.by_pk(id).one!
|
36
|
+
end
|
37
|
+
|
38
|
+
def find_by_status(status, limit = 100)
|
39
|
+
outbox_messages.by_status(status).limit(limit).to_a
|
40
|
+
end
|
41
|
+
|
42
|
+
def find_ready_for_retry(limit = 100)
|
43
|
+
outbox_messages.ready_for_retry.limit(limit).to_a
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
# Register the repository
|
48
|
+
register "outbox_messages", repository.new(target["persistence.rom"])
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
# Background job for processing outbox messages
|
55
|
+
class ProcessorJob
|
56
|
+
include Dry::Monads[:result]
|
57
|
+
|
58
|
+
# Process pending outbox messages
|
59
|
+
# @param batch_size [Integer] Number of messages to process in one batch
|
60
|
+
# @return [Hash] Processing statistics
|
61
|
+
def self.perform(batch_size = 100)
|
62
|
+
Pigeon.processor.process_pending(batch_size: batch_size)
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
# Background job for cleaning up processed outbox messages
|
67
|
+
class CleanupJob
|
68
|
+
include Dry::Monads[:result]
|
69
|
+
|
70
|
+
# Clean up processed outbox messages
|
71
|
+
# @param older_than [Integer] Age threshold for cleanup in days
|
72
|
+
# @return [Integer] Number of records cleaned up
|
73
|
+
def self.perform(older_than = 7)
|
74
|
+
Pigeon.processor.cleanup_processed(older_than: older_than)
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Pigeon
|
4
|
+
module HealthCheck
|
5
|
+
# Kafka health check functionality
|
6
|
+
module Kafka
|
7
|
+
# Check the health of Kafka connectivity
|
8
|
+
# @return [Hash] Health check result
|
9
|
+
def self.health
|
10
|
+
# Try to connect to Kafka
|
11
|
+
begin
|
12
|
+
# Use Karafka producer to check connectivity
|
13
|
+
Pigeon.karafka_producer.produce_sync("health_check", topic: "health_check", key: "health_check")
|
14
|
+
|
15
|
+
status = "healthy"
|
16
|
+
message = "Kafka connection is healthy"
|
17
|
+
details = { connected: true }
|
18
|
+
rescue StandardError => e
|
19
|
+
status = "critical"
|
20
|
+
message = "Failed to connect to Kafka: #{e.message}"
|
21
|
+
details = {
|
22
|
+
connected: false,
|
23
|
+
error_class: e.class.name,
|
24
|
+
error_message: e.message
|
25
|
+
}
|
26
|
+
end
|
27
|
+
|
28
|
+
{
|
29
|
+
component: "kafka",
|
30
|
+
status: status,
|
31
|
+
message: message,
|
32
|
+
details: details
|
33
|
+
}
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,70 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Pigeon
|
4
|
+
module HealthCheck
|
5
|
+
# Processor health check functionality
|
6
|
+
module Processor
|
7
|
+
# Check the health of the processor
|
8
|
+
# @return [Hash] Health check result
|
9
|
+
def self.health
|
10
|
+
processor_running = Pigeon.processing?
|
11
|
+
|
12
|
+
# Get processor statistics
|
13
|
+
stats = statistics(processor_running)
|
14
|
+
|
15
|
+
# Determine overall status
|
16
|
+
status, message = status(processor_running, stats)
|
17
|
+
|
18
|
+
{
|
19
|
+
component: "processor",
|
20
|
+
status: status,
|
21
|
+
message: message,
|
22
|
+
details: stats
|
23
|
+
}
|
24
|
+
end
|
25
|
+
|
26
|
+
# Get processor statistics
|
27
|
+
# @param processor_running [Boolean] Whether the processor is running
|
28
|
+
# @return [Hash] Processor statistics
|
29
|
+
def self.statistics(processor_running)
|
30
|
+
stats = {
|
31
|
+
running: processor_running,
|
32
|
+
uptime_seconds: processor_running ? (Time.now - Pigeon.processor_start_time).to_i : 0,
|
33
|
+
last_run_at: Pigeon.last_processing_run,
|
34
|
+
last_successful_run_at: Pigeon.last_successful_processing_run
|
35
|
+
}
|
36
|
+
|
37
|
+
# Add error rate if metrics are available
|
38
|
+
add_error_rate_to_stats(stats)
|
39
|
+
|
40
|
+
stats
|
41
|
+
end
|
42
|
+
|
43
|
+
# Add error rate to statistics if metrics are available
|
44
|
+
# @param stats [Hash] Statistics hash to update
|
45
|
+
# @return [void]
|
46
|
+
def self.add_error_rate_to_stats(stats)
|
47
|
+
return unless Pigeon.config.metrics_collector
|
48
|
+
|
49
|
+
total_processed = Pigeon.metrics_collector.get_counter(:messages_processed_total) || 0
|
50
|
+
total_failed = Pigeon.metrics_collector.get_counter(:messages_failed_total) || 0
|
51
|
+
|
52
|
+
stats[:error_rate] = total_processed.positive? ? (total_failed.to_f / total_processed) : 0
|
53
|
+
end
|
54
|
+
|
55
|
+
# Determine processor status
|
56
|
+
# @param processor_running [Boolean] Whether the processor is running
|
57
|
+
# @param stats [Hash] Processor statistics
|
58
|
+
# @return [Array<String, String>] Status and message
|
59
|
+
def self.status(processor_running, stats)
|
60
|
+
if !processor_running
|
61
|
+
["critical", "Processor is not running"]
|
62
|
+
elsif stats[:last_run_at].nil? || (Time.now - stats[:last_run_at]) > 300 # 5 minutes
|
63
|
+
["warning", "Processor has not run in the last 5 minutes"]
|
64
|
+
else
|
65
|
+
["healthy", "Processor is running normally"]
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
@@ -0,0 +1,69 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Pigeon
|
4
|
+
module HealthCheck
|
5
|
+
# Queue health check functionality
|
6
|
+
module Queue
|
7
|
+
# Check the health of the queue
|
8
|
+
# @return [Hash] Health check result
|
9
|
+
def self.health
|
10
|
+
# Get queue statistics
|
11
|
+
stats = statistics
|
12
|
+
|
13
|
+
# Determine overall status based on queue depth and age
|
14
|
+
status, message = status(stats)
|
15
|
+
|
16
|
+
{
|
17
|
+
component: "queue",
|
18
|
+
status: status,
|
19
|
+
message: message,
|
20
|
+
details: stats
|
21
|
+
}
|
22
|
+
end
|
23
|
+
|
24
|
+
# Get queue statistics
|
25
|
+
# @return [Hash] Queue statistics
|
26
|
+
def self.statistics
|
27
|
+
pending_count = Pigeon.count_outbox_messages_by_status("pending")
|
28
|
+
failed_count = Pigeon.count_outbox_messages_by_status("failed")
|
29
|
+
|
30
|
+
# Get the oldest pending message
|
31
|
+
oldest_pending = Pigeon.find_oldest_outbox_message_by_status("pending")
|
32
|
+
oldest_pending_age = oldest_pending ? (Time.now - oldest_pending.created_at).to_i : 0
|
33
|
+
|
34
|
+
{
|
35
|
+
pending_count: pending_count,
|
36
|
+
failed_count: failed_count,
|
37
|
+
oldest_pending_age_seconds: oldest_pending_age,
|
38
|
+
oldest_pending_id: oldest_pending&.id
|
39
|
+
}
|
40
|
+
end
|
41
|
+
|
42
|
+
# Determine queue status
|
43
|
+
# @param stats [Hash] Queue statistics
|
44
|
+
# @return [Array<String, String>] Status and message
|
45
|
+
def self.status(stats)
|
46
|
+
pending_count = stats[:pending_count]
|
47
|
+
age_seconds = stats[:oldest_pending_age_seconds]
|
48
|
+
|
49
|
+
message = queue_status_message(pending_count, age_seconds)
|
50
|
+
|
51
|
+
if pending_count > 1000 || age_seconds > 3600 # 1 hour
|
52
|
+
["critical", message]
|
53
|
+
elsif pending_count > 100 || age_seconds > 300 # 5 minutes
|
54
|
+
["warning", message]
|
55
|
+
else
|
56
|
+
["healthy", "Queue is processing normally"]
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
# Generate queue status message
|
61
|
+
# @param pending_count [Integer] Number of pending messages
|
62
|
+
# @param age_seconds [Integer] Age of oldest message in seconds
|
63
|
+
# @return [String] Status message
|
64
|
+
def self.queue_status_message(pending_count, age_seconds)
|
65
|
+
"Queue has #{pending_count} pending messages, oldest is #{age_seconds} seconds old"
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "health_check/processor"
|
4
|
+
require_relative "health_check/queue"
|
5
|
+
require_relative "health_check/kafka"
|
6
|
+
|
7
|
+
module Pigeon
|
8
|
+
# Health check module for Pigeon
|
9
|
+
module HealthCheck
|
10
|
+
# Check the health of the processor
|
11
|
+
# @return [Hash] Health check result
|
12
|
+
def self.processor_health
|
13
|
+
Processor.health
|
14
|
+
end
|
15
|
+
|
16
|
+
# Check the health of the queue
|
17
|
+
# @return [Hash] Health check result
|
18
|
+
def self.queue_health
|
19
|
+
Queue.health
|
20
|
+
end
|
21
|
+
|
22
|
+
# Check the health of Kafka connectivity
|
23
|
+
# @return [Hash] Health check result
|
24
|
+
def self.kafka_health
|
25
|
+
Kafka.health
|
26
|
+
end
|
27
|
+
|
28
|
+
# Get overall health status
|
29
|
+
# @return [Hash] Overall health status
|
30
|
+
def self.status
|
31
|
+
processor = processor_health
|
32
|
+
queue = queue_health
|
33
|
+
kafka = kafka_health
|
34
|
+
|
35
|
+
# Determine overall status (worst of all components)
|
36
|
+
components = [processor, queue, kafka]
|
37
|
+
status = determine_overall_status(components)
|
38
|
+
|
39
|
+
{
|
40
|
+
status: status,
|
41
|
+
components: {
|
42
|
+
processor: processor,
|
43
|
+
queue: queue,
|
44
|
+
kafka: kafka
|
45
|
+
},
|
46
|
+
timestamp: Time.now.iso8601
|
47
|
+
}
|
48
|
+
end
|
49
|
+
|
50
|
+
# Determine the overall status based on component statuses
|
51
|
+
# @param components [Array<Hash>] Component health check results
|
52
|
+
# @return [String] Overall status
|
53
|
+
def self.determine_overall_status(components)
|
54
|
+
if components.any? { |c| c[:status] == "critical" }
|
55
|
+
"critical"
|
56
|
+
elsif components.any? { |c| c[:status] == "warning" }
|
57
|
+
"warning"
|
58
|
+
else
|
59
|
+
"healthy"
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,181 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "logger"
|
4
|
+
require "json"
|
5
|
+
|
6
|
+
module Pigeon
|
7
|
+
module Logging
|
8
|
+
# Structured logger for Pigeon
|
9
|
+
class StructuredLogger
|
10
|
+
# Log levels
|
11
|
+
LEVELS = {
|
12
|
+
debug: Logger::DEBUG,
|
13
|
+
info: Logger::INFO,
|
14
|
+
warn: Logger::WARN,
|
15
|
+
error: Logger::ERROR,
|
16
|
+
fatal: Logger::FATAL
|
17
|
+
}.freeze
|
18
|
+
|
19
|
+
# Initialize a new structured logger
|
20
|
+
# @param logger [Logger] Base logger to use
|
21
|
+
# @param default_context [Hash] Default context to include in all log entries
|
22
|
+
def initialize(logger = nil, default_context = {})
|
23
|
+
@logger = logger || Logger.new($stdout)
|
24
|
+
@default_context = default_context || {}
|
25
|
+
end
|
26
|
+
|
27
|
+
# Set the log level
|
28
|
+
# @param level [Symbol, String, Integer] Log level
|
29
|
+
# @return [Integer] New log level
|
30
|
+
def level=(level)
|
31
|
+
level_int = level_to_int(level)
|
32
|
+
@logger.level = level_int
|
33
|
+
end
|
34
|
+
|
35
|
+
# Get the current log level
|
36
|
+
# @return [Integer] Current log level
|
37
|
+
def level
|
38
|
+
@logger.level
|
39
|
+
end
|
40
|
+
|
41
|
+
# Check if a log level is enabled
|
42
|
+
# @param level [Symbol, String, Integer] Log level
|
43
|
+
# @return [Boolean] Whether the log level is enabled
|
44
|
+
def level_enabled?(level)
|
45
|
+
@logger.level <= level_to_int(level)
|
46
|
+
end
|
47
|
+
|
48
|
+
# Log a debug message
|
49
|
+
# @param message [String] Log message
|
50
|
+
# @param context [Hash] Additional context for the log entry
|
51
|
+
# @return [void]
|
52
|
+
def debug(message, context = {})
|
53
|
+
log(:debug, message, context)
|
54
|
+
end
|
55
|
+
|
56
|
+
# Log an info message
|
57
|
+
# @param message [String] Log message
|
58
|
+
# @param context [Hash] Additional context for the log entry
|
59
|
+
# @return [void]
|
60
|
+
def info(message, context = {})
|
61
|
+
log(:info, message, context)
|
62
|
+
end
|
63
|
+
|
64
|
+
# Log a warning message
|
65
|
+
# @param message [String] Log message
|
66
|
+
# @param context [Hash] Additional context for the log entry
|
67
|
+
# @return [void]
|
68
|
+
def warn(message, context = {})
|
69
|
+
log(:warn, message, context)
|
70
|
+
end
|
71
|
+
|
72
|
+
# Log an error message
|
73
|
+
# @param message [String] Log message
|
74
|
+
# @param context [Hash] Additional context for the log entry
|
75
|
+
# @param error [Exception, nil] Optional error to include in the log entry
|
76
|
+
# @return [void]
|
77
|
+
def error(message, context = {}, error = nil)
|
78
|
+
context = context.dup
|
79
|
+
if error
|
80
|
+
context[:error] = {
|
81
|
+
class: error.class.name,
|
82
|
+
message: error.message,
|
83
|
+
backtrace: error.backtrace&.take(10)
|
84
|
+
}
|
85
|
+
end
|
86
|
+
log(:error, message, context)
|
87
|
+
end
|
88
|
+
|
89
|
+
# Log a fatal message
|
90
|
+
# @param message [String] Log message
|
91
|
+
# @param context [Hash] Additional context for the log entry
|
92
|
+
# @param error [Exception, nil] Optional error to include in the log entry
|
93
|
+
# @return [void]
|
94
|
+
def fatal(message, context = {}, error = nil)
|
95
|
+
context = context.dup
|
96
|
+
if error
|
97
|
+
context[:error] = {
|
98
|
+
class: error.class.name,
|
99
|
+
message: error.message,
|
100
|
+
backtrace: error.backtrace
|
101
|
+
}
|
102
|
+
end
|
103
|
+
log(:fatal, message, context)
|
104
|
+
end
|
105
|
+
|
106
|
+
# Check if debug level is enabled
|
107
|
+
# @return [Boolean] Whether debug level is enabled
|
108
|
+
def debug?
|
109
|
+
level_enabled?(:debug)
|
110
|
+
end
|
111
|
+
|
112
|
+
# Check if info level is enabled
|
113
|
+
# @return [Boolean] Whether info level is enabled
|
114
|
+
def info?
|
115
|
+
level_enabled?(:info)
|
116
|
+
end
|
117
|
+
|
118
|
+
# Check if warn level is enabled
|
119
|
+
# @return [Boolean] Whether warn level is enabled
|
120
|
+
def warn?
|
121
|
+
level_enabled?(:warn)
|
122
|
+
end
|
123
|
+
|
124
|
+
# Check if error level is enabled
|
125
|
+
# @return [Boolean] Whether error level is enabled
|
126
|
+
def error?
|
127
|
+
level_enabled?(:error)
|
128
|
+
end
|
129
|
+
|
130
|
+
# Check if fatal level is enabled
|
131
|
+
# @return [Boolean] Whether fatal level is enabled
|
132
|
+
def fatal?
|
133
|
+
level_enabled?(:fatal)
|
134
|
+
end
|
135
|
+
|
136
|
+
# Create a new logger with additional default context
|
137
|
+
# @param additional_context [Hash] Additional context to include in all log entries
|
138
|
+
# @return [StructuredLogger] New logger instance
|
139
|
+
def with_context(additional_context)
|
140
|
+
self.class.new(@logger, @default_context.merge(additional_context))
|
141
|
+
end
|
142
|
+
|
143
|
+
private
|
144
|
+
|
145
|
+
# Log a message at the specified level
|
146
|
+
# @param level [Symbol] Log level
|
147
|
+
# @param message [String] Log message
|
148
|
+
# @param context [Hash] Additional context for the log entry
|
149
|
+
# @return [void]
|
150
|
+
def log(level, message, context = {})
|
151
|
+
return unless level_enabled?(level)
|
152
|
+
|
153
|
+
# Combine default context and provided context
|
154
|
+
combined_context = @default_context.merge(context)
|
155
|
+
|
156
|
+
# Create the log entry
|
157
|
+
log_entry = {
|
158
|
+
timestamp: Time.now.iso8601(3),
|
159
|
+
level: level.to_s.upcase,
|
160
|
+
message: message
|
161
|
+
}
|
162
|
+
|
163
|
+
# Add context if present
|
164
|
+
log_entry[:context] = combined_context unless combined_context.empty?
|
165
|
+
|
166
|
+
# Log as JSON
|
167
|
+
@logger.send(level, log_entry.to_json)
|
168
|
+
end
|
169
|
+
|
170
|
+
# Convert a log level to its integer value
|
171
|
+
# @param level [Symbol, String, Integer] Log level
|
172
|
+
# @return [Integer] Integer log level
|
173
|
+
def level_to_int(level)
|
174
|
+
return level if level.is_a?(Integer)
|
175
|
+
return LEVELS[level.to_sym] if level.respond_to?(:to_sym) && LEVELS.key?(level.to_sym)
|
176
|
+
|
177
|
+
Logger::INFO
|
178
|
+
end
|
179
|
+
end
|
180
|
+
end
|
181
|
+
end
|