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.
- checksums.yaml +7 -0
- data/LICENSE.md +202 -0
- data/README.md +379 -0
- data/lib/generators/iugu_logger/install/install_generator.rb +44 -0
- data/lib/generators/iugu_logger/install/templates/iugu_logger.rb.tt +52 -0
- data/lib/iugu_logger/buffer.rb +97 -0
- data/lib/iugu_logger/configuration.rb +76 -0
- data/lib/iugu_logger/job_logger.rb +171 -0
- data/lib/iugu_logger/logger.rb +247 -0
- data/lib/iugu_logger/pii.rb +291 -0
- data/lib/iugu_logger/railtie.rb +95 -0
- data/lib/iugu_logger/request_logger.rb +257 -0
- data/lib/iugu_logger/schema.rb +168 -0
- data/lib/iugu_logger/severity.rb +44 -0
- data/lib/iugu_logger/smoke_test.rb +172 -0
- data/lib/iugu_logger/tasks/iugu_logger.rake +13 -0
- data/lib/iugu_logger/tenant_context.rb +53 -0
- data/lib/iugu_logger/trace_context.rb +210 -0
- data/lib/iugu_logger/version.rb +5 -0
- data/lib/iugu_logger.rb +68 -0
- metadata +146 -0
|
@@ -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
|