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,291 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module IuguLogger
|
|
4
|
+
# PII detection and redaction module.
|
|
5
|
+
#
|
|
6
|
+
# 3-layer defense:
|
|
7
|
+
# - Layer 1 (ParamFilter): blocks values of keys whose names match a
|
|
8
|
+
# sensitive blocklist (password, secret, token, etc.) BEFORE the deep
|
|
9
|
+
# content scan
|
|
10
|
+
# - Layer 2 (Scanner): regex-based deep content redaction in all string
|
|
11
|
+
# fields, with strategy-based replacement (full_redact, last4,
|
|
12
|
+
# detect_only, preserve)
|
|
13
|
+
# - Layer 3 (Logger): emitted log payload always carries pii.scanned=true
|
|
14
|
+
# populated by Scanner — handled in Logger, not here
|
|
15
|
+
#
|
|
16
|
+
# PII patterns reuse those validated in production by core/utils/sanitizer.py
|
|
17
|
+
# (iugu-agents).
|
|
18
|
+
#
|
|
19
|
+
# Decisions applied:
|
|
20
|
+
# - ILS-002: iugu.account_id 32-hex preserved (SAFE_PATTERNS exclusion)
|
|
21
|
+
# - ILS-003: email :detect_only by default (deferred — tech debt)
|
|
22
|
+
#
|
|
23
|
+
# Spec: IUGU_LOGGING_STANDARD.md §5
|
|
24
|
+
module Pii
|
|
25
|
+
PATTERNS = {
|
|
26
|
+
cpf: /\b\d{3}\.?\d{3}\.?\d{3}-?\d{2}\b/,
|
|
27
|
+
cnpj: /\b\d{2}\.\d{3}\.\d{3}\/\d{4}-\d{2}\b/,
|
|
28
|
+
email: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/,
|
|
29
|
+
# Lookarounds (?<![\w-]) and (?![\w-]) require the phone-shaped digit
|
|
30
|
+
# group to be flanked by non-identifier chars. Without them the regex
|
|
31
|
+
# matched the middle of dense identifiers (span_id, trace_id, jids,
|
|
32
|
+
# UUIDs without hyphens) and produced false positives that broke trace
|
|
33
|
+
# correlation in production. SAFE_KEY_PATHS is the primary defense;
|
|
34
|
+
# this is defense-in-depth for arbitrary user content.
|
|
35
|
+
phone: /(?<![\w-])\(?\d{2}\)?\s?9?\d{4}-?\d{4}(?![\w-])/,
|
|
36
|
+
cc: /\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{1,7}\b/,
|
|
37
|
+
aws_key: /\bAKIA[0-9A-Z]{16}\b/,
|
|
38
|
+
bearer: /Bearer\s+[A-Za-z0-9\-._~+\/]+=*\b/i,
|
|
39
|
+
url_with_creds: /https?:\/\/[^\/\s:]+:[^\/\s@]+@\S+/
|
|
40
|
+
}.freeze
|
|
41
|
+
|
|
42
|
+
# Strings matching SAFE_PATTERNS are excluded from redaction even when
|
|
43
|
+
# they incidentally match a PII pattern. Hex identifiers (trace_id 32,
|
|
44
|
+
# span_id 16, account_id 32, UUID v4) are structural identifiers that
|
|
45
|
+
# by definition never carry PII; pre-empting them at the value level
|
|
46
|
+
# avoids the regex coincidence that any 10+ consecutive digits look
|
|
47
|
+
# like a Brazilian phone number.
|
|
48
|
+
SAFE_PATTERNS = {
|
|
49
|
+
iugu_account_id: /\A[A-Fa-f0-9]{32}\z/, # 32-hex (case-insensitive: legacy uppercase + modern lowercase)
|
|
50
|
+
otel_trace_id: /\A[a-fA-F0-9]{32}\z/, # OpenTelemetry trace_id (16 bytes hex)
|
|
51
|
+
otel_span_id: /\A[a-fA-F0-9]{16}\z/, # OpenTelemetry span_id (8 bytes hex)
|
|
52
|
+
uuid: /\A[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}\z/i # UUID v4 / v7
|
|
53
|
+
}.freeze
|
|
54
|
+
|
|
55
|
+
# Canonical schema paths whose values are skipped entirely by the scan.
|
|
56
|
+
# These fields hold structural identifiers / controlled metadata defined
|
|
57
|
+
# by IUGU_LOGGING_STANDARD §2 — never user-supplied content. Skipping
|
|
58
|
+
# them prevents false-positive PII detection on hex/UUID-shaped values
|
|
59
|
+
# AND saves CPU on the hot path.
|
|
60
|
+
#
|
|
61
|
+
# Path = dot-joined hash keys from the user_section root, e.g. a value
|
|
62
|
+
# at `payload['trace']['span_id']` has path "trace.span_id".
|
|
63
|
+
SAFE_KEY_PATHS = %w[
|
|
64
|
+
@timestamp
|
|
65
|
+
log.level
|
|
66
|
+
event.kind
|
|
67
|
+
event.action
|
|
68
|
+
service.name
|
|
69
|
+
service.version
|
|
70
|
+
service.environment
|
|
71
|
+
service.instance
|
|
72
|
+
trace.id
|
|
73
|
+
trace.span_id
|
|
74
|
+
trace.parent_id
|
|
75
|
+
request.id
|
|
76
|
+
http.status_code
|
|
77
|
+
http.duration_ms
|
|
78
|
+
].freeze
|
|
79
|
+
|
|
80
|
+
# Default redaction strategies. Override via Configuration#pii_redaction.
|
|
81
|
+
#
|
|
82
|
+
# Strategies:
|
|
83
|
+
# :full_redact → "[<TYPE>_REDACTED]"
|
|
84
|
+
# :last4 → "**** **** **** 1234" (CC only)
|
|
85
|
+
# :detect_only → unchanged content, but `detected` is recorded
|
|
86
|
+
# :preserve → neither detected nor redacted (escape hatch)
|
|
87
|
+
#
|
|
88
|
+
# Philosophy (data-completeness-first, since v0.7):
|
|
89
|
+
#
|
|
90
|
+
# Operational logs in iugu serve ops, support, fraud analysts,
|
|
91
|
+
# compliance, and ML pipelines — not just engineers. Redacting personal
|
|
92
|
+
# data at emission time breaks those downstream consumers; the legacy
|
|
93
|
+
# rails_semantic_logger output that they already rely on includes full
|
|
94
|
+
# CPF, CNPJ, phone, address, email, bank account details. We normalize
|
|
95
|
+
# that — `:detect_only` means we still RECORD that PII was found (so
|
|
96
|
+
# `pii.detected: [cpf, phone]` is queryable for audit) but we don't
|
|
97
|
+
# remove the values from the log. LGPD compliance is met via
|
|
98
|
+
# access-control on the log store and retention policies, not via
|
|
99
|
+
# redaction at the source.
|
|
100
|
+
#
|
|
101
|
+
# Things that DO stay redacted by default:
|
|
102
|
+
# - Payment card numbers (`:cc` → `:last4`) — PCI-DSS hard rule
|
|
103
|
+
# - Credentials (`aws_key`, `bearer`, `url_with_creds`) — these are
|
|
104
|
+
# never user data, only ever leak risk
|
|
105
|
+
#
|
|
106
|
+
# Override per-app: any app needing stricter redaction (e.g. external
|
|
107
|
+
# log export targets) can set `:full_redact` for the types it needs
|
|
108
|
+
# via `IuguLogger.configure { |c| c.pii_redaction = ... }`.
|
|
109
|
+
DEFAULT_STRATEGIES = {
|
|
110
|
+
cpf: :detect_only, # personal data — detected, not redacted (v0.7+)
|
|
111
|
+
cnpj: :detect_only, # legal entity — detected, not redacted (v0.7+)
|
|
112
|
+
email: :detect_only, # personal data — detected, not redacted (was always)
|
|
113
|
+
phone: :detect_only, # personal data — detected, not redacted (v0.7+)
|
|
114
|
+
cc: :last4, # PCI-DSS — last 4 only (KEPT)
|
|
115
|
+
aws_key: :full_redact, # credential — never log (KEPT)
|
|
116
|
+
bearer: :full_redact, # credential — never log (KEPT)
|
|
117
|
+
url_with_creds: :full_redact # credential — never log (KEPT)
|
|
118
|
+
}.freeze
|
|
119
|
+
|
|
120
|
+
# Layer 1: keys whose values are filtered before any scanning.
|
|
121
|
+
# Case-insensitive.
|
|
122
|
+
DEFAULT_PARAM_BLOCKLIST = %w[
|
|
123
|
+
password password_confirmation passwd
|
|
124
|
+
secret token api_key apikey
|
|
125
|
+
authorization auth bearer_token
|
|
126
|
+
credit_card cc_number ccnumber cvv cvc
|
|
127
|
+
ssn pin private_key
|
|
128
|
+
].freeze
|
|
129
|
+
|
|
130
|
+
PARAM_FILTER_PLACEHOLDER = '[FILTERED]'
|
|
131
|
+
|
|
132
|
+
# Frozen result of a Pii scan. Logger uses this to populate the canonical
|
|
133
|
+
# pii.* block of the emitted payload.
|
|
134
|
+
class Result
|
|
135
|
+
attr_reader :payload, :detected, :redacted_count
|
|
136
|
+
|
|
137
|
+
def initialize(payload:, detected:, redacted_count:)
|
|
138
|
+
@payload = payload
|
|
139
|
+
@detected = detected.uniq.sort
|
|
140
|
+
@redacted_count = redacted_count
|
|
141
|
+
freeze
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def to_pii_block
|
|
145
|
+
{
|
|
146
|
+
'scanned' => true,
|
|
147
|
+
'detected' => @detected.map(&:to_s),
|
|
148
|
+
'redacted' => @redacted_count
|
|
149
|
+
}
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Stateful per-scan orchestrator (Layer 1 + Layer 2). NOT thread-safe —
|
|
154
|
+
# create a new Scanner per log event.
|
|
155
|
+
class Scanner
|
|
156
|
+
def initialize(strategies: DEFAULT_STRATEGIES, param_blocklist: DEFAULT_PARAM_BLOCKLIST)
|
|
157
|
+
@strategies = strategies
|
|
158
|
+
@param_blocklist = param_blocklist.map { |k| k.to_s.downcase }
|
|
159
|
+
@detected = []
|
|
160
|
+
@redacted_count = 0
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Returns a Result with a sanitized deep copy of the payload + detection metadata.
|
|
164
|
+
def scan(payload)
|
|
165
|
+
sanitized = process(payload)
|
|
166
|
+
Result.new(payload: sanitized, detected: @detected, redacted_count: @redacted_count)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
private
|
|
170
|
+
|
|
171
|
+
def process(value, path = nil)
|
|
172
|
+
return value if path && SAFE_KEY_PATHS.include?(path)
|
|
173
|
+
|
|
174
|
+
case value
|
|
175
|
+
when Hash then process_hash(value, path)
|
|
176
|
+
when Array then value.map { |item| process(item, path) }
|
|
177
|
+
when String then process_string(value)
|
|
178
|
+
else value
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def process_hash(hash, parent_path = nil)
|
|
183
|
+
result = {}
|
|
184
|
+
hash.each do |key, value|
|
|
185
|
+
new_path = parent_path ? "#{parent_path}.#{key}" : key.to_s
|
|
186
|
+
result[key] = if blocked_key?(key)
|
|
187
|
+
PARAM_FILTER_PLACEHOLDER
|
|
188
|
+
else
|
|
189
|
+
process(value, new_path)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
result
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def blocked_key?(key)
|
|
196
|
+
@param_blocklist.include?(key.to_s.downcase)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def process_string(str)
|
|
200
|
+
return str if str.empty?
|
|
201
|
+
|
|
202
|
+
new_str = str
|
|
203
|
+
PATTERNS.each do |type, pattern|
|
|
204
|
+
strategy = @strategies[type] || :full_redact
|
|
205
|
+
next if strategy == :preserve # skip this type entirely
|
|
206
|
+
next unless new_str =~ pattern
|
|
207
|
+
|
|
208
|
+
new_str = apply_strategy(new_str, type, pattern, strategy)
|
|
209
|
+
end
|
|
210
|
+
new_str
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def apply_strategy(str, type, pattern, strategy)
|
|
214
|
+
any_detected = false
|
|
215
|
+
redactions = 0
|
|
216
|
+
|
|
217
|
+
new_str = str.gsub(pattern) do |match|
|
|
218
|
+
if safe_match?(match, type)
|
|
219
|
+
match
|
|
220
|
+
else
|
|
221
|
+
any_detected = true
|
|
222
|
+
replacement = redact(match, strategy, type)
|
|
223
|
+
redactions += 1 if replacement != match
|
|
224
|
+
replacement
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
@detected << type if any_detected && !@detected.include?(type)
|
|
229
|
+
@redacted_count += redactions
|
|
230
|
+
new_str
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# A match is "safe" (skip detection + redaction) when:
|
|
234
|
+
# 1. type == :cc — Luhn alone decides. A Luhn-valid number is a real
|
|
235
|
+
# PAN and is NEVER safe (always redacted), even if its digit shape
|
|
236
|
+
# collides with a SAFE_PATTERN: a bare 16-digit card is 16 chars of
|
|
237
|
+
# valid hex and otherwise matches the 16-hex otel_span_id pattern,
|
|
238
|
+
# which previously shielded the full PAN from redaction (PCI-DSS
|
|
239
|
+
# leak). A Luhn-INVALID cc-shaped match is card-like but actually a
|
|
240
|
+
# CPF (11d), CNPJ (14d), or generic id → safe (v0.8.1 fix for
|
|
241
|
+
# "54565372000194" being redacted as a card).
|
|
242
|
+
# 2. Any other type — it matches one of SAFE_PATTERNS (account_id
|
|
243
|
+
# 32-hex, OTel trace/span ids, UUID). Real span_ids live at
|
|
244
|
+
# SAFE_KEY_PATHS (trace.span_id) and are skipped by path before
|
|
245
|
+
# content scan, so this is only a content-level guard.
|
|
246
|
+
def safe_match?(match, type)
|
|
247
|
+
return !luhn_valid?(match) if type == :cc
|
|
248
|
+
|
|
249
|
+
SAFE_PATTERNS.values.any? { |sp| match.match?(sp) }
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Luhn / mod-10 checksum — the standard validator real credit card
|
|
253
|
+
# numbers pass and most fiscal codes / arbitrary digit strings
|
|
254
|
+
# don't. Stops the regex (which only validates digit count, 13-19)
|
|
255
|
+
# from over-matching numeric identifiers.
|
|
256
|
+
def luhn_valid?(value)
|
|
257
|
+
digits = value.to_s.scan(/\d/).map(&:to_i)
|
|
258
|
+
return false if digits.size < 13 || digits.size > 19
|
|
259
|
+
|
|
260
|
+
sum = 0
|
|
261
|
+
digits.reverse.each_with_index do |d, i|
|
|
262
|
+
d *= 2 if i.odd?
|
|
263
|
+
d -= 9 if d > 9
|
|
264
|
+
sum += d
|
|
265
|
+
end
|
|
266
|
+
(sum % 10).zero?
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def redact(match, strategy, type)
|
|
270
|
+
case strategy
|
|
271
|
+
when :full_redact then redaction_marker(type)
|
|
272
|
+
when :last4 then mask_last4(match)
|
|
273
|
+
when :detect_only then match
|
|
274
|
+
when :preserve then match
|
|
275
|
+
else redaction_marker(type)
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def redaction_marker(type)
|
|
280
|
+
"[#{type.to_s.upcase}_REDACTED]"
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def mask_last4(match)
|
|
284
|
+
digits = match.gsub(/\D/, '')
|
|
285
|
+
return match if digits.length < 4
|
|
286
|
+
|
|
287
|
+
"**** **** **** #{digits[-4, 4]}"
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Loaded conditionally from lib/iugu_logger.rb only when ::Rails::Railtie is
|
|
4
|
+
# defined (i.e. Rails is on the load path). Apps without Rails ignore this file.
|
|
5
|
+
|
|
6
|
+
module IuguLogger
|
|
7
|
+
# Rails Railtie — wires the SDK into a Rails application with zero config
|
|
8
|
+
# for the common case:
|
|
9
|
+
#
|
|
10
|
+
# * inserts IuguLogger::RequestLogger after ActionDispatch::DebugExceptions
|
|
11
|
+
# in the middleware stack
|
|
12
|
+
# * registers IuguLogger::JobLogger::Sidekiq as a server-side middleware
|
|
13
|
+
# when Sidekiq is loaded
|
|
14
|
+
# * derives a sensible default service_name from the app class
|
|
15
|
+
# (Rails.application.class.name → snake_case, dash-prefixed app name)
|
|
16
|
+
#
|
|
17
|
+
# Rails.logger is intentionally NOT replaced in this release. Callers
|
|
18
|
+
# migrate to IuguLogger.event(...) for schema-conforming events; existing
|
|
19
|
+
# Rails.logger.X calls keep working unchanged. A future release may
|
|
20
|
+
# introduce an opt-in adapter to route Rails.logger through the buffer.
|
|
21
|
+
#
|
|
22
|
+
# Spec: IUGU_LOGGING_STANDARD.md §6 (Railtie auto-config)
|
|
23
|
+
class Railtie < ::Rails::Railtie
|
|
24
|
+
config.before_configuration do |app|
|
|
25
|
+
next if IuguLogger.configuration.service_name
|
|
26
|
+
|
|
27
|
+
IuguLogger.configuration.service_name = derive_service_name(app)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
initializer 'iugu_logger.insert_request_middleware', after: :load_config_initializers do |app|
|
|
31
|
+
app.middleware.insert_after(
|
|
32
|
+
ActionDispatch::DebugExceptions,
|
|
33
|
+
IuguLogger::RequestLogger
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
initializer 'iugu_logger.register_sidekiq_middleware', after: :load_config_initializers do
|
|
38
|
+
next unless defined?(::Sidekiq) && ::Sidekiq.respond_to?(:configure_server)
|
|
39
|
+
|
|
40
|
+
::Sidekiq.configure_server do |config|
|
|
41
|
+
config.server_middleware do |chain|
|
|
42
|
+
chain.add IuguLogger::JobLogger::Sidekiq
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Captures Rails view/db runtime from the canonical ActiveSupport
|
|
48
|
+
# notification fired at the end of every controller action. For
|
|
49
|
+
# ActionController::API
|
|
50
|
+
# the legacy `action_dispatch.view_runtime` / `db_runtime` env keys
|
|
51
|
+
# aren't populated by Rails — but the `process_action.action_controller`
|
|
52
|
+
# notification payload always has both. We stash them in Buffer; the
|
|
53
|
+
# RequestLogger middleware reads from there when building the
|
|
54
|
+
# `rails.*` block.
|
|
55
|
+
initializer 'iugu_logger.subscribe_action_controller_runtime', after: :load_config_initializers do
|
|
56
|
+
next unless defined?(::ActiveSupport::Notifications)
|
|
57
|
+
|
|
58
|
+
::ActiveSupport::Notifications.subscribe('process_action.action_controller') do |*args|
|
|
59
|
+
# `rescue` inside a do/end block needs explicit `begin/end` on
|
|
60
|
+
# Ruby 2.4 (the bare form was only added in 2.5). We support
|
|
61
|
+
# 2.4 as first-class per spec §13.7.
|
|
62
|
+
begin
|
|
63
|
+
# Rails 6+: ActiveSupport::Notifications::Event is the canonical type;
|
|
64
|
+
# the args splat works across the 5.0..8.x range we support.
|
|
65
|
+
payload = args.last.is_a?(Hash) ? args.last : args.last.payload
|
|
66
|
+
IuguLogger::Buffer.current.set_rails_runtime(
|
|
67
|
+
view_ms: payload[:view_runtime],
|
|
68
|
+
db_ms: payload[:db_runtime]
|
|
69
|
+
)
|
|
70
|
+
rescue StandardError
|
|
71
|
+
# Never let instrumentation break the request.
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
rake_tasks do
|
|
77
|
+
load File.expand_path('tasks/iugu_logger.rake', __dir__)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
class << self
|
|
81
|
+
# Best-effort service name from `MyApp::Application` → "my-app".
|
|
82
|
+
# Falls back to "rails-app" when the app class is unusual.
|
|
83
|
+
def derive_service_name(app)
|
|
84
|
+
klass = app.class.name.to_s
|
|
85
|
+
return 'rails-app' if klass.empty?
|
|
86
|
+
|
|
87
|
+
first = klass.split('::').first
|
|
88
|
+
first.gsub(/([A-Z]+)([A-Z][a-z])/, '\\1_\\2')
|
|
89
|
+
.gsub(/([a-z\d])([A-Z])/, '\\1_\\2')
|
|
90
|
+
.downcase
|
|
91
|
+
.tr('_', '-')
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module IuguLogger
|
|
4
|
+
# Rack middleware — emits ONE consolidated log per HTTP request:
|
|
5
|
+
# event.action = http.request.completed (or http.request.failed on raise)
|
|
6
|
+
#
|
|
7
|
+
# Behavior:
|
|
8
|
+
# 1. On request start: resets thread-local Buffer, populates context with
|
|
9
|
+
# trace + iugu (auto-extracted from env via TraceContext / TenantContext).
|
|
10
|
+
# 2. App / controllers call IuguLogger::Buffer.current.push(...) for in-app
|
|
11
|
+
# logs throughout the request. (Once Railtie lands in v0.5,
|
|
12
|
+
# Rails.logger.X also routes here.)
|
|
13
|
+
# 3. On request end: drains the buffer, emits a single event including
|
|
14
|
+
# `logs: [...]` (the buffered entries) plus http status and duration.
|
|
15
|
+
# 4. Always resets the buffer afterward.
|
|
16
|
+
#
|
|
17
|
+
# Failures: when the downstream app raises, emits `http.request.failed` with
|
|
18
|
+
# the partial buffer + exception, then re-raises so other middleware sees
|
|
19
|
+
# the original error.
|
|
20
|
+
#
|
|
21
|
+
# Insertion: typically right after Rails' DebugExceptions and before
|
|
22
|
+
# ActionDispatch's logger so we capture exceptions but bypass Rails' own
|
|
23
|
+
# request logging.
|
|
24
|
+
#
|
|
25
|
+
# Spec: IUGU_LOGGING_STANDARD.md §6 (RequestLogger middleware)
|
|
26
|
+
class RequestLogger
|
|
27
|
+
def initialize(app)
|
|
28
|
+
@app = app
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def call(env)
|
|
32
|
+
Buffer.reset!
|
|
33
|
+
buffer = Buffer.current
|
|
34
|
+
|
|
35
|
+
trace = TraceContext.extract(rack_env: env)
|
|
36
|
+
req = build_request_block(env)
|
|
37
|
+
|
|
38
|
+
# Tenant is best-effort here (covers Rack middleware upstream that may
|
|
39
|
+
# have populated env['iugu.current_*']). The canonical timing is after
|
|
40
|
+
# @app.call — host apps typically populate tenant in a controller
|
|
41
|
+
# before_action, which only runs once Rails has dispatched the action.
|
|
42
|
+
tenant = TenantContext.from_rack(env)
|
|
43
|
+
buffer.add_context(trace: trace, iugu: tenant, request: req)
|
|
44
|
+
|
|
45
|
+
start = monotonic_now
|
|
46
|
+
status, headers, body = @app.call(env)
|
|
47
|
+
duration_ms = elapsed_ms(start)
|
|
48
|
+
|
|
49
|
+
# Re-read tenant: controllers may have populated env['iugu.current_*']
|
|
50
|
+
# via before_actions during @app.call. The earlier extraction would
|
|
51
|
+
# have missed those — see spec §6.3 (RequestLogger / TenantContext).
|
|
52
|
+
tenant = TenantContext.from_rack(env)
|
|
53
|
+
|
|
54
|
+
# Enrich request with Rails dispatch info populated during @app.call
|
|
55
|
+
# (params, format). Pre-call build_request_block couldn't see these.
|
|
56
|
+
req = enrich_request_post_dispatch(req, env)
|
|
57
|
+
|
|
58
|
+
emit_completed(req: req, trace: trace, tenant: tenant,
|
|
59
|
+
status: status, duration_ms: duration_ms,
|
|
60
|
+
rails: extract_rails_info(env),
|
|
61
|
+
logs: buffer.drain)
|
|
62
|
+
|
|
63
|
+
[status, headers, body]
|
|
64
|
+
rescue StandardError => e
|
|
65
|
+
duration_ms = elapsed_ms(start)
|
|
66
|
+
# Same re-read on failure path: if the controller populated tenant
|
|
67
|
+
# before raising, we should still report it.
|
|
68
|
+
tenant = TenantContext.from_rack(env)
|
|
69
|
+
req = enrich_request_post_dispatch(req, env)
|
|
70
|
+
emit_failed(req: req, trace: trace, tenant: tenant, error: e,
|
|
71
|
+
duration_ms: duration_ms,
|
|
72
|
+
rails: extract_rails_info(env),
|
|
73
|
+
logs: buffer&.drain || [])
|
|
74
|
+
raise
|
|
75
|
+
ensure
|
|
76
|
+
Buffer.reset!
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def build_request_block(env)
|
|
82
|
+
{
|
|
83
|
+
'id' => env['action_dispatch.request_id'] || env['HTTP_X_REQUEST_ID'],
|
|
84
|
+
'method' => env['REQUEST_METHOD'],
|
|
85
|
+
'path' => env['PATH_INFO'],
|
|
86
|
+
'source' => 'api'
|
|
87
|
+
}.reject { |_, v| v.nil? || (v.respond_to?(:empty?) && v.empty?) }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Adds `params` and `format` from the Rack env once the Rails dispatcher
|
|
91
|
+
# has had a chance to populate them during @app.call. Both keys are
|
|
92
|
+
# absent for pure Rack/Sinatra apps — the block stays unaltered then.
|
|
93
|
+
#
|
|
94
|
+
# Rails populates `action_dispatch.request.parameters` with
|
|
95
|
+
# `filter_parameters` already applied (passwords, credit cards, etc.
|
|
96
|
+
# arrive as "[FILTERED]"), so we don't need to re-filter here. PII
|
|
97
|
+
# detection (CPF, CNPJ, phone, email) is then handled by Pii::Scanner
|
|
98
|
+
# downstream — with v0.7's :detect_only defaults the content stays
|
|
99
|
+
# readable for ops/support/fraud while pii.detected is still populated.
|
|
100
|
+
def enrich_request_post_dispatch(req, env)
|
|
101
|
+
params = env['action_dispatch.request.parameters']
|
|
102
|
+
format = extract_request_format(env)
|
|
103
|
+
|
|
104
|
+
req = req.merge('params' => params) if params
|
|
105
|
+
req = req.merge('format' => format) if format
|
|
106
|
+
req
|
|
107
|
+
rescue StandardError
|
|
108
|
+
# Never let enrichment break the response — return what we had.
|
|
109
|
+
req
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Resolves the request format with a robust fallback chain:
|
|
113
|
+
# 1. Rails Mime#symbol (e.g. :json, :html) — empty/nil symbols are
|
|
114
|
+
# discarded so we don't emit an empty string;
|
|
115
|
+
# 2. Rails Mime#to_s (e.g. "application/json") when no symbol exists;
|
|
116
|
+
# 3. HTTP_ACCEPT header's first segment;
|
|
117
|
+
# 4. nil — block is omitted by `compact`/`nilify_empty` downstream.
|
|
118
|
+
def extract_request_format(env)
|
|
119
|
+
formats = env['action_dispatch.request.formats']
|
|
120
|
+
if formats.respond_to?(:first) && formats.first
|
|
121
|
+
mime = formats.first
|
|
122
|
+
sym = mime.respond_to?(:symbol) ? mime.symbol : nil
|
|
123
|
+
return sym.to_s if sym && !sym.to_s.empty?
|
|
124
|
+
|
|
125
|
+
str = mime.to_s
|
|
126
|
+
return str unless str.empty?
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
accept = env['HTTP_ACCEPT']&.split(',')&.first&.strip
|
|
130
|
+
accept if accept && !accept.empty?
|
|
131
|
+
rescue StandardError
|
|
132
|
+
nil
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Builds the `rails:` block when Rails dispatcher info is available.
|
|
136
|
+
# Sources, in order of preference:
|
|
137
|
+
# - env['action_controller.instance'] → controller / action
|
|
138
|
+
# - env['action_dispatch.view_runtime'] → view_runtime_ms (full-stack Rails)
|
|
139
|
+
# - env['action_dispatch.db_runtime'] → db_runtime_ms (full-stack Rails)
|
|
140
|
+
# - Buffer.current.rails_runtime → view_ms / db_ms captured by
|
|
141
|
+
# process_action subscriber
|
|
142
|
+
# (ActionController::API path)
|
|
143
|
+
def extract_rails_info(env)
|
|
144
|
+
controller = env['action_controller.instance']
|
|
145
|
+
|
|
146
|
+
view_ms = env['action_dispatch.view_runtime']
|
|
147
|
+
db_ms = env['action_dispatch.db_runtime']
|
|
148
|
+
|
|
149
|
+
runtime = Buffer.current.rails_runtime
|
|
150
|
+
view_ms ||= runtime['view_ms']
|
|
151
|
+
db_ms ||= runtime['db_ms']
|
|
152
|
+
|
|
153
|
+
info = {}
|
|
154
|
+
info['controller'] = controller.class.name if controller
|
|
155
|
+
info['action'] = controller.action_name if controller&.respond_to?(:action_name)
|
|
156
|
+
info['view_runtime_ms'] = view_ms.to_f.round(2) if view_ms
|
|
157
|
+
info['db_runtime_ms'] = db_ms.to_f.round(2) if db_ms
|
|
158
|
+
info.empty? ? nil : info
|
|
159
|
+
rescue StandardError
|
|
160
|
+
nil
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def emit_completed(req:, trace:, tenant:, status:, duration_ms:, rails:, logs:)
|
|
164
|
+
IuguLogger.event(
|
|
165
|
+
'http.request.completed',
|
|
166
|
+
severity: severity_for(status),
|
|
167
|
+
message: format_completed_message(req, status, duration_ms),
|
|
168
|
+
request: req,
|
|
169
|
+
http: {
|
|
170
|
+
'status_code' => status,
|
|
171
|
+
'status_message' => http_status_message(status),
|
|
172
|
+
'duration_ms' => duration_ms
|
|
173
|
+
}.compact,
|
|
174
|
+
trace: nilify_empty(trace),
|
|
175
|
+
iugu: nilify_empty(tenant),
|
|
176
|
+
rails: nilify_empty(rails),
|
|
177
|
+
logs: logs.empty? ? nil : logs
|
|
178
|
+
)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def emit_failed(req:, trace:, tenant:, error:, duration_ms:, rails:, logs:)
|
|
182
|
+
IuguLogger.event(
|
|
183
|
+
'http.request.failed',
|
|
184
|
+
severity: :error,
|
|
185
|
+
message: format_failed_message(req, error, duration_ms),
|
|
186
|
+
request: req,
|
|
187
|
+
http: { 'duration_ms' => duration_ms },
|
|
188
|
+
trace: nilify_empty(trace),
|
|
189
|
+
iugu: nilify_empty(tenant),
|
|
190
|
+
rails: nilify_empty(rails),
|
|
191
|
+
error: {
|
|
192
|
+
'type' => error.class.name,
|
|
193
|
+
'message' => error.message.to_s,
|
|
194
|
+
'fingerprint' => fingerprint(error)
|
|
195
|
+
},
|
|
196
|
+
logs: logs.empty? ? nil : logs
|
|
197
|
+
)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# HTTP status message ("OK", "Not Found", "Bad Request", etc) — matches
|
|
201
|
+
# the legacy rails_semantic_logger `payload.status_message` field, so
|
|
202
|
+
# operators / support / fraud teams keep the same readable signal in
|
|
203
|
+
# the canonical event.
|
|
204
|
+
def http_status_message(status)
|
|
205
|
+
require 'rack/utils'
|
|
206
|
+
Rack::Utils::HTTP_STATUS_CODES[status.to_i]
|
|
207
|
+
rescue StandardError
|
|
208
|
+
nil
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Human-readable summary mirroring Apache "Combined Log" intuition:
|
|
212
|
+
# `<METHOD> <PATH> <STATUS> [<duration_ms>ms]`. The canonical
|
|
213
|
+
# `event.action` lives in attributes for queries; this is for stream
|
|
214
|
+
# readability (humans + AI scanning a log viewer).
|
|
215
|
+
def format_completed_message(req, status, duration_ms)
|
|
216
|
+
[req['method'], req['path'], status, "[#{duration_ms}ms]"].compact.join(' ')
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def format_failed_message(req, error, duration_ms)
|
|
220
|
+
[req['method'], req['path'], 'failed', "[#{duration_ms}ms]", error.class.name].compact.join(' ')
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def severity_for(status_code)
|
|
224
|
+
return :info if status_code.to_i < 400
|
|
225
|
+
return :warn if status_code.to_i < 500
|
|
226
|
+
|
|
227
|
+
:error
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def nilify_empty(value)
|
|
231
|
+
return nil if value.nil?
|
|
232
|
+
return nil if value.respond_to?(:empty?) && value.empty?
|
|
233
|
+
|
|
234
|
+
value
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def monotonic_now
|
|
238
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def elapsed_ms(start)
|
|
242
|
+
return 0 if start.nil?
|
|
243
|
+
|
|
244
|
+
((monotonic_now - start) * 1000).round
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def fingerprint(error)
|
|
248
|
+
require 'digest'
|
|
249
|
+
Digest::SHA256.hexdigest("#{error.class.name}|#{normalized_message(error.message)}")
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def normalized_message(msg)
|
|
253
|
+
# Strip variable parts (numbers, hex blobs) so similar errors group
|
|
254
|
+
msg.to_s.gsub(/\d+/, 'N').gsub(/[a-f0-9]{16,}/i, 'HEX')
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|