logstruct 0.0.1 → 0.0.2.pre.rc1

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.
Files changed (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -2
  3. data/LICENSE +21 -0
  4. data/README.md +67 -0
  5. data/lib/log_struct/concerns/configuration.rb +93 -0
  6. data/lib/log_struct/concerns/error_handling.rb +94 -0
  7. data/lib/log_struct/concerns/logging.rb +45 -0
  8. data/lib/log_struct/config_struct/error_handling_modes.rb +25 -0
  9. data/lib/log_struct/config_struct/filters.rb +80 -0
  10. data/lib/log_struct/config_struct/integrations.rb +89 -0
  11. data/lib/log_struct/configuration.rb +59 -0
  12. data/lib/log_struct/enums/error_handling_mode.rb +22 -0
  13. data/lib/log_struct/enums/error_reporter.rb +14 -0
  14. data/lib/log_struct/enums/event.rb +48 -0
  15. data/lib/log_struct/enums/level.rb +66 -0
  16. data/lib/log_struct/enums/source.rb +26 -0
  17. data/lib/log_struct/enums.rb +9 -0
  18. data/lib/log_struct/formatter.rb +224 -0
  19. data/lib/log_struct/handlers.rb +27 -0
  20. data/lib/log_struct/hash_utils.rb +21 -0
  21. data/lib/log_struct/integrations/action_mailer/callbacks.rb +100 -0
  22. data/lib/log_struct/integrations/action_mailer/error_handling.rb +173 -0
  23. data/lib/log_struct/integrations/action_mailer/event_logging.rb +90 -0
  24. data/lib/log_struct/integrations/action_mailer/metadata_collection.rb +78 -0
  25. data/lib/log_struct/integrations/action_mailer.rb +50 -0
  26. data/lib/log_struct/integrations/active_job/log_subscriber.rb +104 -0
  27. data/lib/log_struct/integrations/active_job.rb +38 -0
  28. data/lib/log_struct/integrations/active_record.rb +258 -0
  29. data/lib/log_struct/integrations/active_storage.rb +94 -0
  30. data/lib/log_struct/integrations/carrierwave.rb +111 -0
  31. data/lib/log_struct/integrations/good_job/log_subscriber.rb +228 -0
  32. data/lib/log_struct/integrations/good_job/logger.rb +73 -0
  33. data/lib/log_struct/integrations/good_job.rb +111 -0
  34. data/lib/log_struct/integrations/host_authorization.rb +81 -0
  35. data/lib/log_struct/integrations/integration_interface.rb +21 -0
  36. data/lib/log_struct/integrations/lograge.rb +114 -0
  37. data/lib/log_struct/integrations/rack.rb +31 -0
  38. data/lib/log_struct/integrations/rack_error_handler/middleware.rb +146 -0
  39. data/lib/log_struct/integrations/rack_error_handler.rb +32 -0
  40. data/lib/log_struct/integrations/shrine.rb +75 -0
  41. data/lib/log_struct/integrations/sidekiq/logger.rb +43 -0
  42. data/lib/log_struct/integrations/sidekiq.rb +39 -0
  43. data/lib/log_struct/integrations/sorbet.rb +49 -0
  44. data/lib/log_struct/integrations.rb +41 -0
  45. data/lib/log_struct/log/action_mailer.rb +55 -0
  46. data/lib/log_struct/log/active_job.rb +64 -0
  47. data/lib/log_struct/log/active_storage.rb +78 -0
  48. data/lib/log_struct/log/carrierwave.rb +82 -0
  49. data/lib/log_struct/log/error.rb +76 -0
  50. data/lib/log_struct/log/good_job.rb +151 -0
  51. data/lib/log_struct/log/interfaces/additional_data_field.rb +20 -0
  52. data/lib/log_struct/log/interfaces/common_fields.rb +42 -0
  53. data/lib/log_struct/log/interfaces/message_field.rb +20 -0
  54. data/lib/log_struct/log/interfaces/request_fields.rb +36 -0
  55. data/lib/log_struct/log/plain.rb +53 -0
  56. data/lib/log_struct/log/request.rb +76 -0
  57. data/lib/log_struct/log/security.rb +80 -0
  58. data/lib/log_struct/log/shared/add_request_fields.rb +29 -0
  59. data/lib/log_struct/log/shared/merge_additional_data_fields.rb +28 -0
  60. data/lib/log_struct/log/shared/serialize_common.rb +36 -0
  61. data/lib/log_struct/log/shrine.rb +70 -0
  62. data/lib/log_struct/log/sidekiq.rb +50 -0
  63. data/lib/log_struct/log/sql.rb +126 -0
  64. data/lib/log_struct/log.rb +43 -0
  65. data/lib/log_struct/log_keys.rb +102 -0
  66. data/lib/log_struct/monkey_patches/active_support/tagged_logging/formatter.rb +36 -0
  67. data/lib/log_struct/multi_error_reporter.rb +149 -0
  68. data/lib/log_struct/param_filters.rb +89 -0
  69. data/lib/log_struct/railtie.rb +31 -0
  70. data/lib/log_struct/semantic_logger/color_formatter.rb +209 -0
  71. data/lib/log_struct/semantic_logger/formatter.rb +94 -0
  72. data/lib/log_struct/semantic_logger/logger.rb +129 -0
  73. data/lib/log_struct/semantic_logger/setup.rb +219 -0
  74. data/lib/log_struct/sorbet/serialize_symbol_keys.rb +23 -0
  75. data/lib/log_struct/sorbet.rb +13 -0
  76. data/lib/log_struct/string_scrubber.rb +84 -0
  77. data/lib/log_struct/version.rb +6 -0
  78. data/lib/log_struct.rb +37 -0
  79. data/lib/logstruct.rb +2 -6
  80. data/logstruct.gemspec +52 -0
  81. metadata +221 -5
  82. data/Rakefile +0 -5
@@ -0,0 +1,48 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module LogStruct
5
+ # Define log event types as an enum
6
+ class Event < T::Enum
7
+ enums do
8
+ # Plain log messages
9
+ Log = new(:log)
10
+
11
+ # Request events
12
+ Request = new(:request)
13
+
14
+ # Job events
15
+ Enqueue = new(:enqueue)
16
+ Schedule = new(:schedule)
17
+ Start = new(:start)
18
+ Finish = new(:finish)
19
+
20
+ # File storage events (ActiveStorage, Shrine, CarrierWave, etc.)
21
+ Upload = new(:upload)
22
+ Download = new(:download)
23
+ Delete = new(:delete)
24
+ Metadata = new(:metadata)
25
+ Exist = new(:exist)
26
+ Stream = new(:stream)
27
+ Url = new(:url)
28
+
29
+ # Email events
30
+ Delivery = new(:delivery)
31
+ Delivered = new(:delivered)
32
+
33
+ # Security events
34
+ IPSpoof = new(:ip_spoof)
35
+ CSRFViolation = new(:csrf_violation)
36
+ BlockedHost = new(:blocked_host)
37
+
38
+ # Database events
39
+ Database = new(:database)
40
+
41
+ # Error events
42
+ Error = new(:error)
43
+
44
+ # Fallback
45
+ Unknown = new(:unknown)
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,66 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "logger"
5
+
6
+ module LogStruct
7
+ # Define log levels as an enum
8
+ class Level < T::Enum
9
+ extend T::Sig
10
+
11
+ enums do
12
+ # Standard log levels
13
+ Debug = new(:debug)
14
+ Info = new(:info)
15
+ Warn = new(:warn)
16
+ Error = new(:error)
17
+ Fatal = new(:fatal)
18
+ Unknown = new(:unknown)
19
+ end
20
+
21
+ # Convert a Level to the corresponding Logger integer constant
22
+ sig { returns(Integer) }
23
+ def to_severity_int
24
+ case serialize
25
+ when :debug then ::Logger::DEBUG
26
+ when :info then ::Logger::INFO
27
+ when :warn then ::Logger::WARN
28
+ when :error then ::Logger::ERROR
29
+ when :fatal then ::Logger::FATAL
30
+ else ::Logger::UNKNOWN
31
+ end
32
+ end
33
+
34
+ # Convert a string or integer severity to a Level
35
+ sig { params(severity: T.any(String, Symbol, Integer, NilClass)).returns(Level) }
36
+ def self.from_severity(severity)
37
+ return Unknown if severity.nil?
38
+ return from_severity_int(severity) if severity.is_a?(Integer)
39
+ from_severity_sym(severity.downcase.to_sym)
40
+ end
41
+
42
+ sig { params(severity: Symbol).returns(Level) }
43
+ def self.from_severity_sym(severity)
44
+ case severity.to_s.downcase.to_sym
45
+ when :debug then Debug
46
+ when :info then Info
47
+ when :warn then Warn
48
+ when :error then Error
49
+ when :fatal then Fatal
50
+ else Unknown
51
+ end
52
+ end
53
+
54
+ sig { params(severity: Integer).returns(Level) }
55
+ def self.from_severity_int(severity)
56
+ case severity
57
+ when ::Logger::DEBUG then Debug
58
+ when ::Logger::INFO then Info
59
+ when ::Logger::WARN then Warn
60
+ when ::Logger::ERROR then Error
61
+ when ::Logger::FATAL then Fatal
62
+ else Unknown
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,26 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module LogStruct
5
+ # Combined Source class that unifies log and error sources
6
+ class Source < T::Enum
7
+ enums do
8
+ # Error sources
9
+ TypeChecking = new(:type_checking) # For type checking errors (Sorbet)
10
+ LogStruct = new(:logstruct) # Errors from LogStruct itself
11
+ Security = new(:security) # Security-related events
12
+
13
+ # Application sources
14
+ Rails = new(:rails) # For request-related logs/errors
15
+ Job = new(:job) # ActiveJob logs/errors
16
+ Storage = new(:storage) # ActiveStorage logs/errors
17
+ Mailer = new(:mailer) # ActionMailer logs/errors
18
+ App = new(:app) # General application logs/errors
19
+
20
+ # Third-party gem sources
21
+ Shrine = new(:shrine)
22
+ CarrierWave = new(:carrierwave)
23
+ Sidekiq = new(:sidekiq)
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,9 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ # Require all enums in this directory
5
+ require_relative "enums/error_handling_mode"
6
+ require_relative "enums/error_reporter"
7
+ require_relative "enums/event"
8
+ require_relative "enums/level"
9
+ require_relative "enums/source"
@@ -0,0 +1,224 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "logger"
5
+ require "active_support/core_ext/object/blank"
6
+ require "json"
7
+ require "globalid"
8
+ require_relative "enums/source"
9
+ require_relative "enums/event"
10
+ require_relative "string_scrubber"
11
+ require_relative "log"
12
+ require_relative "param_filters"
13
+ require_relative "multi_error_reporter"
14
+
15
+ module LogStruct
16
+ class Formatter < ::Logger::Formatter
17
+ extend T::Sig
18
+
19
+ # Add current_tags method to support ActiveSupport::TaggedLogging
20
+ sig { returns(T::Array[String]) }
21
+ def current_tags
22
+ Thread.current[:activesupport_tagged_logging_tags] ||= []
23
+ end
24
+
25
+ # Add tagged method to support ActiveSupport::TaggedLogging
26
+ sig { params(tags: T::Array[String], blk: T.proc.params(formatter: Formatter).void).returns(T.untyped) }
27
+ def tagged(*tags, &blk)
28
+ new_tags = tags.flatten
29
+ current_tags.concat(new_tags) if new_tags.any?
30
+ yield self
31
+ ensure
32
+ current_tags.pop(new_tags.size) if new_tags&.any?
33
+ end
34
+
35
+ # Add clear_tags! method to support ActiveSupport::TaggedLogging
36
+ sig { void }
37
+ def clear_tags!
38
+ Thread.current[:activesupport_tagged_logging_tags] = []
39
+ end
40
+
41
+ sig { params(tags: T::Array[String]).returns(T.untyped) }
42
+ def push_tags(*tags)
43
+ current_tags.concat(tags)
44
+ end
45
+
46
+ sig { params(string: String).returns(String) }
47
+ def scrub_string(string)
48
+ # Use StringScrubber module to scrub sensitive information from strings
49
+ StringScrubber.scrub(string)
50
+ end
51
+
52
+ sig { params(arg: T.untyped, recursion_depth: Integer).returns(T.untyped) }
53
+ def process_values(arg, recursion_depth: 0)
54
+ # Prevent infinite recursion in case any args have circular references
55
+ # or are too deeply nested. Just return args.
56
+ return arg if recursion_depth > 20
57
+
58
+ case arg
59
+ when Hash
60
+ result = {}
61
+
62
+ # Process each key-value pair
63
+ arg.each do |key, value|
64
+ # Check if this key should be filtered at any depth
65
+ result[key] = if ParamFilters.should_filter_key?(key)
66
+ # Filter the value
67
+ {_filtered: ParamFilters.summarize_json_attribute(key, value)}
68
+ else
69
+ # Process the value normally
70
+ process_values(value, recursion_depth: recursion_depth + 1)
71
+ end
72
+ end
73
+
74
+ result
75
+ when Array
76
+ result = arg.map { |value| process_values(value, recursion_depth: recursion_depth + 1) }
77
+
78
+ # Filter large arrays, but don't truncate backtraces (arrays of strings that look like file:line)
79
+ if result.size > 10 && !looks_like_backtrace?(result)
80
+ result = result.take(10) + ["... and #{result.size - 10} more items"]
81
+ end
82
+ result
83
+ when GlobalID::Identification
84
+ begin
85
+ arg.to_global_id
86
+ rescue
87
+ begin
88
+ case arg
89
+ when ActiveRecord::Base
90
+ "#{arg.class}(##{arg.id})"
91
+ else
92
+ # For non-ActiveRecord objects that failed to_global_id, try to get a string representation
93
+ # If this also fails, we want to catch it and return the error placeholder
94
+ T.unsafe(arg).to_s
95
+ end
96
+ rescue => e
97
+ LogStruct.handle_exception(e, source: Source::LogStruct)
98
+ "[GLOBALID_ERROR]"
99
+ end
100
+ end
101
+ when Source, Event
102
+ arg.serialize
103
+ when String
104
+ scrub_string(arg)
105
+ when Time
106
+ arg.iso8601(3)
107
+ else
108
+ # Any other type (e.g. Symbol, Integer, Float, Boolean etc.)
109
+ arg
110
+ end
111
+ rescue => e
112
+ # Report error through LogStruct's framework
113
+ context = {
114
+ processor_method: "process_values",
115
+ value_type: arg.class.name,
116
+ recursion_depth: recursion_depth
117
+ }
118
+ LogStruct.handle_exception(e, source: Source::LogStruct, context: context)
119
+ arg
120
+ end
121
+
122
+ sig { params(log_value: T.untyped, time: Time).returns(T::Hash[Symbol, T.untyped]) }
123
+ def log_value_to_hash(log_value, time:)
124
+ case log_value
125
+ when Log::Interfaces::CommonFields
126
+ # Our log classes all implement a custom #serialize method that use symbol keys
127
+ log_value.serialize
128
+
129
+ when T::Struct
130
+ # Default T::Struct.serialize methods returns a hash with string keys, so convert them to symbols
131
+ log_value.serialize.deep_symbolize_keys
132
+
133
+ when Hash
134
+ # Use hash as is and convert string keys to symbols
135
+ log_value.dup.deep_symbolize_keys
136
+
137
+ else
138
+ # Create a Plain log with the message as a string and serialize it with symbol keys
139
+ # log_value can be literally anything: Integer, Float, Boolean, NilClass, etc.
140
+ log_message = case log_value
141
+ # Handle all the basic types without any further processing
142
+ when String, Symbol, TrueClass, FalseClass, NilClass, Array, Hash, Time, Numeric
143
+ log_value
144
+ else
145
+ # Handle the serialization of complex objects in a useful way:
146
+ #
147
+ # 1. For ActiveRecord models: Use as_json which includes attributes
148
+ # 2. For objects with custom as_json implementations: Use their implementation
149
+ # 3. For basic objects that only have ActiveSupport's as_json: Use to_s
150
+ begin
151
+ method_owner = log_value.method(:as_json).owner
152
+
153
+ # If it's ActiveRecord, ActiveModel, or a custom implementation, use as_json
154
+ if method_owner.to_s.include?("ActiveRecord") ||
155
+ method_owner.to_s.include?("ActiveModel") ||
156
+ method_owner.to_s.exclude?("ActiveSupport::CoreExtensions") &&
157
+ method_owner.to_s.exclude?("Object")
158
+ log_value.as_json
159
+ else
160
+ # For plain objects with only the default ActiveSupport as_json
161
+ log_value.to_s
162
+ end
163
+ rescue => e
164
+ # Handle serialization errors
165
+ context = {
166
+ object_class: log_value.class.name,
167
+ object_inspect: log_value.inspect.truncate(100)
168
+ }
169
+ LogStruct.handle_exception(e, source: Source::LogStruct, context: context)
170
+
171
+ # Fall back to the string representation to ensure we continue processing
172
+ log_value.to_s
173
+ end
174
+ end
175
+
176
+ Log::Plain.new(
177
+ message: log_message,
178
+ timestamp: time
179
+ ).serialize
180
+ end
181
+ end
182
+
183
+ # Serializes Log (or string) into JSON
184
+ sig { params(severity: T.any(String, Symbol, Integer), time: Time, progname: T.nilable(String), log_value: T.untyped).returns(String) }
185
+ def call(severity, time, progname, log_value)
186
+ level_enum = Level.from_severity(severity)
187
+
188
+ data = log_value_to_hash(log_value, time: time)
189
+
190
+ # Filter params, scrub sensitive values, format ActiveJob GlobalID arguments
191
+ data = process_values(data)
192
+
193
+ # Add standard fields if not already present
194
+ data[:src] ||= Source::App
195
+ data[:evt] ||= Event::Log
196
+ data[:ts] ||= time.iso8601(3)
197
+ data[:lvl] = level_enum # Set level from severity parameter
198
+ data[:prog] = progname if progname.present?
199
+
200
+ generate_json(data)
201
+ end
202
+
203
+ # Output as JSON with a newline. We mock this method in tests so we can
204
+ # inspect the data right before it gets turned into a JSON string.
205
+ sig { params(data: T::Hash[T.untyped, T.untyped]).returns(String) }
206
+ def generate_json(data)
207
+ "#{data.to_json}\n"
208
+ end
209
+
210
+ # Check if an array looks like a backtrace (array of strings with file:line pattern)
211
+ sig { params(array: T::Array[T.untyped]).returns(T::Boolean) }
212
+ def looks_like_backtrace?(array)
213
+ return false if array.empty?
214
+
215
+ # Check if most elements look like backtrace lines (file.rb:123 or similar patterns)
216
+ backtrace_like_count = array.first(5).count do |element|
217
+ element.is_a?(String) && element.match?(/\A[^:\s]+:\d+/)
218
+ end
219
+
220
+ # If at least 3 out of the first 5 elements look like backtrace lines, treat as backtrace
221
+ backtrace_like_count >= 3
222
+ end
223
+ end
224
+ end
@@ -0,0 +1,27 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module LogStruct
5
+ # Module for custom handlers used throughout the library
6
+ module Handlers
7
+ # Type for Lograge custom options
8
+ LogrageCustomOptions = T.type_alias {
9
+ T.proc.params(
10
+ event: ActiveSupport::Notifications::Event,
11
+ options: T::Hash[Symbol, T.untyped]
12
+ ).returns(T.untyped)
13
+ }
14
+
15
+ # Type for error reporting handlers
16
+ ErrorReporter = T.type_alias {
17
+ T.proc.params(
18
+ error: StandardError,
19
+ context: T.nilable(T::Hash[Symbol, T.untyped]),
20
+ source: Source
21
+ ).void
22
+ }
23
+
24
+ # Type for string scrubbing handlers
25
+ StringScrubber = T.type_alias { T.proc.params(string: String).returns(String) }
26
+ end
27
+ end
@@ -0,0 +1,21 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "digest"
5
+
6
+ module LogStruct
7
+ # Utility module for hashing sensitive data
8
+ module HashUtils
9
+ class << self
10
+ extend T::Sig
11
+
12
+ # Create a hash of a string value for tracing while preserving privacy
13
+ sig { params(value: String).returns(String) }
14
+ def hash_value(value)
15
+ salt = LogStruct.config.filters.hash_salt
16
+ length = LogStruct.config.filters.hash_length
17
+ Digest::SHA256.hexdigest("#{salt}#{value}")[0...length] || "error"
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,100 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module LogStruct
5
+ module Integrations
6
+ module ActionMailer
7
+ # Backport of the *_deliver callbacks from Rails 7.1
8
+ module Callbacks
9
+ extend T::Sig
10
+ extend ::ActiveSupport::Concern
11
+
12
+ # Track if we've already patched MessageDelivery
13
+ @patched_message_delivery = T.let(false, T::Boolean)
14
+
15
+ # We can't use included block with strict typing
16
+ # This will be handled by ActiveSupport::Concern at runtime
17
+ included do
18
+ include ::ActiveSupport::Callbacks
19
+ if defined?(::ActiveSupport) && ::ActiveSupport.gem_version >= Gem::Version.new("7.1.0")
20
+ define_callbacks :deliver, skip_after_callbacks_if_terminated: true
21
+ else
22
+ define_callbacks :deliver
23
+ end
24
+ end
25
+
26
+ # When this module is prepended (our integration uses prepend), ensure callbacks are defined
27
+ if respond_to?(:prepended)
28
+ prepended do
29
+ include ::ActiveSupport::Callbacks
30
+ if defined?(::ActiveSupport) && ::ActiveSupport.gem_version >= Gem::Version.new("7.1.0")
31
+ define_callbacks :deliver, skip_after_callbacks_if_terminated: true
32
+ else
33
+ define_callbacks :deliver
34
+ end
35
+ end
36
+ end
37
+
38
+ # Define class methods in a separate module
39
+ module ClassMethods
40
+ extend T::Sig
41
+
42
+ # Defines a callback that will get called right before the
43
+ # message is sent to the delivery method.
44
+ sig { params(filters: T.untyped, blk: T.nilable(T.proc.bind(T.untyped).void)).void }
45
+ def before_deliver(*filters, &blk)
46
+ # Use T.unsafe for splat arguments due to Sorbet limitation
47
+ T.unsafe(self).set_callback(:deliver, :before, *filters, &blk)
48
+ end
49
+
50
+ # Defines a callback that will get called right after the
51
+ # message's delivery method is finished.
52
+ sig { params(filters: T.untyped, blk: T.nilable(T.proc.bind(T.untyped).void)).void }
53
+ def after_deliver(*filters, &blk)
54
+ # Use T.unsafe for splat arguments due to Sorbet limitation
55
+ T.unsafe(self).set_callback(:deliver, :after, *filters, &blk)
56
+ end
57
+
58
+ # Defines a callback that will get called around the message's deliver method.
59
+ sig { params(filters: T.untyped, blk: T.nilable(T.proc.bind(T.untyped).params(arg0: T.untyped).void)).void }
60
+ def around_deliver(*filters, &blk)
61
+ # Use T.unsafe for splat arguments due to Sorbet limitation
62
+ T.unsafe(self).set_callback(:deliver, :around, *filters, &blk)
63
+ end
64
+ end
65
+
66
+ # Module to patch ActionMailer::MessageDelivery with callback support
67
+ module MessageDeliveryCallbacks
68
+ extend T::Sig
69
+
70
+ sig { returns(T.untyped) }
71
+ def deliver_now
72
+ processed_mailer.run_callbacks(:deliver) do
73
+ message.deliver
74
+ end
75
+ end
76
+
77
+ sig { returns(T.untyped) }
78
+ def deliver_now!
79
+ processed_mailer.run_callbacks(:deliver) do
80
+ message.deliver!
81
+ end
82
+ end
83
+ end
84
+
85
+ sig { returns(T::Boolean) }
86
+ def self.patch_message_delivery
87
+ # Return early if we've already patched
88
+ return true if @patched_message_delivery
89
+
90
+ # Prepend our module to add callback support to MessageDelivery
91
+ ::ActionMailer::MessageDelivery.prepend(MessageDeliveryCallbacks)
92
+
93
+ # Mark as patched so we don't do it again
94
+ @patched_message_delivery = true
95
+ true
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,173 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module LogStruct
5
+ module Integrations
6
+ module ActionMailer
7
+ # Handles error handling for ActionMailer
8
+ #
9
+ # IMPORTANT LIMITATIONS:
10
+ # 1. This module must be included BEFORE users define rescue_from handlers
11
+ # to ensure proper handler precedence (user handlers are checked first)
12
+ # 2. Rails rescue_from handlers don't bubble to parent class handlers after reraise
13
+ # 3. Handler order matters: Rails checks rescue_from handlers in reverse declaration order
14
+ module ErrorHandling
15
+ extend T::Sig
16
+ extend ActiveSupport::Concern
17
+
18
+ # NOTE: rescue_from handlers are checked in reverse order of declaration.
19
+ # We want LogStruct handlers to be checked AFTER user handlers (lower priority),
20
+ # so we need to add them BEFORE user handlers are declared.
21
+
22
+ # This will be called when the module is included/prepended
23
+ sig { params(base: T.untyped).void }
24
+ def self.install_handler(base)
25
+ # Only add the handler once per class
26
+ return if base.instance_variable_get(:@_logstruct_handler_installed)
27
+
28
+ # Add our handler FIRST so it has lower priority than user handlers
29
+ base.rescue_from StandardError, with: :log_and_reraise_error
30
+
31
+ # Mark as installed to prevent duplicates
32
+ base.instance_variable_set(:@_logstruct_handler_installed, true)
33
+ end
34
+
35
+ included do
36
+ LogStruct::Integrations::ActionMailer::ErrorHandling.install_handler(self)
37
+ end
38
+
39
+ # Also support prepended (used by tests and manual setup)
40
+ sig { params(base: T.untyped).void }
41
+ def self.prepended(base)
42
+ install_handler(base)
43
+ end
44
+
45
+ protected
46
+
47
+ # Just log the error without reporting or retrying
48
+ sig { params(ex: StandardError).void }
49
+ def log_and_ignore_error(ex)
50
+ log_email_delivery_error(ex, notify: false, report: false, reraise: false)
51
+ end
52
+
53
+ # Log and report to error service, but doesn't reraise.
54
+ sig { params(ex: StandardError).void }
55
+ def log_and_report_error(ex)
56
+ log_email_delivery_error(ex, notify: false, report: true, reraise: false)
57
+ end
58
+
59
+ # Log, report to error service, and reraise for retry
60
+ sig { params(ex: StandardError).void }
61
+ def log_and_reraise_error(ex)
62
+ log_email_delivery_error(ex, notify: false, report: true, reraise: true)
63
+ end
64
+
65
+ private
66
+
67
+ # Handle an error from a mailer
68
+ sig { params(mailer: T.untyped, error: StandardError, message: String).void }
69
+ def log_structured_error(mailer, error, message)
70
+ # Create a structured exception log with context
71
+ context = {
72
+ mailer_class: mailer.class.to_s,
73
+ mailer_action: mailer.respond_to?(:action_name) ? mailer.action_name : nil,
74
+ message: message
75
+ }
76
+
77
+ # Create the structured exception log
78
+ exception_data = Log::Error.from_exception(
79
+ Source::Mailer,
80
+ error,
81
+ context
82
+ )
83
+
84
+ # Log the structured error
85
+ LogStruct.error(exception_data)
86
+ end
87
+
88
+ # Log when email delivery fails
89
+ sig { params(error: StandardError, notify: T::Boolean, report: T::Boolean, reraise: T::Boolean).void }
90
+ def log_email_delivery_error(error, notify: false, report: true, reraise: true)
91
+ # Generate appropriate error message
92
+ message = error_message_for(error, reraise)
93
+
94
+ # Use structured error logging
95
+ log_structured_error(self, error, message)
96
+
97
+ # Handle notifications and reporting
98
+ handle_error_notifications(error, notify, report, reraise)
99
+ end
100
+
101
+ # Generate appropriate error message based on error handling strategy
102
+ sig { params(error: StandardError, reraise: T::Boolean).returns(String) }
103
+ def error_message_for(error, reraise)
104
+ if reraise
105
+ "#{error.class}: Email delivery error, will retry. Recipients: #{recipients(error)}"
106
+ else
107
+ "#{error.class}: Cannot send email to #{recipients(error)}"
108
+ end
109
+ end
110
+
111
+ # Handle error notifications, reporting, and reraising
112
+ sig { params(error: StandardError, notify: T::Boolean, report: T::Boolean, reraise: T::Boolean).void }
113
+ def handle_error_notifications(error, notify, report, reraise)
114
+ # Log a notification event if requested
115
+ log_notification_event(error) if notify
116
+
117
+ # Report to error reporting service if requested
118
+ if report
119
+ context = {
120
+ mailer_class: self.class.to_s,
121
+ mailer_action: respond_to?(:action_name) ? action_name : nil,
122
+ recipients: recipients(error)
123
+ }
124
+
125
+ # Create an exception log for structured logging
126
+ exception_data = Log::Error.from_exception(
127
+ Source::Mailer,
128
+ error,
129
+ context
130
+ )
131
+
132
+ # Log the exception with structured data
133
+ LogStruct.error(exception_data)
134
+
135
+ # Call the error handler
136
+ LogStruct.handle_exception(error, source: Source::Mailer, context: context)
137
+ end
138
+
139
+ # Re-raise the error if requested
140
+ Kernel.raise error if reraise
141
+ end
142
+
143
+ # Log a notification event that can be picked up by external systems
144
+ sig { params(error: StandardError).void }
145
+ def log_notification_event(error)
146
+ # Create an error log data object
147
+ exception_data = Log::Error.from_exception(
148
+ Source::Mailer,
149
+ error,
150
+ {
151
+ mailer: self.class,
152
+ action: action_name,
153
+ recipients: recipients(error)
154
+ }
155
+ )
156
+
157
+ # Log the error at info level since it's not a critical error
158
+ LogStruct.info(exception_data)
159
+ end
160
+
161
+ sig { params(error: StandardError).returns(String) }
162
+ def recipients(error)
163
+ # Extract recipient info if available
164
+ if error.respond_to?(:recipients) && T.unsafe(error).recipients.present?
165
+ T.unsafe(error).recipients.join(", ")
166
+ else
167
+ "unknown"
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end
173
+ end