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,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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IuguLogger
4
+ VERSION = '0.10.0'
5
+ end