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,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