logstruct 0.1.12 → 0.2.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.
Files changed (91) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +133 -1
  3. data/README.md +76 -68
  4. data/lib/log_struct/concerns/configuration.rb +139 -91
  5. data/lib/log_struct/concerns/error_handling.rb +36 -6
  6. data/lib/log_struct/config_struct/error_handling_modes.rb +3 -1
  7. data/lib/log_struct/config_struct/filters.rb +116 -33
  8. data/lib/log_struct/config_struct/integrations.rb +0 -4
  9. data/lib/log_struct/config_struct/limits.rb +29 -0
  10. data/lib/log_struct/configuration.rb +2 -0
  11. data/lib/log_struct/enums/event.rb +3 -1
  12. data/lib/log_struct/enums/log_field.rb +5 -0
  13. data/lib/log_struct/formatter.rb +304 -56
  14. data/lib/log_struct/hash_utils.rb +33 -4
  15. data/lib/log_struct/integrations/action_mailer/error_handling.rb +78 -20
  16. data/lib/log_struct/integrations/action_mailer/fallback_error_handlers.rb +81 -0
  17. data/lib/log_struct/integrations/action_mailer/metadata_collection.rb +0 -20
  18. data/lib/log_struct/integrations/action_mailer.rb +22 -17
  19. data/lib/log_struct/integrations/active_job/log_subscriber.rb +177 -25
  20. data/lib/log_struct/integrations/active_job.rb +2 -7
  21. data/lib/log_struct/integrations/active_model_serializers.rb +7 -1
  22. data/lib/log_struct/integrations/active_record.rb +39 -17
  23. data/lib/log_struct/integrations/active_storage.rb +31 -31
  24. data/lib/log_struct/integrations/ahoy.rb +19 -15
  25. data/lib/log_struct/integrations/carrierwave.rb +55 -51
  26. data/lib/log_struct/integrations/dotenv.rb +22 -32
  27. data/lib/log_struct/integrations/good_job/logger.rb +30 -24
  28. data/lib/log_struct/integrations/good_job.rb +111 -40
  29. data/lib/log_struct/integrations/host_authorization.rb +47 -36
  30. data/lib/log_struct/integrations/lograge.rb +39 -4
  31. data/lib/log_struct/integrations/puma.rb +74 -34
  32. data/lib/log_struct/integrations/rack_error_handler/debug_exceptions_logging.rb +35 -0
  33. data/lib/log_struct/integrations/rack_error_handler/error_lines.rb +160 -0
  34. data/lib/log_struct/integrations/rack_error_handler/middleware.rb +14 -169
  35. data/lib/log_struct/integrations/rack_error_handler/parse_error_logging.rb +36 -0
  36. data/lib/log_struct/integrations/rack_error_handler.rb +20 -4
  37. data/lib/log_struct/integrations/rack_middleware.rb +23 -0
  38. data/lib/log_struct/integrations/rails_log_subscribers.rb +32 -0
  39. data/lib/log_struct/integrations/request_context/middleware.rb +4 -9
  40. data/lib/log_struct/integrations/shrine.rb +48 -73
  41. data/lib/log_struct/integrations/sidekiq/job_context_middleware.rb +32 -0
  42. data/lib/log_struct/integrations/sidekiq/logger.rb +17 -9
  43. data/lib/log_struct/integrations/sidekiq.rb +98 -7
  44. data/lib/log_struct/integrations/sorbet.rb +10 -46
  45. data/lib/log_struct/integrations.rb +3 -2
  46. data/lib/log_struct/log/{good_job/enqueue.rb → active_job/discard.rb} +13 -13
  47. data/lib/log_struct/log/active_job/enqueue.rb +0 -4
  48. data/lib/log_struct/log/{good_job/schedule.rb → active_job/error.rb} +15 -15
  49. data/lib/log_struct/log/{good_job/start.rb → active_job/retry.rb} +13 -13
  50. data/lib/log_struct/log/{good_job/finish.rb → active_job/retry_stopped.rb} +13 -17
  51. data/lib/log_struct/log/active_job.rb +4 -0
  52. data/lib/log_struct/log/active_storage/delete.rb +6 -2
  53. data/lib/log_struct/log/active_storage/download.rb +2 -4
  54. data/lib/log_struct/log/active_storage/exist.rb +2 -2
  55. data/lib/log_struct/log/active_storage/metadata.rb +4 -4
  56. data/lib/log_struct/log/active_storage/upload.rb +2 -10
  57. data/lib/log_struct/log/active_storage/url.rb +2 -6
  58. data/lib/log_struct/log/active_storage.rb +2 -3
  59. data/lib/log_struct/log/error.rb +24 -1
  60. data/lib/log_struct/log/good_job.rb +0 -4
  61. data/lib/log_struct/log/puma/error.rb +63 -0
  62. data/lib/log_struct/log/puma.rb +1 -0
  63. data/lib/log_struct/log/security/blocked_host.rb +0 -4
  64. data/lib/log_struct/log/shrine/exist.rb +0 -2
  65. data/lib/log_struct/log/shrine/metadata.rb +2 -4
  66. data/lib/log_struct/log/shrine/upload.rb +4 -0
  67. data/lib/log_struct/log/sidekiq/error.rb +63 -0
  68. data/lib/log_struct/log/{active_storage/stream.rb → sidekiq/log.rb} +17 -11
  69. data/lib/log_struct/log/sidekiq.rb +3 -45
  70. data/lib/log_struct/log.rb +4 -2
  71. data/lib/log_struct/logged_errors.rb +25 -0
  72. data/lib/log_struct/monkey_patches/active_support/tagged_logging/formatter.rb +2 -3
  73. data/lib/log_struct/param_filters.rb +51 -38
  74. data/lib/log_struct/params_errors.rb +78 -0
  75. data/lib/log_struct/rails_boot_banner_silencer.rb +7 -2
  76. data/lib/log_struct/railtie.rb +29 -14
  77. data/lib/log_struct/semantic_logger/color_formatter.rb +60 -53
  78. data/lib/log_struct/semantic_logger/concerns/log_methods.rb +65 -57
  79. data/lib/log_struct/semantic_logger/formatter.rb +79 -57
  80. data/lib/log_struct/semantic_logger/logger.rb +29 -53
  81. data/lib/log_struct/semantic_logger/reopen_after_fork.rb +40 -0
  82. data/lib/log_struct/semantic_logger/setup.rb +115 -112
  83. data/lib/log_struct/shared/serialize_common.rb +2 -7
  84. data/lib/log_struct/string_scrubber.rb +229 -20
  85. data/lib/log_struct/version.rb +1 -1
  86. data/lib/log_struct.rb +6 -4
  87. data/logstruct.gemspec +19 -10
  88. metadata +63 -11
  89. data/lib/log_struct/builders/active_job.rb +0 -87
  90. data/lib/log_struct/integrations/good_job/log_subscriber.rb +0 -189
  91. data/lib/log_struct/integrations/rack.rb +0 -31
@@ -8,6 +8,7 @@ require_relative "enums/level"
8
8
  require_relative "enums/log_field"
9
9
  require_relative "log/interfaces/public_common_fields"
10
10
  require_relative "shared/serialize_common_public"
11
+ require_relative "params_errors"
11
12
 
12
13
  # Dynamically require all top-level log structs under log/*
13
14
  # Nested per-event files are required by their parent files.
@@ -23,7 +24,7 @@ module LogStruct
23
24
  sig do
24
25
  params(
25
26
  source: Source,
26
- ex: StandardError,
27
+ ex: Exception,
27
28
  additional_data: T::Hash[T.any(String, Symbol), T.untyped],
28
29
  timestamp: Time
29
30
  ).returns(LogStruct::Log::Error)
@@ -32,7 +33,8 @@ module LogStruct
32
33
  LogStruct::Log::Error.new(
33
34
  source: source,
34
35
  error_class: ex.class,
35
- message: ex.message,
36
+ # Without the input a params parser quoted, for a request's params error that the app logs itself
37
+ message: ParamsErrors.messages(ex).first,
36
38
  backtrace: ex.backtrace,
37
39
  additional_data: additional_data,
38
40
  timestamp: timestamp
@@ -0,0 +1,25 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module LogStruct
5
+ # Exceptions LogStruct has already logged as a structured line, so an integration that sees the same
6
+ # exception again doesn't log it twice. GoodJob passes a failed job's exception to on_thread_error after
7
+ # the ActiveJob integration has logged it, for example. Exceptions are held weakly, so a mark doesn't keep
8
+ # one alive.
9
+ module LoggedErrors
10
+ extend T::Sig
11
+
12
+ @logged = T.let(ObjectSpace::WeakMap.new, ObjectSpace::WeakMap)
13
+ @mutex = T.let(Mutex.new, Mutex)
14
+
15
+ sig { params(error: Exception).void }
16
+ def self.mark(error)
17
+ @mutex.synchronize { @logged[error] = true }
18
+ end
19
+
20
+ sig { params(error: Exception).returns(T::Boolean) }
21
+ def self.logged?(error)
22
+ @mutex.synchronize { @logged.key?(error) }
23
+ end
24
+ end
25
+ end
@@ -31,14 +31,13 @@ module ActiveSupport
31
31
  # Preserve original Rails behavior when LogStruct is disabled
32
32
  return super unless ::LogStruct.enabled?
33
33
 
34
- # Get current tags
35
- tags = T.unsafe(self).respond_to?(:current_tags) ? current_tags : (Thread.current[:activesupport_tagged_logging_tags] || [])
34
+ tags = current_tags
36
35
 
37
36
  # Add tags to data as hash entry (not text prefix)
38
37
  data_with_tags = if data.is_a?(Hash)
39
38
  tags.present? ? data.merge(tags: tags) : data
40
39
  elsif data.is_a?(::LogStruct::Log::Interfaces::CommonFields) || (data.is_a?(T::Struct) && data.respond_to?(:serialize))
41
- hash = T.unsafe(data).serialize
40
+ hash = data.serialize
42
41
  tags.present? ? hash.merge(tags: tags) : hash
43
42
  else
44
43
  base = {LogStruct::LogField::Message.serialize => data.to_s}
@@ -1,8 +1,9 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
- require "digest"
4
+ require "json"
5
5
  require_relative "hash_utils"
6
+ require_relative "string_scrubber"
6
7
  require_relative "config_struct/filters"
7
8
  require_relative "enums/source"
8
9
 
@@ -13,19 +14,47 @@ module LogStruct
13
14
  class << self
14
15
  extend T::Sig
15
16
 
16
- # Check if a key should be filtered based on our defined sensitive keys
17
- sig { params(key: T.untyped, value: T.untyped).returns(T::Boolean) }
18
- def should_filter_key?(key, value = nil)
17
+ # Check if a key should be filtered based on our defined sensitive keys.
18
+ # logstruct_field: the key is one of LogStruct's own fields at the top of a line.
19
+ # path: the keys from the line's top-level field down to this one (the key alone if not given).
20
+ # root: the top-level field's value, or the whole line for a top-level key.
21
+ sig do
22
+ params(
23
+ key: T.untyped,
24
+ value: T.untyped,
25
+ logstruct_field: T::Boolean,
26
+ path: T.nilable(T::Array[String]),
27
+ root: T.untyped
28
+ ).returns(T::Boolean)
29
+ end
30
+ def should_filter_key?(key, value = nil, logstruct_field: false, path: nil, root: nil)
19
31
  filters = LogStruct.config.filters
20
32
  normalized_key = key.to_s
21
- normalized_symbol = normalized_key.downcase.to_sym
22
33
 
23
- return true if filters.filter_keys.include?(normalized_symbol)
34
+ words = KeyWords.normalize(normalized_key).to_sym
35
+ # By its words too, so :api_key filters apiKey and X-Api-Key. Keys in filter_keys_with_hashes are
36
+ # filtered too, with a hash of their value.
37
+ [filters.filter_keys, filters.filter_keys_with_hashes].each do |keys|
38
+ return true if keys.include?(normalized_key.downcase.to_sym) || keys.include?(words)
39
+ end
40
+
41
+ matched = filters.filter_matchers.any? do |matcher|
42
+ next false if logstruct_field && !matcher.matches_logstruct_fields
24
43
 
25
- filters.filter_matchers.any? do |matcher|
26
44
  matcher.matches?(normalized_key, value)
27
45
  rescue => e
28
- handle_filter_matcher_error(e, matcher, normalized_key)
46
+ handle_filter_matcher_error(e, matcher.label, normalized_key)
47
+ false
48
+ end
49
+ return true if matched
50
+ return false if filters.path_matchers.empty?
51
+
52
+ path ||= [normalized_key]
53
+ root = {} if root.nil?
54
+ filters.path_matchers.any? do |matcher|
55
+ matcher.matches?(path, value, root)
56
+ rescue => e
57
+ handle_filter_matcher_error(e, matcher.label, normalized_key)
29
58
  false
30
59
  end
31
60
  end
@@ -33,7 +62,8 @@ module LogStruct
33
62
  # Check if a key should be hashed rather than completely filtered
34
63
  sig { params(key: T.untyped).returns(T::Boolean) }
35
64
  def should_include_string_hash?(key)
36
- LogStruct.config.filters.filter_keys_with_hashes.include?(key.to_s.downcase.to_sym)
65
+ keys = LogStruct.config.filters.filter_keys_with_hashes
66
+ keys.include?(key.to_s.downcase.to_sym) || keys.include?(KeyWords.normalize(key.to_s).to_sym)
37
67
  end
38
68
 
39
69
  # Convert a value to a filtered summary hash (e.g. { _filtered: { class: "String", ... }})
@@ -51,56 +81,39 @@ module LogStruct
51
81
  end
52
82
  end
53
83
 
54
- # Summarize a String for logging, including details and an SHA256 hash (if configured)
84
+ # Summarize a filtered String: its class, and an HMAC of it for keys in filter_keys_with_hashes.
85
+ # No length, which would say how long a password or token is.
55
86
  sig { params(string: String, include_hash: T::Boolean).returns(T::Hash[Symbol, T.untyped]) }
56
87
  def summarize_string(string, include_hash)
57
88
  filtered_string = {
58
89
  _class: String
59
90
  }
60
- if include_hash
61
- filtered_string[:_hash] = HashUtils.hash_value(string)
62
- else
63
- filtered_string[:_bytes] = string.bytesize
64
- end
91
+ filtered_string[:_hash] = StringScrubber.value_hash(string) if include_hash
65
92
 
66
93
  filtered_string
67
94
  end
68
95
 
69
- # Summarize a Hash for logging, including details about the size and keys
96
+ # Summarize a filtered Hash: its first 10 keys and how many there are. No size, which would say
97
+ # how long the secrets in it are.
70
98
  sig { params(hash: T::Hash[T.untyped, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
71
99
  def summarize_hash(hash)
72
100
  return {_class: "Hash", _empty: true} if hash.empty?
73
101
 
74
- # Don't include byte size if hash contains any filtered keys
75
- has_sensitive_keys = T.let(false, T::Boolean)
76
- normalized_keys = []
77
-
78
- hash.each do |key, value|
79
- has_sensitive_keys ||= should_filter_key?(key, value)
80
- normalized_keys << normalize_summary_key(key)
81
- end
82
-
83
- summary = {
102
+ {
84
103
  _class: Hash,
85
104
  _keys_count: hash.keys.size,
86
- _keys: normalized_keys.take(10)
105
+ _keys: hash.keys.take(10).map { |key| normalize_summary_key(StringScrubber.scrub_name(key)) }
87
106
  }
88
-
89
- # Only add byte size if no sensitive keys are present
90
- summary[:_bytes] = hash.to_json.bytesize unless has_sensitive_keys
91
-
92
- summary
93
107
  end
94
108
 
95
- # Summarize an Array for logging, including details about the size and items
109
+ # Summarize a filtered Array: how many items it has
96
110
  sig { params(array: T::Array[T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
97
111
  def summarize_array(array)
98
112
  return {_class: "Array", _empty: true} if array.empty?
99
113
 
100
114
  {
101
115
  _class: Array,
102
- _count: array.size,
103
- _bytes: array.to_json.bytesize
116
+ _count: array.size
104
117
  }
105
118
  end
106
119
 
@@ -119,10 +132,10 @@ module LogStruct
119
132
  key.to_s
120
133
  end
121
134
 
122
- sig { params(error: StandardError, matcher: ConfigStruct::FilterMatcher, key: String).void }
123
- def handle_filter_matcher_error(error, matcher, key)
135
+ sig { params(error: StandardError, label: String, key: String).void }
136
+ def handle_filter_matcher_error(error, label, key)
124
137
  context = {
125
- matcher: matcher.label,
138
+ matcher: label,
126
139
  key: key
127
140
  }
128
141
  LogStruct.handle_exception(error, source: Source::Internal, context: context)
@@ -0,0 +1,78 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module LogStruct
5
+ # Errors from parsing a request's params: Rails' ParseError (and its ParamErrors), and Rack's. Their messages, and
6
+ # those of errors raised because of them (Rails' "Invalid request parameters: ..."), quote the request's input: a
7
+ # JSON parser's excerpt of the body, Rack's "invalid %-encoding (<value>)". Nothing can filter a body that didn't
8
+ # parse, so LogStruct's lines leave the input out.
9
+ module ParamsErrors
10
+ extend T::Sig
11
+
12
+ # Matched by name, so this works without ActionDispatch or Rack loaded
13
+ NAMES = T.let(
14
+ [
15
+ "ActionDispatch::Http::Parameters::ParseError",
16
+ "Rack::BadRequest",
17
+ "Rack::QueryParser::InvalidParameterError",
18
+ "Rack::QueryParser::ParameterTypeError",
19
+ "Rack::QueryParser::ParamsTooDeepError"
20
+ ].freeze,
21
+ T::Array[String]
22
+ )
23
+ # Rails' message for a ParseError
24
+ PARSE_ERROR_MESSAGE = "Error occurred while parsing request parameters"
25
+ PARAMS_ERROR_MESSAGE = "Invalid request parameters"
26
+ CAUSE_MESSAGE_LEFT_OUT = "(left out: it quotes the request's input)"
27
+
28
+ # The message and the cause's message to log for an error, without the input a params parser quoted
29
+ sig { params(error: Exception).returns([String, T.nilable(String)]) }
30
+ def self.messages(error)
31
+ cause = error.cause
32
+ if params_error?(error)
33
+ [parse_error?(error) ? PARSE_ERROR_MESSAGE : PARAMS_ERROR_MESSAGE, nil]
34
+ elsif cause && params_error?(cause)
35
+ message = error.message.sub(cause.message, "").sub(/[\s:]+\z/, "")
36
+ [message.empty? ? PARAMS_ERROR_MESSAGE : message, nil]
37
+ else
38
+ [error.message, cause&.message]
39
+ end
40
+ end
41
+
42
+ # For text that writes an error's whole chain (its causes too): each message in it that quotes a params parser's
43
+ # input, with what to write instead. Longest first, since an error's message can contain its cause's. An error
44
+ # raised without a message (Rack's and Rails' depth limits) has its class name as its message, which quotes
45
+ # nothing and is also the text's label for its class, so it stays.
46
+ sig { params(error: Exception).returns(T::Array[[String, String]]) }
47
+ def self.replacements(error)
48
+ pairs = T.let([], T::Array[[String, String]])
49
+ seen = T.let({}.compare_by_identity, T::Hash[Exception, TrueClass])
50
+ current = T.let(error, T.nilable(Exception))
51
+ while current && !seen.key?(current)
52
+ seen[current] = true
53
+ message, cause_message = messages(current)
54
+ pairs << [current.message, message] if message != current.message && !default_message?(current)
55
+ cause = current.cause
56
+ pairs << [cause.message, CAUSE_MESSAGE_LEFT_OUT] if cause && cause_message.nil? && !default_message?(cause)
57
+ current = cause
58
+ end
59
+ pairs.reject { |quoted, _| quoted.empty? }.uniq.sort_by { |quoted, _| -quoted.length }
60
+ end
61
+
62
+ sig { params(error: Exception).returns(T::Boolean) }
63
+ def self.default_message?(error)
64
+ error.message == error.class.name
65
+ end
66
+
67
+ sig { params(error: Exception).returns(T::Boolean) }
68
+ def self.parse_error?(error)
69
+ !!defined?(::ActionDispatch::Http::Parameters::ParseError) &&
70
+ error.instance_of?(::ActionDispatch::Http::Parameters::ParseError)
71
+ end
72
+
73
+ sig { params(error: Exception).returns(T::Boolean) }
74
+ def self.params_error?(error)
75
+ error.class.ancestors.any? { |ancestor| NAMES.include?(ancestor.name) }
76
+ end
77
+ end
78
+ end
@@ -15,6 +15,9 @@ module LogStruct
15
15
  @installed = true
16
16
 
17
17
  return unless ARGV.include?("server")
18
+ # Rails' own banner stays when LogStruct is disabled
19
+ return unless LogStruct.enabled?
20
+
18
21
  patch!
19
22
  end
20
23
 
@@ -51,6 +54,8 @@ module LogStruct
51
54
 
52
55
  module ServerCommandSilencer
53
56
  extend T::Sig
57
+ extend T::Helpers
58
+ requires_ancestor { Object }
54
59
 
55
60
  sig { params(args: T.untyped, block: T.nilable(T.proc.returns(T.untyped))).returns(T.untyped) }
56
61
  def perform(*args, &block)
@@ -105,9 +110,9 @@ module LogStruct
105
110
 
106
111
  sig { returns(String) }
107
112
  def lookup_executable
108
- return "rails" unless T.unsafe(self).respond_to?(:executable, true)
113
+ return "rails" unless respond_to?(:executable, true)
109
114
 
110
- T.cast(T.unsafe(self).send(:executable), String)
115
+ T.cast(send(:executable), String)
111
116
  rescue
112
117
  "rails"
113
118
  end
@@ -10,31 +10,49 @@ require_relative "integrations"
10
10
  module LogStruct
11
11
  # Railtie to integrate with Rails
12
12
  class Railtie < ::Rails::Railtie
13
- # Configure early, right after logger initialization
13
+ # Replace the logger as soon as Rails creates it, so boot-time logs and anything that keeps
14
+ # a reference to Rails.logger use LogStruct's logger
14
15
  initializer "logstruct.configure_logger", after: :initialize_logger do |app|
15
16
  next unless LogStruct.enabled?
16
17
 
17
18
  # Use SemanticLogger for powerful logging features
18
19
  LogStruct::SemanticLogger::Setup.configure_semantic_logger(app)
20
+
21
+ # Initializers that configure Sidekiq log with its logger while config/initializers run
22
+ Integrations::Sidekiq.configure_logger if LogStruct.config.integrations.enable_sidekiq
19
23
  end
20
24
 
21
- # Setup all integrations after logger setup is complete
22
- initializer "logstruct.setup", before: :build_middleware_stack do |app|
25
+ # Set up integrations after config/initializers, so that the settings in
26
+ # config/initializers/logstruct.rb and filter_parameters added by other initializers apply
27
+ initializer "logstruct.setup", after: :load_config_initializers, before: :build_middleware_stack do |app|
28
+ # An initializer may have changed `enabled` or `enabled_environments` after the logger was
29
+ # (or wasn't) replaced
30
+ LogStruct.apply_initializer_settings!
31
+ if LogStruct.enabled?
32
+ if LogStruct::SemanticLogger::Setup.configured?
33
+ # The formatter, colors, noisy logger filter and log file settings in config/initializers apply too
34
+ LogStruct::SemanticLogger::Setup.reconfigure_appenders(app)
35
+ else
36
+ LogStruct::SemanticLogger::Setup.configure_semantic_logger(app)
37
+ end
38
+ elsif LogStruct::SemanticLogger::Setup.configured?
39
+ LogStruct::SemanticLogger::Setup.restore_rails_logger(app)
40
+ end
41
+ unless LogStruct.enabled? && LogStruct.config.integrations.enable_sidekiq
42
+ Integrations::Sidekiq.restore_logger
43
+ end
44
+ Integrations::ActionMailer.setup_fallback_error_handlers(LogStruct.config)
45
+
23
46
  next unless LogStruct.enabled?
24
47
 
25
48
  # Merge Rails filter parameters into our filters
26
49
  LogStruct.merge_rails_filter_parameters!
27
50
 
28
- # Set up non-middleware integrations first
29
51
  Integrations.setup_integrations(stage: :non_middleware)
30
-
31
- # Note: Host allowances are managed by the test app itself.
32
52
  end
33
53
 
34
- # Setup middleware integrations during Rails configuration (before middleware stack is built)
35
- # Must be done in the Railtie class body, not in an initializer
36
- initializer "logstruct.configure_middleware", before: :build_middleware_stack do |app|
37
- # This runs before middleware stack is frozen, so we can configure it
54
+ # Add middleware before the middleware stack is built
55
+ initializer "logstruct.configure_middleware", after: "logstruct.setup", before: :build_middleware_stack do |app|
38
56
  next unless LogStruct.enabled?
39
57
 
40
58
  Integrations.setup_integrations(stage: :middleware)
@@ -42,6 +60,7 @@ module LogStruct
42
60
 
43
61
  # Emit Puma lifecycle logs when running `rails server`
44
62
  initializer "logstruct.puma_lifecycle", after: "logstruct.configure_logger" do
63
+ next unless LogStruct.enabled?
45
64
  is_server = ::LogStruct.server_mode?
46
65
  next unless is_server
47
66
  begin
@@ -53,10 +72,6 @@ module LogStruct
53
72
  process_id: Process.pid,
54
73
  listening_addresses: port ? ["tcp://127.0.0.1:#{port}"] : nil
55
74
  )
56
- begin
57
- warn("[logstruct] puma lifecycle init")
58
- rescue
59
- end
60
75
  LogStruct.info(started)
61
76
 
62
77
  at_exit do
@@ -6,46 +6,12 @@ require_relative "formatter"
6
6
 
7
7
  module LogStruct
8
8
  module SemanticLogger
9
- # Development-Optimized Colorized JSON Formatter
10
- #
11
- # This formatter extends SemanticLogger's Color formatter to provide beautiful,
12
- # readable JSON output in development environments. It significantly improves
13
- # the developer experience when working with structured logs.
14
- #
15
- # ## Benefits of Colorized Output:
16
- #
17
- # ### Readability
18
- # - **Syntax highlighting**: JSON keys, values, and data types are color-coded
19
- # - **Visual hierarchy**: Different colors help identify structure at a glance
20
- # - **Error spotting**: Quickly identify malformed data or unexpected values
21
- # - **Context separation**: Log entries are visually distinct from each other
22
- #
23
- # ### Performance in Development
24
- # - **Faster debugging**: Quickly scan logs without reading every character
25
- # - **Pattern recognition**: Colors help identify common log patterns
26
- # - **Reduced cognitive load**: Less mental effort required to parse log output
27
- # - **Improved workflow**: Spend less time reading logs, more time coding
28
- #
29
- # ### Customization
30
- # - **Configurable colors**: Customize colors for keys, strings, numbers, etc.
31
- # - **Environment-aware**: Automatically disabled in production/CI environments
32
- # - **Fallback support**: Gracefully falls back to standard formatting if needed
33
- #
34
- # ## Color Mapping:
35
- # - **Keys**: Yellow - Easy to spot field names
36
- # - **Strings**: Green - Clear indication of text values
37
- # - **Numbers**: Blue - Numeric values stand out
38
- # - **Booleans**: Magenta - true/false values are distinctive
39
- # - **Null**: Red - Missing values are immediately visible
40
- # - **Logger names**: Cyan - Source identification
41
- #
42
- # ## Integration with SemanticLogger:
43
- # This formatter preserves all SemanticLogger benefits (performance, threading,
44
- # reliability) while adding visual enhancements. It processes LogStruct types,
45
- # hashes, and plain messages with appropriate colorization.
46
- #
47
- # The formatter is automatically enabled in development when `enable_color_output`
48
- # is true (default), providing zero-configuration enhanced logging experience.
9
+ # Writes lines as pretty-printed JSON in color, for reading in a terminal: keys yellow, strings green, numbers
10
+ # blue, booleans magenta, nil red and logger names cyan (config.color_map changes them). LogStruct uses it in
11
+ # development when LogStruct is enabled there, prefer_json_in_development is false and enable_color_output is
12
+ # true (its default). LogStruct entries and Hashes are the JSON line, pretty-printed. Plain lines keep
13
+ # SemanticLogger's layout, with their message, exception and tags scrubbed and their named tags filtered as a
14
+ # JSON line's ctx is.
49
15
  class ColorFormatter < ::SemanticLogger::Formatters::Color
50
16
  extend T::Sig
51
17
 
@@ -53,19 +19,17 @@ module LogStruct
53
19
  def initialize(color_map: nil, **args)
54
20
  super(**args)
55
21
  @logstruct_formatter = T.let(LogStruct::Formatter.new, LogStruct::Formatter)
22
+ @json_formatter = T.let(LogStruct::SemanticLogger::Formatter.new, LogStruct::SemanticLogger::Formatter)
56
23
 
57
24
  # Set up custom color mapping
58
- @custom_colors = T.let(color_map || default_color_map, T::Hash[Symbol, Symbol])
25
+ @custom_colors = T.let(default_color_map.merge(color_map || {}), T::Hash[Symbol, Symbol])
59
26
  end
60
27
 
61
28
  sig { override.params(log: ::SemanticLogger::Log, logger: T.untyped).returns(String) }
62
29
  def call(log, logger)
63
30
  # Handle LogStruct types specially with colorization
64
- if log.payload.is_a?(LogStruct::Log::Interfaces::CommonFields)
65
- formatted = format_logstruct_payload(log)
66
- formatted if formatted
67
- elsif log.payload.is_a?(Hash) || log.payload.is_a?(T::Struct)
68
- formatted = format_logstruct_payload(log)
31
+ if log.payload.is_a?(Hash) || log.payload.is_a?(T::Struct)
32
+ formatted = format_logstruct_payload(log, logger)
69
33
  formatted if formatted
70
34
  else
71
35
  # For plain messages, use SemanticLogger's default colorization
@@ -73,8 +37,51 @@ module LogStruct
73
37
  end
74
38
  end
75
39
 
40
+ # The parts of a plain line that hold what was logged, scrubbed like the strings in a JSON line
41
+ sig { returns(T.untyped) }
42
+ def message
43
+ scrub_text(super)
44
+ end
45
+
46
+ sig { returns(T.untyped) }
47
+ def payload
48
+ scrub_text(super)
49
+ end
50
+
51
+ # SemanticLogger's text for the exception and its causes, without the input a params parser quoted
52
+ sig { returns(T.untyped) }
53
+ def exception
54
+ text = super
55
+ error = log.exception
56
+ if text.is_a?(String) && error.is_a?(Exception)
57
+ ParamsErrors.replacements(error).each { |quoted, logged| text = text.gsub(quoted, logged) }
58
+ end
59
+ scrub_text(text)
60
+ end
61
+
62
+ sig { returns(T.untyped) }
63
+ def tags
64
+ scrub_text(super)
65
+ end
66
+
67
+ # Filtered and scrubbed as they are in a JSON line's ctx: a key filtering hides there is hidden here too
68
+ sig { returns(T.nilable(String)) }
69
+ def named_tags
70
+ named_tags = log.named_tags
71
+ return if named_tags.nil? || named_tags.empty?
72
+
73
+ filtered = @logstruct_formatter.process_values({ctx: named_tags.to_h.transform_keys(&:to_sym)})[:ctx]
74
+ list = filtered.map { |name, value| "#{color}#{name}: #{value}#{color_map.clear}" }
75
+ "{#{list.join(", ")}}"
76
+ end
77
+
76
78
  private
77
79
 
80
+ sig { params(text: T.untyped).returns(T.untyped) }
81
+ def scrub_text(text)
82
+ text.is_a?(String) ? StringScrubber.scrub(text) : text
83
+ end
84
+
78
85
  sig { returns(LogStruct::Formatter) }
79
86
  attr_reader :logstruct_formatter
80
87
 
@@ -91,11 +98,9 @@ module LogStruct
91
98
  }
92
99
  end
93
100
 
94
- # Simple JSON colorizer that adds ANSI codes
101
+ # The line as pretty-printed JSON, with ANSI colors for its keys, strings, numbers, booleans and nulls
95
102
  sig { params(data: T::Hash[String, T.untyped]).returns(String) }
96
103
  def colorize_json(data)
97
- # For now, just return a simple colorized version of the JSON
98
- # This is much simpler than the full recursive approach
99
104
  json_str = JSON.pretty_generate(data)
100
105
 
101
106
  # Apply basic colorization with regex
@@ -106,9 +111,10 @@ module LogStruct
106
111
  .gsub(": null", ": " + colorize_text("null", :nil))
107
112
  end
108
113
 
109
- sig { params(log: ::SemanticLogger::Log).returns(T.nilable(String)) }
110
- def format_logstruct_payload(log)
111
- logstruct_json = @logstruct_formatter.call(log.level, log.time, log.name, log.payload)
114
+ # The line LogStruct::SemanticLogger::Formatter writes (message, tags and ctx included), in color
115
+ sig { params(log: ::SemanticLogger::Log, logger: T.untyped).returns(T.nilable(String)) }
116
+ def format_logstruct_payload(log, logger)
117
+ logstruct_json = @json_formatter.call(log, logger)
112
118
 
113
119
  parsed_data = T.let(JSON.parse(logstruct_json), T::Hash[String, T.untyped])
114
120
  colorized_json = colorize_json(parsed_data)
@@ -119,7 +125,8 @@ module LogStruct
119
125
  process: log.process_info,
120
126
  name: format_name(log.name))
121
127
 
122
- "#{prefix}#{colorized_json}\n"
128
+ # SemanticLogger's appenders add the newline
129
+ "#{prefix}#{colorized_json}"
123
130
  rescue JSON::ParserError
124
131
  nil
125
132
  end