unified_logger 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 2b1182b4653c380d8b35b55733c0ba47fa4c5eff722178765dfe6b2f1fddaf8c
4
+ data.tar.gz: 7b94cd76f7288dffa0004f69a57e633ffa2342cfb67969031e8822f02a96aab6
5
+ SHA512:
6
+ metadata.gz: 3a6090da5a5deb10ad56cf57c475342517dc854658bd23934b4a4c7bf247060cb0851a96af50698b8dd1ca268c8f2a147a4e7478df05931fd3cf0c7e2a3093f6
7
+ data.tar.gz: 23df148c3859f4e42f1646ddc94d774de39ed9908e74660035ebbe618f8b81d709d9fc5dbe96a0454d71ae4058c2df0ec2a65799d6d3e68caee49883d5d88b8e
@@ -0,0 +1,45 @@
1
+ require "English"
2
+
3
+ module UnifiedLogger
4
+ class JobLogger
5
+ DEFAULT_MAX_RETRIES = 5
6
+
7
+ class << self
8
+ def log(job)
9
+ yield
10
+ ensure
11
+ log_execution(job) if UnifiedLogger.current_logger.is_a?(UnifiedLogger::Logger)
12
+ end
13
+
14
+ private
15
+
16
+ def log_execution(job)
17
+ log = {
18
+ log_type: :job,
19
+ class_name: job.class.name,
20
+ id: job.job_id,
21
+ queue: job.queue_name,
22
+ params: job.arguments,
23
+ executions_count: job.executions,
24
+ exception_executions: job.exception_executions,
25
+ enqueued_at: job.enqueued_at,
26
+ locale: job.locale,
27
+ duration: job.enqueued_at.present? ? UnifiedLogger.current_time - job.enqueued_at.in_time_zone : "unknown"
28
+ }
29
+ log[:custom] = UnifiedLogger::Logger.fetch_and_reset_custom_logs if UnifiedLogger::Logger.custom_logs.any?
30
+
31
+ if $ERROR_INFO
32
+ log[:exception] = UnifiedLogger::Logger.format_exception($ERROR_INFO)
33
+ log[:status] = job.executions >= DEFAULT_MAX_RETRIES ? :error : :warn
34
+ else
35
+ log[:status] = :ok
36
+ end
37
+
38
+ custom = {}
39
+ UnifiedLogger.transform_job_log_callable&.call(custom)
40
+ log.merge!(custom)
41
+ UnifiedLogger.current_logger.write(UnifiedLogger::Logger.format(log))
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,143 @@
1
+ module UnifiedLogger
2
+ class Logger < ::Logger
3
+ CUSTOM_LOGS = Concurrent::ThreadLocalVar.new([])
4
+ SEVERITY_LEVELS = {
5
+ debug: ::Logger::DEBUG,
6
+ info: ::Logger::INFO,
7
+ warn: ::Logger::WARN,
8
+ error: ::Logger::ERROR,
9
+ fatal: ::Logger::FATAL,
10
+ unknown: ::Logger::UNKNOWN
11
+ }.freeze
12
+ SEVERITY_MAP = SEVERITY_LEVELS.invert.freeze
13
+
14
+ def initialize(logging_device = $stdout, *)
15
+ super
16
+ @logging_device = logging_device
17
+ self.formatter = proc {}
18
+ end
19
+
20
+ def debug(message = nil, params = {})
21
+ add(::Logger::DEBUG, message, params)
22
+ end
23
+
24
+ def info(message = nil, params = {})
25
+ add(::Logger::INFO, message, params)
26
+ end
27
+
28
+ def warn(message = nil, params = {})
29
+ add(::Logger::WARN, message, params)
30
+ end
31
+
32
+ def error(message = nil, params = {})
33
+ add(::Logger::ERROR, message, params)
34
+ end
35
+
36
+ def fatal(message = nil, params = {})
37
+ add(::Logger::FATAL, message, params)
38
+ end
39
+
40
+ def unknown(message = nil, params = {})
41
+ add(::Logger::UNKNOWN, message, params)
42
+ end
43
+
44
+ def add(severity, message = nil, params = {})
45
+ return true if message.blank?
46
+ return true unless severity >= level
47
+
48
+ severity_symbol = SEVERITY_MAP[severity] || :unknown
49
+ self.class.append_custom_log(severity_symbol, message, params)
50
+ end
51
+
52
+ def <<(message)
53
+ add(::Logger::UNKNOWN, message.to_s.chomp, {})
54
+ self
55
+ end
56
+
57
+ def write(message)
58
+ @logging_device.write("#{message}\n") if @logging_device.respond_to?(:write)
59
+ end
60
+
61
+ class << self
62
+ def custom_logs
63
+ CUSTOM_LOGS.value
64
+ end
65
+
66
+ def reset_thread_logs
67
+ CUSTOM_LOGS.value = []
68
+ end
69
+
70
+ def fetch_and_reset_custom_logs
71
+ logs = custom_logs
72
+ reset_thread_logs
73
+ logs
74
+ end
75
+
76
+ def trim(data)
77
+ data = filter(data)
78
+ size = data.inspect.length
79
+ max = UnifiedLogger.config[:max_log_field_size]
80
+ return data if size < max
81
+
82
+ begin
83
+ json = JSON.generate(data, quirks_mode: true)
84
+ rescue JSON::GeneratorError, Encoding::UndefinedConversionError
85
+ json = data.respond_to?("to_s") ? data.to_s : "unparseable object (instance of #{data.class.name})"
86
+ end
87
+ "#{json[0..max]}... (#{size - max} extra characters omitted)"
88
+ end
89
+
90
+ def format_exception(exception)
91
+ if exception.is_a?(String)
92
+ { message: exception }
93
+ elsif exception.is_a?(Exception)
94
+ btc = ActiveSupport::BacktraceCleaner.new
95
+ prefix = UnifiedLogger.backtrace_root + File::SEPARATOR
96
+ btc.add_filter { |line| line.sub(/\A#{Regexp.escape(prefix)}/, "") }
97
+ btc.add_silencer { |line| /request_logger.rb/.match?(line) }
98
+ { class_name: exception.class.name, message: exception.message, backtrace: btc.clean(exception.backtrace || []) }
99
+ elsif exception.respond_to?(:to_s)
100
+ exception.to_s
101
+ else
102
+ exception.class.name
103
+ end
104
+ end
105
+
106
+ def filter(content)
107
+ return content unless content.respond_to?(:each)
108
+
109
+ filter_class = if defined?(ActiveSupport::ParameterFilter)
110
+ ActiveSupport::ParameterFilter
111
+ else
112
+ ActionDispatch::Http::ParameterFilter
113
+ end
114
+ filter_class.new(UnifiedLogger.config[:filter_params]).filter(content)
115
+ end
116
+
117
+ def format(log)
118
+ filtered_log = filter(log)
119
+ formatter = UnifiedLogger.log_transformer
120
+ formatter.present? ? formatter.call(filtered_log) : filtered_log.to_json
121
+ end
122
+
123
+ def append_custom_log(severity, message, params)
124
+ clean_message = clean_log_message(message)
125
+ log_hash = { timestamp: UnifiedLogger.current_time, severity: severity,
126
+ message: clean_message, params: params }.compact_blank
127
+
128
+ CUSTOM_LOGS.value = CUSTOM_LOGS.value + [log_hash]
129
+ true
130
+ end
131
+
132
+ def clean_log_message(text)
133
+ return text unless text.is_a?(String)
134
+
135
+ text = text.gsub(/\e\[[0-9;]*m/, "")
136
+ text = text.gsub(%r{[^a-zA-Z0-9\s.,;:!?'"()\[\]{}\-_@#$%&*+=<>|~`/]}, "")
137
+ text = text.gsub('"', "'")
138
+ text = text.gsub(/\s+/, " ")
139
+ text.strip
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,7 @@
1
+ module UnifiedLogger
2
+ class Railtie < Rails::Railtie
3
+ initializer "unified_logger.middleware", after: :load_config_initializers do |app|
4
+ app.middleware.insert_after ActionDispatch::DebugExceptions, UnifiedLogger::RequestLogger if UnifiedLogger.config[:auto_insert_middleware]
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,108 @@
1
+ require "English"
2
+
3
+ module UnifiedLogger
4
+ class RequestLogger
5
+ def initialize(app)
6
+ @app = app
7
+ end
8
+
9
+ def call(env)
10
+ return @app.call(env) unless UnifiedLogger.current_logger.is_a?(UnifiedLogger::Logger)
11
+
12
+ started = UnifiedLogger.current_time
13
+ status, headers, response = @app.call(env)
14
+ ensure
15
+ if UnifiedLogger.current_logger.is_a?(UnifiedLogger::Logger) && !silenced?(env["REQUEST_PATH"])
16
+ log = build_log(started, env, status, headers, response)
17
+ custom = {}
18
+ UnifiedLogger.transform_request_log_callable&.call(custom, env)
19
+ log.merge!(custom)
20
+ UnifiedLogger.current_logger.write(UnifiedLogger::Logger.format(log))
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def silenced?(path)
27
+ UnifiedLogger.config[:silence_paths].any? do |pattern|
28
+ pattern.is_a?(Regexp) ? pattern.match?(path) : pattern == path
29
+ end
30
+ end
31
+
32
+ def build_log(started, env, status = nil, headers = nil, response = nil)
33
+ status ||= 500
34
+ headers ||= {}
35
+ response ||= ""
36
+ path_parameters = env["action_dispatch.request.path_parameters"] || {}
37
+ query_string = env["QUERY_STRING"]
38
+ log = {
39
+ log_type: :request,
40
+ id: env["action_dispatch.request_id"],
41
+ ip: env["action_dispatch.remote_ip"].to_s,
42
+ controller: path_parameters[:controller],
43
+ action: path_parameters[:action],
44
+ request: {
45
+ path: env["REQUEST_PATH"],
46
+ method: env["REQUEST_METHOD"],
47
+ headers: env.select { |e| e.start_with?("HTTP_") }.reject { |h| h.start_with?("HTTP_SEC_") },
48
+ path_params: path_parameters.except(:controller, :action),
49
+ query_params: query_string.blank? ? {} : Rack::Utils.parse_nested_query(query_string),
50
+ body: request_body(env)
51
+ },
52
+ response: {
53
+ headers: headers,
54
+ status: status,
55
+ body: response_body(response, headers["content-type"])
56
+ },
57
+ thread_id: Thread.current.object_id,
58
+ process_id: Process.pid,
59
+ duration: started ? UnifiedLogger.current_time - started : 0
60
+ }
61
+ log[:exception] = UnifiedLogger::Logger.format_exception($ERROR_INFO) if $ERROR_INFO.present?
62
+ log[:custom] = UnifiedLogger::Logger.fetch_and_reset_custom_logs if UnifiedLogger::Logger.custom_logs.any?
63
+
64
+ log
65
+ end
66
+
67
+ def request_body(env)
68
+ return nil unless (input = env["rack.input"])
69
+ return env["rack.request.form_hash"] if env["rack.request.form_hash"]
70
+
71
+ input.rewind
72
+ body = input.read
73
+ input.rewind
74
+
75
+ parse_body(body, env["CONTENT_TYPE"])
76
+ end
77
+
78
+ def response_body(response, content_type)
79
+ return nil if response.nil? || (response.respond_to?(:empty?) && response.empty?)
80
+ return nil if content_type&.exclude?("application/") && content_type.exclude?("text/plain")
81
+ return response_body(response.body, content_type) if response.respond_to?(:body)
82
+
83
+ body = response.respond_to?(:map) ? response.map(&:to_s).join : response.to_s
84
+
85
+ parse_body(body, content_type)
86
+ ensure
87
+ response.close if response.respond_to?(:close)
88
+ end
89
+
90
+ def parse_body(body, content_type)
91
+ return nil if body.nil?
92
+ return nil if body.empty?
93
+
94
+ main_content_type = content_type&.split(";")&.first&.strip&.downcase
95
+
96
+ case main_content_type
97
+ when "application/json"
98
+ JSON.parse(body)
99
+ when "application/x-www-form-urlencoded"
100
+ Rack::Utils.parse_nested_query(body)
101
+ else
102
+ UnifiedLogger::Logger.trim(body)
103
+ end
104
+ rescue JSON::ParserError, TypeError, ArgumentError
105
+ body
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,3 @@
1
+ module UnifiedLogger
2
+ VERSION = "0.1.0".freeze
3
+ end
@@ -0,0 +1,81 @@
1
+ require "concurrent"
2
+ require "active_support/core_ext/object/blank"
3
+ require "active_support/core_ext/enumerable"
4
+ require "active_support/parameter_filter"
5
+ require "active_support/backtrace_cleaner"
6
+ require "json"
7
+ require "logger"
8
+
9
+ require_relative "unified_logger/version"
10
+ require_relative "unified_logger/logger"
11
+ require_relative "unified_logger/request_logger"
12
+ require_relative "unified_logger/job_logger"
13
+
14
+ module UnifiedLogger
15
+ class DoubleDefineError < StandardError; end
16
+
17
+ DEFAULTS = {
18
+ max_log_field_size: 2048,
19
+ filter_params: %i[passw secret token crypt salt certificate otp ssn set-cookie http_authorization http_cookie pin],
20
+ auto_insert_middleware: true,
21
+ silence_paths: []
22
+ }.freeze
23
+
24
+ def self.config
25
+ @config ||= DEFAULTS.dup
26
+ end
27
+
28
+ def self.configure(options = {})
29
+ config.merge!(options)
30
+ end
31
+
32
+ class << self
33
+ attr_reader :transform_request_log_callable, :transform_job_log_callable, :log_transformer
34
+
35
+ def transform_request_log=(callable)
36
+ raise DoubleDefineError, "transform_request_log already defined" if @transform_request_log_callable
37
+
38
+ @transform_request_log_callable = callable
39
+ end
40
+
41
+ def transform_job_log=(callable)
42
+ raise DoubleDefineError, "transform_job_log already defined" if @transform_job_log_callable
43
+
44
+ @transform_job_log_callable = callable
45
+ end
46
+
47
+ def log_transformer=(callable)
48
+ raise DoubleDefineError, "log_transformer already defined" if @log_transformer
49
+
50
+ @log_transformer = callable
51
+ end
52
+
53
+ delegate :trim, :filter, :format, :format_exception,
54
+ :clean_log_message, :append_custom_log,
55
+ :custom_logs, :fetch_and_reset_custom_logs, :reset_thread_logs,
56
+ to: :"UnifiedLogger::Logger"
57
+ end
58
+
59
+ def self.backtrace_root
60
+ if defined?(Rails)
61
+ Rails.root.to_s
62
+ else
63
+ Dir.pwd
64
+ end
65
+ end
66
+
67
+ def self.current_logger
68
+ return unless defined?(Rails)
69
+
70
+ return Rails.logger if Rails.logger.is_a?(UnifiedLogger::Logger)
71
+ return Rails.logger unless Rails.logger.respond_to?(:broadcasts)
72
+
73
+ Rails.logger.broadcasts.find { |l| l.is_a?(UnifiedLogger::Logger) } || Rails.logger
74
+ end
75
+
76
+ def self.current_time
77
+ Time.zone&.now || Time.now.utc
78
+ end
79
+ end
80
+
81
+ require "unified_logger/railtie" if defined?(Rails::Railtie)
metadata ADDED
@@ -0,0 +1,102 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: unified_logger
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Marcovecchio
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activesupport
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '4.2'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '9'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '4.2'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '9'
32
+ - !ruby/object:Gem::Dependency
33
+ name: concurrent-ruby
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - "~>"
37
+ - !ruby/object:Gem::Version
38
+ version: '1.0'
39
+ type: :runtime
40
+ prerelease: false
41
+ version_requirements: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - "~>"
44
+ - !ruby/object:Gem::Version
45
+ version: '1.0'
46
+ - !ruby/object:Gem::Dependency
47
+ name: rack
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: '1.6'
53
+ - - "<"
54
+ - !ruby/object:Gem::Version
55
+ version: '4'
56
+ type: :runtime
57
+ prerelease: false
58
+ version_requirements: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: '1.6'
63
+ - - "<"
64
+ - !ruby/object:Gem::Version
65
+ version: '4'
66
+ description: One JSON log line per request or job. Captures request/response data,
67
+ in-app logger calls, exceptions with cleaned backtraces, and sensitive data filtering
68
+ — all in a single event.
69
+ executables: []
70
+ extensions: []
71
+ extra_rdoc_files: []
72
+ files:
73
+ - lib/unified_logger.rb
74
+ - lib/unified_logger/job_logger.rb
75
+ - lib/unified_logger/logger.rb
76
+ - lib/unified_logger/railtie.rb
77
+ - lib/unified_logger/request_logger.rb
78
+ - lib/unified_logger/version.rb
79
+ homepage: https://github.com/marcovecchio/unified_logger
80
+ licenses:
81
+ - MIT
82
+ metadata:
83
+ rubygems_mfa_required: 'true'
84
+ source_code_uri: https://github.com/marcovecchio/unified_logger
85
+ rdoc_options: []
86
+ require_paths:
87
+ - lib
88
+ required_ruby_version: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ version: '2.4'
93
+ required_rubygems_version: !ruby/object:Gem::Requirement
94
+ requirements:
95
+ - - ">="
96
+ - !ruby/object:Gem::Version
97
+ version: '0'
98
+ requirements: []
99
+ rubygems_version: 3.6.7
100
+ specification_version: 4
101
+ summary: Structured JSON logging for Rack and Rails applications
102
+ test_files: []