iugu_logger 0.10.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.
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'concurrent'
4
+
5
+ module IuguLogger
6
+ # Thread-local buffer for in-app log entries collected during a single
7
+ # request or job execution.
8
+ #
9
+ # Pattern (per IUGU_LOGGING_STANDARD.md §2.1):
10
+ # - Framework / app code calls IuguLogger::Buffer.current.push(...)
11
+ # instead of emitting one log per call.
12
+ # - Middleware (RequestLogger / JobLogger) drains the buffer at end of
13
+ # execution and emits ONE consolidated event with `logs: [...]` array.
14
+ # - Result: 1 log line per request, instead of N — aggregation is 10×
15
+ # simpler downstream (Loki/Splunk).
16
+ #
17
+ # Also carries a `context` hash for request-scoped enrichment (trace,
18
+ # iugu, request) that downstream code can read or that middleware merges
19
+ # into the final emitted event.
20
+ #
21
+ # Thread safety: each thread has its own buffer instance via
22
+ # Concurrent::ThreadLocalVar. Reset between requests by the middleware.
23
+ #
24
+ # NOT used by IuguLogger.event(...) directly — that API still emits
25
+ # immediately. Buffer is the place where Rails.logger.X (post-Railtie,
26
+ # v0.5) accumulates entries.
27
+ class Buffer
28
+ THREAD_LOCAL = Concurrent::ThreadLocalVar.new
29
+
30
+ class << self
31
+ # Returns the buffer for the current thread, creating one lazily.
32
+ def current
33
+ THREAD_LOCAL.value ||= new
34
+ end
35
+
36
+ # Resets the current thread's buffer (used by middleware between
37
+ # requests). Safe to call when no buffer exists yet.
38
+ def reset!
39
+ THREAD_LOCAL.value = nil
40
+ end
41
+ end
42
+
43
+ MAX_ENTRIES = 100
44
+
45
+ attr_reader :context, :rails_runtime
46
+
47
+ def initialize
48
+ @entries = []
49
+ @context = {}
50
+ @rails_runtime = {} # view_ms / db_ms captured by Railtie subscriber
51
+ end
52
+
53
+ # Records the Rails view/db runtime captured at the end of action
54
+ # dispatch via ActiveSupport::Notifications. RequestLogger reads
55
+ # these when building the canonical `rails.*` block, since
56
+ # ActionController::API doesn't populate the corresponding env keys.
57
+ def set_rails_runtime(view_ms: nil, db_ms: nil)
58
+ @rails_runtime['view_ms'] = view_ms unless view_ms.nil?
59
+ @rails_runtime['db_ms'] = db_ms unless db_ms.nil?
60
+ end
61
+
62
+ # Append a log entry. Severity is a symbol or string; message is the
63
+ # human-readable summary; extras become extra keys on the entry.
64
+ def push(severity:, message:, **extras)
65
+ return if @entries.length >= MAX_ENTRIES # cap to avoid unbounded growth
66
+
67
+ entry = {
68
+ 'timestamp' => Time.now.utc.strftime('%Y-%m-%dT%H:%M:%S.%6NZ'),
69
+ 'severity' => severity.to_s,
70
+ 'message' => message
71
+ }
72
+ extras.each { |k, v| entry[k.to_s] = v unless v.nil? }
73
+ @entries << entry
74
+ end
75
+
76
+ # Returns the array of entries collected so far and clears the buffer.
77
+ def drain
78
+ collected = @entries
79
+ @entries = []
80
+ collected
81
+ end
82
+
83
+ # Number of entries currently buffered (without draining).
84
+ def size
85
+ @entries.length
86
+ end
87
+
88
+ # Merge fields into the request-scoped context. Existing keys overwritten.
89
+ def add_context(**fields)
90
+ fields.each { |k, v| @context[k] = v unless v.nil? }
91
+ end
92
+
93
+ def reset_context!
94
+ @context = {}
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IuguLogger
4
+ # SDK configuration object. Set via `IuguLogger.configure`.
5
+ class Configuration
6
+ attr_accessor :service_name, :service_version, :service_environment,
7
+ :output, :format,
8
+ :pii_redaction, :pii_param_blocklist,
9
+ :event_action_validator, :event_action_registry,
10
+ :max_log_size_kb, :emit_service_instance
11
+
12
+ # Default cap for emitted log size, in KB. The middleware truncates
13
+ # `request.params` (typically the biggest field) when the encoded
14
+ # payload would exceed this, and sets `request.params_truncated: true`
15
+ # so downstream consumers know.
16
+ DEFAULT_MAX_LOG_SIZE_KB = 64
17
+
18
+ def initialize
19
+ @service_name = nil
20
+ @service_version = nil
21
+ @service_environment = ENV['RAILS_ENV'] || ENV['RACK_ENV'] || 'development'
22
+ @output = $stdout
23
+ @format = :json # :json | :pretty
24
+
25
+ # PII v0.2 — strategies overridable per type:
26
+ # :full_redact / :last4 / :detect_only / :preserve
27
+ @pii_redaction = Pii::DEFAULT_STRATEGIES.dup
28
+ @pii_param_blocklist = Pii::DEFAULT_PARAM_BLOCKLIST.dup
29
+
30
+ # Schema v0.3 — event.action validation:
31
+ # :strict — raise on unknown action / missing required field
32
+ # :warn — annotate emitted log with labels.schema_warning, proceed
33
+ # :off — disabled (default — backward compatible)
34
+ # event_action_registry: Hash from Schema.load_from_file or nil. When
35
+ # nil, validation is :off regardless of mode.
36
+ @event_action_validator = Schema::DEFAULT_MODE
37
+ @event_action_registry = nil
38
+
39
+ # v0.9 — log size cap (KB). Set to nil to disable.
40
+ @max_log_size_kb = DEFAULT_MAX_LOG_SIZE_KB
41
+
42
+ # service.instance (= HOSTNAME/POD_NAME) is OFF by default: in the
43
+ # canonical K8s + Alloy pipeline it duplicates the `pod_name` label
44
+ # Alloy already attaches to every log line. Set to true for contexts
45
+ # where no collector enriches the log (local dev, non-K8s, alternate
46
+ # sinks) and you still want the instance identifier in the payload.
47
+ @emit_service_instance = false
48
+ end
49
+
50
+ # Validates required fields. Raises ConfigurationError if missing.
51
+ def validate!
52
+ missing = []
53
+ missing << 'service_name' if blank?(service_name)
54
+ missing << 'service_version' if blank?(service_version)
55
+ missing << 'service_environment' if blank?(service_environment)
56
+
57
+ unless missing.empty?
58
+ raise ConfigurationError,
59
+ "missing required configuration: #{missing.join(', ')}. " \
60
+ "Call IuguLogger.configure { |c| c.service_name = ... }."
61
+ end
62
+
63
+ unless %i[json pretty].include?(format)
64
+ raise ConfigurationError, "unknown format: #{format.inspect} (expected :json or :pretty)"
65
+ end
66
+
67
+ self
68
+ end
69
+
70
+ private
71
+
72
+ def blank?(value)
73
+ value.nil? || (value.respond_to?(:empty?) && value.empty?)
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+
5
+ module IuguLogger
6
+ # Background job middlewares — emit ONE consolidated event per job execution.
7
+ # Same pattern as RequestLogger, applied to Sidekiq and ActiveJob.
8
+ #
9
+ # Usage (Sidekiq, server side):
10
+ #
11
+ # Sidekiq.configure_server do |config|
12
+ # config.server_middleware do |chain|
13
+ # chain.add IuguLogger::JobLogger::Sidekiq
14
+ # end
15
+ # end
16
+ #
17
+ # Usage (ActiveJob — typically via ApplicationJob):
18
+ #
19
+ # class ApplicationJob < ActiveJob::Base
20
+ # include IuguLogger::JobLogger::ActiveJob
21
+ # end
22
+ #
23
+ # Spec: IUGU_LOGGING_STANDARD.md §6 (JobLogger middleware)
24
+ module JobLogger
25
+ SOURCE_SIDEKIQ = 'sidekiq'
26
+ SOURCE_ACTIVEJOB = 'activejob'
27
+
28
+ # Common body for Sidekiq + ActiveJob — wraps a job execution with the
29
+ # 1-log-per-execution pattern. Both wrappers below delegate here.
30
+ class Adapter
31
+ def initialize(job_class:, job_id:, queue:, source:, attempt:)
32
+ @job_class = job_class.to_s
33
+ @job_id = job_id.to_s
34
+ @queue = queue.to_s
35
+ @source = source.to_s
36
+ @attempt = attempt.to_i
37
+ end
38
+
39
+ def call
40
+ Buffer.reset!
41
+ buffer = Buffer.current
42
+
43
+ request = build_request
44
+ labels = build_labels
45
+
46
+ buffer.add_context(request: request, labels: labels)
47
+
48
+ start = monotonic_now
49
+ result = yield
50
+ emit_completed(request: request, labels: labels,
51
+ duration_ms: elapsed_ms(start), logs: buffer.drain)
52
+ result
53
+ rescue StandardError => e
54
+ emit_failed(request: request, labels: labels, error: e,
55
+ duration_ms: elapsed_ms(start), logs: buffer&.drain || [])
56
+ raise
57
+ ensure
58
+ Buffer.reset!
59
+ end
60
+
61
+ private
62
+
63
+ def build_request
64
+ {
65
+ 'id' => @job_id,
66
+ 'source' => @source
67
+ }.reject { |_, v| v.nil? || (v.respond_to?(:empty?) && v.empty?) }
68
+ end
69
+
70
+ def build_labels
71
+ # Loki cardinality: keep only stable, low-cardinality dimensions here
72
+ # (job_class, queue, attempt). Per-execution things (duration_ms,
73
+ # job_id) live elsewhere on the payload.
74
+ {
75
+ 'job_class' => @job_class,
76
+ 'queue' => @queue,
77
+ 'attempt' => @attempt.to_s
78
+ }
79
+ end
80
+
81
+ def emit_completed(request:, labels:, duration_ms:, logs:)
82
+ IuguLogger.event(
83
+ "#{@source}.job.completed",
84
+ severity: :info,
85
+ message: format_completed_message(duration_ms),
86
+ request: request,
87
+ labels: labels,
88
+ duration_ms: duration_ms,
89
+ logs: logs.empty? ? nil : logs
90
+ )
91
+ end
92
+
93
+ def emit_failed(request:, labels:, error:, duration_ms:, logs:)
94
+ IuguLogger.event(
95
+ "#{@source}.job.failed",
96
+ severity: :error,
97
+ message: format_failed_message(error, duration_ms),
98
+ request: request,
99
+ labels: labels,
100
+ duration_ms: duration_ms,
101
+ error: {
102
+ 'type' => error.class.name,
103
+ 'message' => error.message.to_s,
104
+ 'fingerprint' => fingerprint(error)
105
+ },
106
+ logs: logs.empty? ? nil : logs
107
+ )
108
+ end
109
+
110
+ # Human-readable summary in the same family as the HTTP RequestLogger:
111
+ # `<JobClass>#perform [<queue>] <ok|failed> [<duration_ms>ms]`. Helps
112
+ # plantonista 3am scan Sidekiq logs without expanding each event.
113
+ def format_completed_message(duration_ms)
114
+ "#{@job_class}#perform [#{@queue}] ok [#{duration_ms}ms]"
115
+ end
116
+
117
+ def format_failed_message(error, duration_ms)
118
+ "#{@job_class}#perform [#{@queue}] failed [#{duration_ms}ms] #{error.class.name}"
119
+ end
120
+
121
+ def monotonic_now
122
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
123
+ end
124
+
125
+ def elapsed_ms(start)
126
+ return 0 if start.nil?
127
+
128
+ ((monotonic_now - start) * 1000).round
129
+ end
130
+
131
+ def fingerprint(error)
132
+ Digest::SHA256.hexdigest("#{error.class.name}|#{normalized_message(error.message)}")
133
+ end
134
+
135
+ def normalized_message(msg)
136
+ msg.to_s.gsub(/\d+/, 'N').gsub(/[a-f0-9]{16,}/i, 'HEX')
137
+ end
138
+ end
139
+
140
+ # Sidekiq server middleware. Compatible with sidekiq, sidekiq-pro,
141
+ # sidekiq-ent (msg payload format identical).
142
+ class Sidekiq
143
+ def call(_worker_instance, msg, queue)
144
+ Adapter.new(
145
+ job_class: msg['class'],
146
+ job_id: msg['jid'],
147
+ queue: queue,
148
+ source: SOURCE_SIDEKIQ,
149
+ attempt: (msg['retry_count'] || 0) + 1
150
+ ).call { yield }
151
+ end
152
+ end
153
+
154
+ # ActiveJob mixin. `include` in ApplicationJob to capture all jobs.
155
+ # ActiveJob >= 5 has #executions; we fall back to 1 on Rails 4.2
156
+ # (platform).
157
+ module ActiveJob
158
+ def self.included(base)
159
+ base.around_perform do |job, block|
160
+ Adapter.new(
161
+ job_class: job.class.name,
162
+ job_id: job.job_id,
163
+ queue: job.queue_name,
164
+ source: SOURCE_ACTIVEJOB,
165
+ attempt: job.respond_to?(:executions) ? job.executions : 1
166
+ ).call { block.call }
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,247 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'time'
5
+
6
+ module IuguLogger
7
+ # Core logger — emits canonical iugu log events as JSON lines.
8
+ #
9
+ # v0.3 adds Schema::Validator integration. Subsequent releases add:
10
+ # - Trace context extraction + Rack/Sidekiq middlewares (ILS-023)
11
+ # - Thread-local buffer for in-app logs
12
+ # - Railtie auto-config
13
+ #
14
+ # Spec: IUGU_LOGGING_STANDARD.md §6
15
+ class Logger
16
+ EVENT_KIND_DEFAULT = 'event'
17
+ DEFAULT_SEVERITY = :info
18
+
19
+ def initialize(configuration)
20
+ @configuration = configuration
21
+ @configuration.validate!
22
+ end
23
+
24
+ # Emits a structured event log.
25
+ #
26
+ # @example
27
+ # logger.event('pix.out.requested',
28
+ # pix: { end_to_end_id: e2e, amount_brl: 100 },
29
+ # iugu: { account_id: account.id })
30
+ #
31
+ # @example with explicit severity and kind
32
+ # logger.event('pix.out.timeout', severity: :error, message: 'jdpi timeout')
33
+ #
34
+ # When event_action_validator is :strict, an unknown action or a missing
35
+ # required field raises (UnknownEventAction / SchemaViolation).
36
+ # When :warn, the emitted log carries `labels.schema_warning` and proceeds.
37
+ # When :off (default), no validation runs.
38
+ #
39
+ # If the registry definition declares an `event_kind` for the action
40
+ # (e.g. audit-class events), it is used as default when the caller does
41
+ # not pass an explicit `kind:`.
42
+ def event(action, severity: nil, kind: nil, message: nil, **fields)
43
+ raise ArgumentError, 'event.action is required' if action.nil? || action.to_s.empty?
44
+
45
+ validation = schema_validator.validate(action.to_s, fields)
46
+ enforce_validation!(action, validation)
47
+
48
+ effective_kind = kind || validation.event_kind || EVENT_KIND_DEFAULT
49
+ effective_severity = severity || DEFAULT_SEVERITY
50
+
51
+ raise ArgumentError, "unknown severity: #{effective_severity.inspect}" unless Severity.valid?(effective_severity)
52
+
53
+ fields = inject_trace_context(fields)
54
+
55
+ user_section_raw = build_user_section(message: message || action, fields: fields)
56
+ user_section_raw = annotate_warning(user_section_raw, validation) if warn_mode? && needs_warning?(validation)
57
+
58
+ scan_result = pii_scanner.scan(user_section_raw)
59
+
60
+ payload = build_metadata(action: action.to_s, severity: effective_severity.to_s, kind: effective_kind.to_s)
61
+ .merge(scan_result.payload)
62
+ .merge('pii' => scan_result.to_pii_block)
63
+
64
+ emit(payload)
65
+ end
66
+
67
+ private
68
+
69
+ # Raise on validation problems when in :strict mode. In :warn mode, no
70
+ # raise — the warning is annotated into the emitted payload.
71
+ def enforce_validation!(action, validation)
72
+ return if validation.ok? || validation.off?
73
+ return unless @configuration.event_action_validator == Schema::STRICT
74
+
75
+ case validation.status
76
+ when :unknown_action
77
+ suggestion_text = format_suggestions(validation.suggestions)
78
+ raise UnknownEventAction, "unknown event.action: #{action.inspect}#{suggestion_text}"
79
+ when :missing_required
80
+ raise SchemaViolation,
81
+ "event.action #{action.inspect} missing required field(s): #{validation.missing_fields.join(', ')}"
82
+ end
83
+ end
84
+
85
+ def format_suggestions(suggestions)
86
+ return '' if suggestions.nil? || suggestions.empty?
87
+
88
+ ". Did you mean: #{suggestions.join(', ')}?"
89
+ end
90
+
91
+ def warn_mode?
92
+ @configuration.event_action_validator == Schema::WARN
93
+ end
94
+
95
+ def needs_warning?(validation)
96
+ validation.unknown_action? || validation.missing_required?
97
+ end
98
+
99
+ def annotate_warning(section, validation)
100
+ warning =
101
+ if validation.unknown_action?
102
+ 'unknown_event_action'
103
+ else
104
+ "missing_required:#{validation.missing_fields.join(',')}"
105
+ end
106
+
107
+ labels = (section['labels'] || {}).merge('schema_warning' => warning)
108
+ section.merge('labels' => labels)
109
+ end
110
+
111
+ # SDK-controlled fields. Not subjected to PII scan (no user content).
112
+ def build_metadata(action:, severity:, kind:)
113
+ {
114
+ '@timestamp' => current_timestamp,
115
+ 'log.level' => severity,
116
+ 'event.kind' => kind,
117
+ 'event.action' => action,
118
+ 'service' => service_payload
119
+ }
120
+ end
121
+
122
+ # Auto-correlates a standalone event with the active trace context, so a
123
+ # domain event (`IuguLogger.event`) emitted during a request/job carries
124
+ # the same `trace.*` as the consolidated request/job log — without the
125
+ # caller having to thread the trace through by hand.
126
+ #
127
+ # Skipped entirely when the caller passes `trace:` (including the
128
+ # RequestLogger middleware, which manages its own — `trace: nil` there
129
+ # means "no trace", and we respect it).
130
+ #
131
+ # Resolution order (first wins):
132
+ # 1. Buffer context — populated by RequestLogger via a full
133
+ # rack_env-aware extraction (incl. the X-Request-Id fallback), so it
134
+ # matches the request log exactly, even when OTEL is absent.
135
+ # 2. Ambient OpenTelemetry/Datadog span via TraceContext.extract —
136
+ # covers jobs / scripts / console where no middleware filled the
137
+ # buffer (no rack_env, so OTEL/Datadog only).
138
+ def inject_trace_context(fields)
139
+ return fields if fields.key?(:trace)
140
+
141
+ trace = buffered_trace || TraceContext.extract
142
+ return fields if trace.nil? || (trace.respond_to?(:empty?) && trace.empty?)
143
+
144
+ fields.merge(trace: trace)
145
+ end
146
+
147
+ def buffered_trace
148
+ context = Buffer.current.context
149
+ context && context[:trace]
150
+ end
151
+
152
+ # User-supplied content. Will be scanned by Pii::Scanner.
153
+ def build_user_section(message:, fields:)
154
+ result = { 'message' => message.to_s }
155
+ fields.each do |key, value|
156
+ next if value.nil?
157
+
158
+ result[key.to_s] = stringify_keys(value)
159
+ end
160
+ result
161
+ end
162
+
163
+ def schema_validator
164
+ @schema_validator ||= Schema::Validator.new(
165
+ registry: @configuration.event_action_registry,
166
+ mode: @configuration.event_action_validator
167
+ )
168
+ end
169
+
170
+ def pii_scanner
171
+ Pii::Scanner.new(
172
+ strategies: @configuration.pii_redaction,
173
+ param_blocklist: @configuration.pii_param_blocklist
174
+ )
175
+ end
176
+
177
+ def current_timestamp
178
+ Time.now.utc.strftime('%Y-%m-%dT%H:%M:%S.%6NZ')
179
+ end
180
+
181
+ def service_payload
182
+ result = {
183
+ 'name' => @configuration.service_name.to_s,
184
+ 'version' => @configuration.service_version.to_s,
185
+ 'environment' => @configuration.service_environment.to_s
186
+ }
187
+ # Opt-in (default off): in K8s + Alloy the `pod_name` label already
188
+ # carries this, so emitting it here would duplicate it on every line.
189
+ if @configuration.emit_service_instance
190
+ instance = ENV['HOSTNAME'] || ENV['POD_NAME']
191
+ result['instance'] = instance unless instance.nil? || instance.empty?
192
+ end
193
+ result
194
+ end
195
+
196
+ # Recursively stringifies hash keys for JSON output. Avoids ActiveSupport
197
+ # dependency for this hot-path operation.
198
+ def stringify_keys(value)
199
+ case value
200
+ when Hash
201
+ result = {}
202
+ value.each { |k, v| result[k.to_s] = stringify_keys(v) }
203
+ result
204
+ when Array
205
+ value.map { |item| stringify_keys(item) }
206
+ else
207
+ value
208
+ end
209
+ end
210
+
211
+ def emit(payload)
212
+ payload = truncate_oversized_params(payload)
213
+
214
+ line =
215
+ case @configuration.format
216
+ when :pretty then JSON.pretty_generate(payload)
217
+ else JSON.generate(payload)
218
+ end
219
+ @configuration.output.puts(line)
220
+ end
221
+
222
+ # If the emitted payload exceeds `Configuration#max_log_size_kb`, drop the
223
+ # contents of `request.params` (typically the biggest field by a wide
224
+ # margin — full HTTP request body) and flag `request.params_truncated:
225
+ # true`. Other canonical fields (trace, iugu, rails, http, pii, etc.)
226
+ # stay intact so observability/audit still works on the truncated log.
227
+ #
228
+ # When `max_log_size_kb` is nil, this is a no-op.
229
+ def truncate_oversized_params(payload)
230
+ cap_kb = @configuration.max_log_size_kb
231
+ return payload if cap_kb.nil?
232
+ return payload unless payload.is_a?(Hash) && payload['request'].is_a?(Hash)
233
+ return payload unless payload['request']['params']
234
+
235
+ cap_bytes = cap_kb * 1024
236
+ return payload if JSON.generate(payload).bytesize <= cap_bytes
237
+
238
+ truncated_request = payload['request'].merge(
239
+ 'params' => '[TRUNCATED]',
240
+ 'params_truncated' => true
241
+ )
242
+ payload.merge('request' => truncated_request)
243
+ rescue StandardError
244
+ payload
245
+ end
246
+ end
247
+ end