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,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module IuguLogger
|
|
6
|
+
# event.action registry validator.
|
|
7
|
+
#
|
|
8
|
+
# Loads a compiled event.action registry (`dist/registry.json`) and validates
|
|
9
|
+
# each `event.action` emitted against it.
|
|
10
|
+
#
|
|
11
|
+
# Three modes:
|
|
12
|
+
# :strict — raise IuguLogger::UnknownEventAction / SchemaViolation
|
|
13
|
+
# :warn — annotate the emitted log with `labels.schema_warning` and proceed
|
|
14
|
+
# :off — disable validation (default — backward compatible)
|
|
15
|
+
#
|
|
16
|
+
# When validation passes and the registry definition declares an
|
|
17
|
+
# `event_kind` (e.g. `pix.out.executed` is audit-class), the validator
|
|
18
|
+
# surfaces it so the Logger can override the caller's default.
|
|
19
|
+
#
|
|
20
|
+
# Spec: IUGU_LOGGING_STANDARD.md §4
|
|
21
|
+
module Schema
|
|
22
|
+
STRICT = :strict
|
|
23
|
+
WARN = :warn
|
|
24
|
+
OFF = :off
|
|
25
|
+
|
|
26
|
+
DEFAULT_MODE = OFF
|
|
27
|
+
VALID_MODES = [STRICT, WARN, OFF].freeze
|
|
28
|
+
|
|
29
|
+
SUGGESTION_LIMIT = 3
|
|
30
|
+
SUGGESTION_MAX_DISTANCE = 6 # cap, otherwise unrelated names show up
|
|
31
|
+
|
|
32
|
+
# Loads a registry.json from a path on disk. Errors fail loud — registry
|
|
33
|
+
# loading is dev-time configuration, not runtime.
|
|
34
|
+
def self.load_from_file(path)
|
|
35
|
+
JSON.parse(File.read(path))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Frozen result of a single validate call.
|
|
39
|
+
# Plain class (not Struct keyword_init: which only landed in Ruby 2.5).
|
|
40
|
+
class Result
|
|
41
|
+
attr_reader :status, :event_kind, :missing_fields, :suggestions
|
|
42
|
+
|
|
43
|
+
def initialize(status:, event_kind: nil, missing_fields: [], suggestions: [])
|
|
44
|
+
@status = status
|
|
45
|
+
@event_kind = event_kind
|
|
46
|
+
@missing_fields = missing_fields
|
|
47
|
+
@suggestions = suggestions
|
|
48
|
+
freeze
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def ok?; status == :ok; end
|
|
52
|
+
def off?; status == :off; end
|
|
53
|
+
def unknown_action?; status == :unknown_action; end
|
|
54
|
+
def missing_required?; status == :missing_required; end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
class Validator
|
|
58
|
+
attr_reader :mode
|
|
59
|
+
|
|
60
|
+
def initialize(registry: nil, mode: DEFAULT_MODE)
|
|
61
|
+
unless VALID_MODES.include?(mode)
|
|
62
|
+
raise ConfigurationError,
|
|
63
|
+
"unknown event_action_validator mode: #{mode.inspect} (expected :strict, :warn, :off)"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
@mode = mode
|
|
67
|
+
@registry = normalize_registry(registry)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def known?(action)
|
|
71
|
+
return false if @registry.nil?
|
|
72
|
+
|
|
73
|
+
@registry['events'].key?(action.to_s)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def event_definition(action)
|
|
77
|
+
return nil if @registry.nil?
|
|
78
|
+
|
|
79
|
+
@registry['events'][action.to_s]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def validate(action, payload)
|
|
83
|
+
return Result.new(status: :off) if @mode == OFF || @registry.nil?
|
|
84
|
+
|
|
85
|
+
defn = event_definition(action)
|
|
86
|
+
return Result.new(status: :unknown_action, suggestions: suggestions_for(action)) if defn.nil?
|
|
87
|
+
|
|
88
|
+
missing = (defn['required_fields'] || []).reject { |path| field_present?(payload, path) }
|
|
89
|
+
return Result.new(status: :missing_required, missing_fields: missing) unless missing.empty?
|
|
90
|
+
|
|
91
|
+
Result.new(status: :ok, event_kind: defn['event_kind'])
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Levenshtein-based typo suggestions for unknown actions.
|
|
95
|
+
def suggestions_for(action)
|
|
96
|
+
return [] if @registry.nil?
|
|
97
|
+
|
|
98
|
+
target = action.to_s
|
|
99
|
+
@registry['events'].keys
|
|
100
|
+
.map { |a| [a, levenshtein(target, a)] }
|
|
101
|
+
.reject { |(_, dist)| dist > SUGGESTION_MAX_DISTANCE }
|
|
102
|
+
.sort_by(&:last)
|
|
103
|
+
.first(SUGGESTION_LIMIT)
|
|
104
|
+
.map(&:first)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def normalize_registry(registry)
|
|
110
|
+
return nil if registry.nil?
|
|
111
|
+
return registry if registry.is_a?(Hash) && registry['events'].is_a?(Hash)
|
|
112
|
+
|
|
113
|
+
raise ConfigurationError, 'event_action_registry must be a Hash with an "events" key'
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Walks a dot-namespaced path inside a payload Hash. Returns true if the
|
|
117
|
+
# leaf is present and non-empty. Both string and symbol keys accepted.
|
|
118
|
+
def field_present?(payload, dotted_path)
|
|
119
|
+
parts = dotted_path.to_s.split('.')
|
|
120
|
+
current = payload
|
|
121
|
+
|
|
122
|
+
parts.each do |part|
|
|
123
|
+
return false unless current.is_a?(Hash)
|
|
124
|
+
|
|
125
|
+
if current.key?(part)
|
|
126
|
+
current = current[part]
|
|
127
|
+
elsif current.key?(part.to_sym)
|
|
128
|
+
current = current[part.to_sym]
|
|
129
|
+
else
|
|
130
|
+
return false
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
return false if current.nil?
|
|
135
|
+
return false if current.respond_to?(:empty?) && current.empty?
|
|
136
|
+
|
|
137
|
+
true
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Standard iterative Levenshtein, stdlib only — fine for registries with
|
|
141
|
+
# ≤500 events (linear scan per validate call is acceptable; this only
|
|
142
|
+
# runs on the slow path of unknown actions).
|
|
143
|
+
def levenshtein(a, b)
|
|
144
|
+
return b.length if a.empty?
|
|
145
|
+
return a.length if b.empty?
|
|
146
|
+
|
|
147
|
+
m = a.length
|
|
148
|
+
n = b.length
|
|
149
|
+
prev = (0..n).to_a
|
|
150
|
+
|
|
151
|
+
(1..m).each do |i|
|
|
152
|
+
curr = [i] + Array.new(n, 0)
|
|
153
|
+
(1..n).each do |j|
|
|
154
|
+
cost = a[i - 1] == b[j - 1] ? 0 : 1
|
|
155
|
+
curr[j] = [
|
|
156
|
+
curr[j - 1] + 1, # insertion
|
|
157
|
+
prev[j] + 1, # deletion
|
|
158
|
+
prev[j - 1] + cost # substitution
|
|
159
|
+
].min
|
|
160
|
+
end
|
|
161
|
+
prev = curr
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
prev[n]
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module IuguLogger
|
|
4
|
+
# Custom severity levels — includes :note (between :info and :warn) for
|
|
5
|
+
# business-critical events that should NOT be filtered as framework noise.
|
|
6
|
+
#
|
|
7
|
+
# Spec: IUGU_LOGGING_STANDARD.md §2.1 (custom :note severity pattern)
|
|
8
|
+
module Severity
|
|
9
|
+
TRACE = 0
|
|
10
|
+
DEBUG = 1
|
|
11
|
+
INFO = 2
|
|
12
|
+
NOTE = 3
|
|
13
|
+
WARN = 4
|
|
14
|
+
ERROR = 5
|
|
15
|
+
FATAL = 6
|
|
16
|
+
|
|
17
|
+
NAMES = {
|
|
18
|
+
TRACE => 'trace',
|
|
19
|
+
DEBUG => 'debug',
|
|
20
|
+
INFO => 'info',
|
|
21
|
+
NOTE => 'note',
|
|
22
|
+
WARN => 'warn',
|
|
23
|
+
ERROR => 'error',
|
|
24
|
+
FATAL => 'fatal'
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
BY_NAME = NAMES.each_with_object({}) { |(num, name), h| h[name.to_sym] = num }.freeze
|
|
28
|
+
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
# Integer severity for the given name (string or symbol). nil if unknown.
|
|
32
|
+
def from_name(name)
|
|
33
|
+
BY_NAME[name.to_sym]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def name_for(level)
|
|
37
|
+
NAMES[level]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def valid?(name)
|
|
41
|
+
BY_NAME.key?(name.to_sym)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'stringio'
|
|
5
|
+
|
|
6
|
+
module IuguLogger
|
|
7
|
+
# Self-contained smoke test for the SDK. Verifies the consuming app's
|
|
8
|
+
# configuration is valid AND that emit / PII redaction / canonical
|
|
9
|
+
# schema work end-to-end. Used by:
|
|
10
|
+
#
|
|
11
|
+
# bundle exec rake iugu_logger:smoke
|
|
12
|
+
#
|
|
13
|
+
# Output is human-readable and the run returns true on success / false on
|
|
14
|
+
# any failure. Exit code is set by the rake task wrapper.
|
|
15
|
+
module SmokeTest
|
|
16
|
+
CHECKS = %i[
|
|
17
|
+
sdk_loaded
|
|
18
|
+
configuration_present
|
|
19
|
+
basic_emit
|
|
20
|
+
pii_detection
|
|
21
|
+
account_id_safe_pattern
|
|
22
|
+
single_line_json
|
|
23
|
+
].freeze
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
def run(io: $stdout)
|
|
28
|
+
io.puts "iugu_logger #{IuguLogger::VERSION} — smoke test"
|
|
29
|
+
io.puts '-' * 56
|
|
30
|
+
|
|
31
|
+
failures = []
|
|
32
|
+
|
|
33
|
+
CHECKS.each do |name|
|
|
34
|
+
result = send("check_#{name}")
|
|
35
|
+
if result == :ok
|
|
36
|
+
io.puts " ✓ #{name}"
|
|
37
|
+
else
|
|
38
|
+
io.puts " ✗ #{name}: #{result}"
|
|
39
|
+
failures << name
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
io.puts '-' * 56
|
|
44
|
+
|
|
45
|
+
if failures.empty?
|
|
46
|
+
io.puts 'OK — all checks passed.'
|
|
47
|
+
true
|
|
48
|
+
else
|
|
49
|
+
io.puts "FAIL — #{failures.size} check(s) failed: #{failures.join(', ')}"
|
|
50
|
+
false
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# ─── checks ────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
def check_sdk_loaded
|
|
57
|
+
missing = %i[Logger Pii Configuration Severity Buffer].reject do |const|
|
|
58
|
+
IuguLogger.const_defined?(const)
|
|
59
|
+
end
|
|
60
|
+
return :ok if missing.empty?
|
|
61
|
+
|
|
62
|
+
"missing IuguLogger::#{missing.join(', IuguLogger::')}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def check_configuration_present
|
|
66
|
+
cfg = IuguLogger.configuration
|
|
67
|
+
return 'service_name is blank' if blank?(cfg.service_name)
|
|
68
|
+
return 'service_version is blank' if blank?(cfg.service_version)
|
|
69
|
+
return 'service_environment is blank' if blank?(cfg.service_environment)
|
|
70
|
+
|
|
71
|
+
:ok
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def check_basic_emit
|
|
75
|
+
payload = capture_emit do |logger|
|
|
76
|
+
logger.event('iugu_logger.smoke.ok', message: 'smoke')
|
|
77
|
+
end
|
|
78
|
+
return 'no payload emitted' if payload.nil?
|
|
79
|
+
return "missing @timestamp" if payload['@timestamp'].to_s.empty?
|
|
80
|
+
return "wrong event.action: #{payload['event.action'].inspect}" if payload['event.action'] != 'iugu_logger.smoke.ok'
|
|
81
|
+
return 'pii.scanned should be true' unless payload.dig('pii', 'scanned') == true
|
|
82
|
+
|
|
83
|
+
:ok
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# v0.7+ default strategies are :detect_only for personal data. This
|
|
87
|
+
# check validates that CPF is DETECTED (so audit + tagging work) but
|
|
88
|
+
# the content is preserved (operators / fraud analysts / support need
|
|
89
|
+
# the raw value to do their jobs). For apps that need stricter
|
|
90
|
+
# redaction, override `c.pii_redaction = ...` in the initializer.
|
|
91
|
+
def check_pii_detection
|
|
92
|
+
payload = capture_emit do |logger|
|
|
93
|
+
logger.event('iugu_logger.smoke.pii', message: 'smoke for CPF 123.456.789-09')
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
detected = payload.dig('pii', 'detected') || []
|
|
97
|
+
return "pii.detected missing 'cpf' (#{detected.inspect})" unless detected.include?('cpf')
|
|
98
|
+
unless payload['message'].to_s.include?('123.456.789-09')
|
|
99
|
+
return 'CPF was modified in message (expected :detect_only default)'
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
:ok
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def check_account_id_safe_pattern
|
|
106
|
+
account_id = 'EB450085C67C482BA652988813DAB1A5'
|
|
107
|
+
payload = capture_emit do |logger|
|
|
108
|
+
logger.event('iugu_logger.smoke.account_id',
|
|
109
|
+
iugu: { account_id: account_id },
|
|
110
|
+
message: "smoke for account #{account_id}")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
unless payload.dig('iugu', 'account_id') == account_id
|
|
114
|
+
return 'iugu.account_id 32-hex was modified (should be SAFE_PATTERN, ILS-002)'
|
|
115
|
+
end
|
|
116
|
+
unless payload['message'].to_s.include?(account_id)
|
|
117
|
+
return 'account_id was redacted from message (should be SAFE_PATTERN, ILS-002)'
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
:ok
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def check_single_line_json
|
|
124
|
+
io = StringIO.new
|
|
125
|
+
with_isolated_logger(io: io, format: :json) do |logger|
|
|
126
|
+
logger.event('iugu_logger.smoke.json_line', message: 'one-liner test')
|
|
127
|
+
end
|
|
128
|
+
raw = io.string
|
|
129
|
+
lines = raw.lines
|
|
130
|
+
return "expected 1 line, got #{lines.size}" if lines.size != 1
|
|
131
|
+
return 'output should end with newline' unless raw.end_with?("\n")
|
|
132
|
+
|
|
133
|
+
JSON.parse(raw.strip)
|
|
134
|
+
:ok
|
|
135
|
+
rescue JSON::ParserError => e
|
|
136
|
+
"JSON parse failed: #{e.message}"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# ─── helpers ───────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
def capture_emit(&block)
|
|
142
|
+
io = StringIO.new
|
|
143
|
+
with_isolated_logger(io: io, format: :json, &block)
|
|
144
|
+
raw = io.string.strip
|
|
145
|
+
return nil if raw.empty?
|
|
146
|
+
|
|
147
|
+
JSON.parse(raw)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Builds an isolated Logger that mirrors the host app's configuration
|
|
151
|
+
# but writes to `io` and forces JSON format. The host app's logger and
|
|
152
|
+
# configuration are left untouched.
|
|
153
|
+
def with_isolated_logger(io:, format: :json)
|
|
154
|
+
base = IuguLogger.configuration
|
|
155
|
+
|
|
156
|
+
cfg = IuguLogger::Configuration.new
|
|
157
|
+
cfg.service_name = base.service_name || 'iugu-logger-smoke'
|
|
158
|
+
cfg.service_version = base.service_version || IuguLogger::VERSION
|
|
159
|
+
cfg.service_environment = base.service_environment || 'test'
|
|
160
|
+
cfg.format = format
|
|
161
|
+
cfg.output = io
|
|
162
|
+
cfg.pii_redaction = base.pii_redaction
|
|
163
|
+
cfg.pii_param_blocklist = base.pii_param_blocklist
|
|
164
|
+
|
|
165
|
+
yield IuguLogger::Logger.new(cfg)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def blank?(value)
|
|
169
|
+
value.nil? || (value.respond_to?(:empty?) && value.empty?)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Rake tasks loaded by the Railtie when iugu_logger boots inside Rails.
|
|
4
|
+
# Standalone Ruby apps can `require 'iugu_logger/tasks/iugu_logger.rake'`
|
|
5
|
+
# from their own Rakefile to expose the same tasks.
|
|
6
|
+
|
|
7
|
+
namespace :iugu_logger do
|
|
8
|
+
desc 'Smoke-test the local iugu_logger setup (config + emit + PII + canonical schema).'
|
|
9
|
+
task smoke: :environment do
|
|
10
|
+
require 'iugu_logger/smoke_test'
|
|
11
|
+
abort('iugu_logger smoke test failed') unless IuguLogger::SmokeTest.run
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module IuguLogger
|
|
4
|
+
# Tenant (iugu domain) context extractor.
|
|
5
|
+
#
|
|
6
|
+
# Reads tenant identifiers from a Rack env hash and builds a Hash compatible
|
|
7
|
+
# with the canonical `iugu.*` block of the schema. Convention is for
|
|
8
|
+
# platform/core to populate the env in middleware (e.g. after authentication)
|
|
9
|
+
# under the `iugu.current_*` namespace.
|
|
10
|
+
#
|
|
11
|
+
# Default key mapping per IUGU_LOGGING_STANDARD.md §6.3:
|
|
12
|
+
#
|
|
13
|
+
# env['iugu.current_account_id'] → iugu.account_id
|
|
14
|
+
# env['iugu.current_subaccount_id'] → iugu.subaccount_id
|
|
15
|
+
# env['iugu.current_organization_id'] → iugu.organization_id
|
|
16
|
+
# env['iugu.current_user_id'] → iugu.user_id
|
|
17
|
+
# env['iugu.current_tier'] → iugu.tier
|
|
18
|
+
# env['iugu.current_feature'] → iugu.feature
|
|
19
|
+
#
|
|
20
|
+
# Spec: IUGU_LOGGING_STANDARD.md §6.3
|
|
21
|
+
module TenantContext
|
|
22
|
+
DEFAULT_RACK_KEYS = {
|
|
23
|
+
'iugu.current_account_id' => 'account_id',
|
|
24
|
+
'iugu.current_subaccount_id' => 'subaccount_id',
|
|
25
|
+
'iugu.current_organization_id' => 'organization_id',
|
|
26
|
+
'iugu.current_user_id' => 'user_id',
|
|
27
|
+
'iugu.current_tier' => 'tier',
|
|
28
|
+
'iugu.current_feature' => 'feature'
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
# Extract tenant context from a Rack env. Returns an empty hash when
|
|
34
|
+
# nothing matched — Logger then emits without `iugu` block.
|
|
35
|
+
#
|
|
36
|
+
# @param rack_env [Hash, nil] env hash; nil returns {}
|
|
37
|
+
# @param key_mapping [Hash] override the default rack-key → iugu-field map
|
|
38
|
+
# @return [Hash] string-keyed iugu context (suitable to pass as `iugu:` kwarg)
|
|
39
|
+
def from_rack(rack_env, key_mapping: DEFAULT_RACK_KEYS)
|
|
40
|
+
return {} if rack_env.nil?
|
|
41
|
+
|
|
42
|
+
result = {}
|
|
43
|
+
key_mapping.each do |rack_key, iugu_key|
|
|
44
|
+
value = rack_env[rack_key]
|
|
45
|
+
next if value.nil?
|
|
46
|
+
next if value.respond_to?(:empty?) && value.empty?
|
|
47
|
+
|
|
48
|
+
result[iugu_key] = value
|
|
49
|
+
end
|
|
50
|
+
result
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module IuguLogger
|
|
4
|
+
# Trace context extractor — W3C / OpenTelemetry / Datadog / legacy header chain.
|
|
5
|
+
#
|
|
6
|
+
# Builds a {'id' => trace_id_32hex, 'span_id' => span_id_16hex,
|
|
7
|
+
# 'parent_id' => parent_span_id_16hex, 'source' => <origin>,
|
|
8
|
+
# 'sampled' => bool} Hash compatible with the canonical `trace.*` ECS+OTEL
|
|
9
|
+
# fields. Values (`id`/`span_id`) come straight from OpenTelemetry when the
|
|
10
|
+
# SDK is active, so the ECS names already carry the OTEL identifiers.
|
|
11
|
+
#
|
|
12
|
+
# `source` records WHERE the context came from (opentelemetry / datadog /
|
|
13
|
+
# w3c / request_id) and `sampled` mirrors the trace-flags sampling decision
|
|
14
|
+
# when the source exposes it.
|
|
15
|
+
#
|
|
16
|
+
# `parent_id` is backfilled from an inbound W3C `traceparent` when a live
|
|
17
|
+
# tracer continued an upstream trace — present means the trace was continued
|
|
18
|
+
# from another service, nil means it was rooted in this call.
|
|
19
|
+
#
|
|
20
|
+
# `otel` is a diagnostic added by {.extract}: when the OpenTelemetry SDK is
|
|
21
|
+
# bundled but never configured (so trace context silently fell back to a
|
|
22
|
+
# header/request-id source), it is set to "not_configured" so the
|
|
23
|
+
# misconfiguration is queryable in the log stream.
|
|
24
|
+
#
|
|
25
|
+
# Source priority (first non-nil wins):
|
|
26
|
+
# 1. OpenTelemetry::Trace.current_span — when otel-api is loaded
|
|
27
|
+
# (Ruby >= 2.6 in practice; the gem itself enforces this)
|
|
28
|
+
# 2. Datadog::Tracing.active_trace — when ddtrace gem is loaded
|
|
29
|
+
# (works on Ruby 2.4 with ddtrace ~> 0.45)
|
|
30
|
+
# 3. W3C `traceparent` header — manual parse, version-agnostic
|
|
31
|
+
# 4. Legacy iugu `X-Request-Id` — padded to 32 hex (correlation only,
|
|
32
|
+
# not a real trace ID; better than nothing for platform Ruby 2.4)
|
|
33
|
+
#
|
|
34
|
+
# Returns nil when no source has trace context — Logger emits without trace.*
|
|
35
|
+
# block (it is optional in the schema).
|
|
36
|
+
#
|
|
37
|
+
# Spec: IUGU_LOGGING_STANDARD.md §6.2.2
|
|
38
|
+
module TraceContext
|
|
39
|
+
TRACEPARENT_REGEX = /\A(\d{2})-([a-f0-9]{32})-([a-f0-9]{16})-([a-f0-9]{2})\z/.freeze
|
|
40
|
+
|
|
41
|
+
EMPTY_SPAN_ID = '0000000000000000'
|
|
42
|
+
|
|
43
|
+
module_function
|
|
44
|
+
|
|
45
|
+
# Main entry point — tries each source in priority order.
|
|
46
|
+
#
|
|
47
|
+
# @param rack_env [Hash, nil] Rack env hash (for header sources). Pass
|
|
48
|
+
# `request.env` from a controller, or nil if not in a request.
|
|
49
|
+
# @return [Hash, nil] {'id' =>, 'span_id' =>, 'parent_id' =>} or nil
|
|
50
|
+
def extract(rack_env: nil)
|
|
51
|
+
result = from_opentelemetry ||
|
|
52
|
+
from_datadog ||
|
|
53
|
+
from_w3c_header(rack_env) ||
|
|
54
|
+
from_legacy_header(rack_env)
|
|
55
|
+
|
|
56
|
+
result = backfill_parent_id(result, rack_env)
|
|
57
|
+
annotate_otel_health(result)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# A live tracer (OTEL/Datadog) creates the local span, but its SpanContext
|
|
61
|
+
# doesn't expose the parent — only `trace_id`/`span_id`. When the request
|
|
62
|
+
# arrived with a W3C `traceparent` whose trace_id matches the active trace,
|
|
63
|
+
# that header's span_id IS the upstream caller's span (our parent), so we
|
|
64
|
+
# backfill `parent_id` with it. The net effect:
|
|
65
|
+
# - parent_id present → trace was CONTINUED from another service;
|
|
66
|
+
# - parent_id nil → trace was ROOTED in this call.
|
|
67
|
+
# Skipped for the `w3c` fallback source (there the header span_id is
|
|
68
|
+
# already reported as `span_id`, so it has no distinct local parent) and
|
|
69
|
+
# for `request_id` (no real trace).
|
|
70
|
+
def backfill_parent_id(result, rack_env)
|
|
71
|
+
return result if result.nil? || rack_env.nil?
|
|
72
|
+
return result unless %w[opentelemetry datadog].include?(result['source'])
|
|
73
|
+
return result unless result['parent_id'].nil?
|
|
74
|
+
|
|
75
|
+
header = rack_env['HTTP_TRACEPARENT'] || rack_env['traceparent']
|
|
76
|
+
return result if header.nil? || header.to_s.empty?
|
|
77
|
+
|
|
78
|
+
match = TRACEPARENT_REGEX.match(header.to_s.strip)
|
|
79
|
+
return result if match.nil?
|
|
80
|
+
|
|
81
|
+
_, header_trace_id, header_span_id, _flags = match.captures
|
|
82
|
+
return result unless header_trace_id == result['id']
|
|
83
|
+
|
|
84
|
+
result['parent_id'] = header_span_id
|
|
85
|
+
result
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Adds `otel: "not_configured"` when the OpenTelemetry SDK is bundled but
|
|
89
|
+
# no SDK tracer provider is active (i.e. `OpenTelemetry::SDK.configure`
|
|
90
|
+
# never ran). That state means the trace context above came from a
|
|
91
|
+
# fallback source, not from a live span — almost always a boot
|
|
92
|
+
# misconfiguration worth surfacing. Stays silent (no field) when:
|
|
93
|
+
# - OTEL SDK isn't bundled at all (Datadog / platform Ruby 2.4 apps), or
|
|
94
|
+
# - the SDK is active (`status == 'active'`).
|
|
95
|
+
# When OTEL is misconfigured but there was no trace context whatsoever,
|
|
96
|
+
# still returns a hash carrying only the diagnostic so it isn't lost.
|
|
97
|
+
def annotate_otel_health(result)
|
|
98
|
+
status = otel_status
|
|
99
|
+
return result if status.nil? || status == 'active'
|
|
100
|
+
|
|
101
|
+
result ||= {}
|
|
102
|
+
result['otel'] = status
|
|
103
|
+
result
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# @return [String, nil] 'active' / 'not_configured' when the OTEL SDK is
|
|
107
|
+
# bundled, nil when it isn't (we don't opine on apps that never shipped
|
|
108
|
+
# OpenTelemetry).
|
|
109
|
+
def otel_status
|
|
110
|
+
return nil unless defined?(::OpenTelemetry::SDK)
|
|
111
|
+
|
|
112
|
+
provider_class = ::OpenTelemetry.tracer_provider.class.name.to_s
|
|
113
|
+
provider_class.start_with?('OpenTelemetry::SDK') ? 'active' : 'not_configured'
|
|
114
|
+
rescue StandardError
|
|
115
|
+
nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def from_opentelemetry
|
|
119
|
+
return nil unless defined?(::OpenTelemetry::Trace)
|
|
120
|
+
|
|
121
|
+
span = ::OpenTelemetry::Trace.current_span
|
|
122
|
+
return nil if span.nil?
|
|
123
|
+
|
|
124
|
+
ctx = span.context
|
|
125
|
+
return nil if ctx.nil?
|
|
126
|
+
return nil unless ctx.respond_to?(:valid?) && ctx.valid?
|
|
127
|
+
|
|
128
|
+
flags = ctx.respond_to?(:trace_flags) ? ctx.trace_flags : nil
|
|
129
|
+
sampled = flags.respond_to?(:sampled?) ? flags.sampled? : nil
|
|
130
|
+
|
|
131
|
+
trace = {
|
|
132
|
+
'id' => ctx.hex_trace_id,
|
|
133
|
+
'span_id' => ctx.hex_span_id,
|
|
134
|
+
'parent_id' => nil,
|
|
135
|
+
'source' => 'opentelemetry'
|
|
136
|
+
}
|
|
137
|
+
trace['sampled'] = sampled unless sampled.nil?
|
|
138
|
+
trace
|
|
139
|
+
rescue StandardError
|
|
140
|
+
# Defensive: never let a tracing-lib oddity break logging
|
|
141
|
+
nil
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def from_datadog
|
|
145
|
+
return nil unless defined?(::Datadog::Tracing)
|
|
146
|
+
|
|
147
|
+
trace = ::Datadog::Tracing.active_trace
|
|
148
|
+
return nil if trace.nil?
|
|
149
|
+
|
|
150
|
+
span = ::Datadog::Tracing.active_span
|
|
151
|
+
span_id = span.respond_to?(:id) ? span.id : nil
|
|
152
|
+
|
|
153
|
+
{
|
|
154
|
+
'id' => format('%032x', trace.id),
|
|
155
|
+
'span_id' => span_id ? format('%016x', span_id) : EMPTY_SPAN_ID,
|
|
156
|
+
'parent_id' => nil,
|
|
157
|
+
'source' => 'datadog'
|
|
158
|
+
}
|
|
159
|
+
rescue StandardError
|
|
160
|
+
nil
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def from_w3c_header(rack_env)
|
|
164
|
+
return nil if rack_env.nil?
|
|
165
|
+
|
|
166
|
+
header = rack_env['HTTP_TRACEPARENT'] || rack_env['traceparent']
|
|
167
|
+
return nil if header.nil? || header.empty?
|
|
168
|
+
|
|
169
|
+
match = TRACEPARENT_REGEX.match(header.to_s.strip)
|
|
170
|
+
return nil if match.nil?
|
|
171
|
+
|
|
172
|
+
_, trace_id, span_id, flags = match.captures
|
|
173
|
+
trace = {
|
|
174
|
+
'id' => trace_id,
|
|
175
|
+
'span_id' => span_id,
|
|
176
|
+
'parent_id' => nil,
|
|
177
|
+
'source' => 'w3c'
|
|
178
|
+
}
|
|
179
|
+
# traceparent flags: bit 0 (0x01) is the sampled flag (W3C §3.3.1).
|
|
180
|
+
trace['sampled'] = (flags.to_i(16) & 0x01) == 1 if flags
|
|
181
|
+
trace
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Last-resort fallback: derive a deterministic 32-hex trace_id from the
|
|
185
|
+
# iugu legacy X-Request-Id (or Rails action_dispatch.request_id). NOT a
|
|
186
|
+
# real W3C trace ID — only good for correlating logs within a single
|
|
187
|
+
# service. Used by platform/Ruby 2.4 where OTEL is unavailable.
|
|
188
|
+
def from_legacy_header(rack_env)
|
|
189
|
+
return nil if rack_env.nil?
|
|
190
|
+
|
|
191
|
+
raw = rack_env['HTTP_X_REQUEST_ID'] ||
|
|
192
|
+
rack_env['action_dispatch.request_id'] ||
|
|
193
|
+
rack_env['HTTP_REQUEST_ID']
|
|
194
|
+
return nil if raw.nil? || raw.to_s.empty?
|
|
195
|
+
|
|
196
|
+
hex = raw.to_s.gsub(/[^a-f0-9]/i, '').downcase[0, 32].to_s
|
|
197
|
+
return nil if hex.empty?
|
|
198
|
+
|
|
199
|
+
hex = hex.ljust(32, '0')
|
|
200
|
+
return nil if hex == ('0' * 32)
|
|
201
|
+
|
|
202
|
+
{
|
|
203
|
+
'id' => hex,
|
|
204
|
+
'span_id' => EMPTY_SPAN_ID,
|
|
205
|
+
'parent_id' => nil,
|
|
206
|
+
'source' => 'request_id'
|
|
207
|
+
}
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|