auditea 0.1.0.beta.1

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,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Auditea
4
+ module Instrumentation
5
+ # Safe ActiveJob final-failure observation. Never captures job arguments.
6
+ module ActiveJob
7
+ module_function
8
+
9
+ def install!
10
+ return unless defined?(::ActiveJob::Base)
11
+ return unless defined?(::ActiveSupport::Notifications)
12
+ return if @installed
13
+
14
+ @installed = true
15
+ @reported = {}
16
+ @mutex = Mutex.new
17
+
18
+ ::ActiveSupport::Notifications.subscribe("retry_stopped.active_job") do |*args|
19
+ handle_notification(args)
20
+ end
21
+ ::ActiveSupport::Notifications.subscribe("discard.active_job") do |*args|
22
+ handle_notification(args)
23
+ end
24
+
25
+ ::ActiveJob::Base.around_perform do |job, block|
26
+ block.call
27
+ rescue StandardError => error
28
+ Auditea::Instrumentation::ActiveJob.report_unhandled(job, error)
29
+ raise
30
+ end
31
+ end
32
+
33
+ def handle_notification(args)
34
+ return unless Auditea.configuration.capture_active_job
35
+
36
+ event = Auditea::Instrumentation::ActiveJob.notification_event(args)
37
+ payload = event.payload || {}
38
+ job = payload[:job]
39
+ error = payload[:error] || payload[:exception_object]
40
+ error ||= Auditea::Instrumentation::ActiveJob.build_from_array(payload[:exception])
41
+ Auditea::Instrumentation::ActiveJob.report(job, error, duration_ms: event.duration)
42
+ rescue StandardError => error
43
+ Auditea.handle_error(error)
44
+ end
45
+
46
+ def report_unhandled(job, error)
47
+ return unless Auditea.configuration.capture_active_job
48
+ return if handled_by_active_job?(job, error)
49
+
50
+ report(job, error)
51
+ end
52
+
53
+ def report(job, error, duration_ms: nil)
54
+ return unless job && error
55
+
56
+ key = report_key(job, error)
57
+ return unless claim!(key)
58
+
59
+ evidence = {
60
+ "job" => {
61
+ "class" => job.class.name,
62
+ "queue" => (job.queue_name if job.respond_to?(:queue_name))
63
+ }.compact,
64
+ "exception" => {
65
+ "type" => error.class.name,
66
+ "message" => Sanitizer.truncate_string(error.message.to_s, 500)
67
+ }
68
+ }
69
+ evidence["job"]["duration_ms"] = duration_ms.round if duration_ms
70
+
71
+ Auditea.capture(
72
+ "job.failed",
73
+ category: "error",
74
+ severity: "error",
75
+ evidence: evidence,
76
+ metadata: { "sdk_event_kind" => "auto_observed" }
77
+ )
78
+ rescue StandardError => error
79
+ Auditea.handle_error(error)
80
+ end
81
+
82
+ # retry_on/discard_on are implemented through ActiveSupport::Rescuable.
83
+ # Ask Rescuable whether a handler exists rather than comparing the raw
84
+ # rescue_handlers keys: those keys are commonly stored as class-name
85
+ # strings, so `key === error` would incorrectly miss handled exceptions
86
+ # and emit job.failed before retries are exhausted.
87
+ def handled_by_active_job?(job, error)
88
+ return false unless job.class.respond_to?(:handler_for_rescue)
89
+
90
+ !job.class.handler_for_rescue(error).nil?
91
+ rescue StandardError
92
+ false
93
+ end
94
+
95
+ def report_key(job, error)
96
+ jid = job.respond_to?(:job_id) ? job.job_id : job.object_id
97
+ "#{jid}:#{error.class.name}"
98
+ end
99
+
100
+ def claim!(key)
101
+ @mutex.synchronize do
102
+ return false if @reported[key]
103
+
104
+ @reported[key] = true
105
+ # Bound memory of dedupe map.
106
+ @reported.shift if @reported.size > 1_000
107
+ true
108
+ end
109
+ end
110
+
111
+ def notification_event(args)
112
+ if args.first.is_a?(::ActiveSupport::Notifications::Event)
113
+ args.first
114
+ else
115
+ ::ActiveSupport::Notifications::Event.new(*args)
116
+ end
117
+ end
118
+
119
+ def build_from_array(pair)
120
+ return nil unless pair.is_a?(Array) && pair.size >= 2
121
+
122
+ klass = begin
123
+ Object.const_get(pair[0])
124
+ rescue StandardError
125
+ StandardError
126
+ end
127
+ klass.new(pair[1].to_s)
128
+ end
129
+
130
+ def reset_for_tests!
131
+ @reported = {}
132
+ @mutex = Mutex.new
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Auditea
4
+ module Instrumentation
5
+ module Exception
6
+ module_function
7
+
8
+ def capture_from_notification(event)
9
+ payload = event.payload || {}
10
+ exception = payload[:exception_object]
11
+ exception ||= build_from_array(payload[:exception])
12
+ return unless exception
13
+
14
+ frames = safe_frames(exception)
15
+ path = payload[:path].to_s.split("?", 2).first
16
+
17
+ Auditea.capture(
18
+ "exception.raised",
19
+ category: "error",
20
+ severity: "error",
21
+ evidence: {
22
+ "exception" => {
23
+ "type" => exception.class.name,
24
+ "message" => Sanitizer.truncate_string(exception.message.to_s, 500),
25
+ "frames" => frames
26
+ }.compact,
27
+ "http" => {
28
+ "method" => payload[:method],
29
+ "path" => path,
30
+ "status" => payload[:status]
31
+ }.compact,
32
+ "rails" => {
33
+ "controller" => payload[:controller],
34
+ "action" => payload[:action]
35
+ }.compact
36
+ },
37
+ metadata: {
38
+ "sdk_event_kind" => "auto_observed"
39
+ }
40
+ )
41
+ rescue StandardError => error
42
+ Auditea.handle_error(error)
43
+ end
44
+
45
+ def build_from_array(pair)
46
+ return nil unless pair.is_a?(Array) && pair.size >= 2
47
+
48
+ klass = begin
49
+ Object.const_get(pair[0])
50
+ rescue StandardError
51
+ StandardError
52
+ end
53
+ klass.new(pair[1].to_s)
54
+ end
55
+
56
+ def safe_frames(exception)
57
+ return [] unless exception.respond_to?(:backtrace) && exception.backtrace
58
+
59
+ exception.backtrace.first(20).map do |line|
60
+ file, linenum, method_name = parse_frame(line)
61
+ { "file" => file, "line" => linenum, "method" => method_name }.compact
62
+ end
63
+ end
64
+
65
+ def parse_frame(line)
66
+ # typical: path:123:in `method'
67
+ if (match = line.match(/\A(.+):(\d+):in [`'](.+)[`']\z/))
68
+ [match[1], match[2].to_i, match[3]]
69
+ else
70
+ [line, nil, nil]
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Auditea
4
+ module Instrumentation
5
+ # Opt-in boundary for future Rails/Ruby Logger forwarding.
6
+ # NOT installed by default in 0.1.0.beta.1 — prevents volume, secrets, recursion.
7
+ #
8
+ # Future usage (not auto-wired):
9
+ # Auditea::Instrumentation::LoggerAdapter.install!(logger)
10
+ class LoggerAdapter
11
+ def self.install!(_logger = nil)
12
+ raise NotImplementedError,
13
+ "Automatic Rails.logger forwarding is intentionally disabled in 0.1.0.beta.1. " \
14
+ "Use Auditea.log(level, message, ...) for structured logs. " \
15
+ "An opt-in adapter can be added later without transport redesign."
16
+ end
17
+
18
+ # Adapter surface reserved for future Logger#add forwarding into Auditea.log.
19
+ def initialize(logger)
20
+ @logger = logger
21
+ end
22
+
23
+ def add(_severity, _message = nil, _progname = nil)
24
+ raise NotImplementedError, "LoggerAdapter forwarding not enabled in beta.1"
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Auditea
4
+ module Instrumentation
5
+ module Request
6
+ module_function
7
+
8
+ def install!
9
+ return unless defined?(ActiveSupport::Notifications)
10
+ return if @installed
11
+
12
+ @installed = true
13
+ ActiveSupport::Notifications.subscribe("process_action.action_controller") do |*args|
14
+ event = args.first.is_a?(ActiveSupport::Notifications::Event) ? args.first : ActiveSupport::Notifications::Event.new(*args)
15
+ capture(event) if Auditea.configuration.capture_requests
16
+ Exception.capture_from_notification(event) if Auditea.configuration.capture_exceptions
17
+ end
18
+ end
19
+
20
+ def capture(event)
21
+ payload = event.payload || {}
22
+ path = payload[:path].to_s.split("?", 2).first
23
+ evidence = {
24
+ "http" => {
25
+ "method" => payload[:method] || request_method(payload),
26
+ "path" => path,
27
+ "status" => payload[:status],
28
+ "duration_ms" => event.duration&.round
29
+ }.compact,
30
+ "rails" => {
31
+ "controller" => payload[:controller],
32
+ "action" => payload[:action],
33
+ "format" => payload[:format]&.to_s
34
+ }.compact
35
+ }
36
+
37
+ if payload[:params] && Auditea.configuration.capture_request_params
38
+ evidence["rails"]["params"] = Sanitizer.sanitize_value(filter_params(payload[:params]))
39
+ end
40
+
41
+ # Preserve route template when Rails exposes it (non-lossy structural evidence).
42
+ route_key = "#{payload[:controller]}##{payload[:action]}"
43
+ evidence["http"]["route_key"] = route_key if payload[:controller] && payload[:action]
44
+
45
+ Auditea.capture(
46
+ "network.request.completed",
47
+ category: "network",
48
+ severity: "info",
49
+ evidence: evidence,
50
+ context: {
51
+ "request_id" => CurrentContext.to_h["request_id"]
52
+ }.compact,
53
+ metadata: {
54
+ "sdk_event_kind" => "auto_observed"
55
+ }
56
+ )
57
+ rescue StandardError => error
58
+ Auditea.handle_error(error)
59
+ end
60
+
61
+ def filter_params(params)
62
+ hash = params.respond_to?(:to_unsafe_h) ? params.to_unsafe_h : params.to_h
63
+ hash.except("controller", "action")
64
+ rescue StandardError
65
+ {}
66
+ end
67
+
68
+ def request_method(payload)
69
+ request = payload[:request]
70
+ return unless request.respond_to?(:request_method)
71
+
72
+ request.request_method
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Auditea
4
+ module Log
5
+ LEVELS = {
6
+ "debug" => "debug",
7
+ "info" => "info",
8
+ "warn" => "warn",
9
+ "warning" => "warn",
10
+ "error" => "error",
11
+ "fatal" => "fatal"
12
+ }.freeze
13
+
14
+ module_function
15
+
16
+ def record(level, message, context: {}, metadata: {})
17
+ normalized = LEVELS[level.to_s.downcase] || "info"
18
+ text = Sanitizer.truncate_string(message.to_s, Auditea.configuration.message_max_bytes)
19
+
20
+ Auditea.capture(
21
+ "log.recorded",
22
+ category: "log",
23
+ severity: normalized,
24
+ context: context,
25
+ evidence: {
26
+ "log" => {
27
+ "message" => text,
28
+ "level" => normalized
29
+ }
30
+ },
31
+ metadata: metadata
32
+ )
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Auditea
4
+ module Middleware
5
+ # Rack middleware: sets CurrentContext with safe structural request fields.
6
+ # Does NOT store session_id, cookies, Authorization, or query strings.
7
+ class Context
8
+ def initialize(app)
9
+ @app = app
10
+ end
11
+
12
+ def call(env)
13
+ CurrentContext.clear!
14
+ request = ::Rack::Request.new(env)
15
+ CurrentContext.set(
16
+ "request_id" => env["action_dispatch.request_id"] || env["HTTP_X_REQUEST_ID"],
17
+ "http" => {
18
+ "method" => request.request_method,
19
+ "path" => path_without_query(request)
20
+ }
21
+ )
22
+ @app.call(env)
23
+ ensure
24
+ CurrentContext.clear!
25
+ end
26
+
27
+ private
28
+
29
+ def path_without_query(request)
30
+ path = request.path.to_s
31
+ path.split("?", 2).first
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Auditea
4
+ class Railtie < ::Rails::Railtie
5
+ initializer "auditea.configure" do
6
+ Auditea.configuration.logger = Rails.logger if Rails.logger
7
+ begin
8
+ Auditea.configuration.validate!
9
+ rescue Auditea::ConfigurationError => error
10
+ Rails.logger&.warn("[auditea] configuration warning: #{error.message}")
11
+ end
12
+ end
13
+
14
+ initializer "auditea.middleware" do |app|
15
+ app.middleware.use Auditea::Middleware::Context
16
+ end
17
+
18
+ initializer "auditea.instrumentation" do
19
+ Auditea::Instrumentation::Request.install!
20
+ Auditea::Instrumentation::ActiveJob.install!
21
+ end
22
+
23
+ config.after_initialize do
24
+ at_exit { Auditea.shutdown }
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Auditea
4
+ # Client-side sanitization before enqueue (server also filters).
5
+ # Never auto-include credentials, cookies, authorization, or raw bodies.
6
+ # Exact normalized key matching only (no substring matches).
7
+ module Sanitizer
8
+ FILTERED = "[FILTERED]"
9
+ SENSITIVE_KEYS = %w[
10
+ password password_confirmation password_digest authorization cookie set_cookie
11
+ api_key access_token refresh_token secret secret_key client_secret private_key token
12
+ otp otp_secret session_id credit_card card_number cvv ssn
13
+ ].freeze
14
+
15
+ module_function
16
+
17
+ def sanitize_event(event)
18
+ redacted = []
19
+ sanitized = deep_sanitize(event, depth: 0, path: nil, redacted: redacted)
20
+ return sanitized unless sanitized.is_a?(Hash)
21
+
22
+ merge_redacted_fields!(sanitized, redacted)
23
+ sanitized
24
+ end
25
+
26
+ def sanitize_value(value, depth: 0)
27
+ deep_sanitize(value, depth: depth, path: nil, redacted: [])
28
+ end
29
+
30
+ def normalize_key(key)
31
+ key.to_s.gsub(/([a-z\d])([A-Z])/, '\1_\2').tr("-", "_").downcase
32
+ end
33
+
34
+ def sensitive_key?(key)
35
+ SENSITIVE_KEYS.include?(normalize_key(key))
36
+ end
37
+
38
+ def truncate_string(value, max_bytes)
39
+ string = value.to_s
40
+ return string if string.bytesize <= max_bytes
41
+
42
+ "#{string.byteslice(0, max_bytes)}…"
43
+ end
44
+
45
+ def deep_sanitize(value, depth:, path:, redacted:)
46
+ cfg = Auditea.configuration
47
+ return { "truncated" => true, "reason" => "max_depth" } if depth > cfg.nesting_max_depth
48
+
49
+ case value
50
+ when Hash
51
+ value.each_with_object({}) do |(key, child), memo|
52
+ key_s = key.to_s
53
+ child_path = path ? "#{path}.#{key_s}" : key_s
54
+ if sensitive_key?(key)
55
+ memo[key_s] = FILTERED
56
+ redacted << child_path if redacted
57
+ else
58
+ memo[key_s] = deep_sanitize(child, depth: depth + 1, path: child_path, redacted: redacted)
59
+ end
60
+ end
61
+ when Array
62
+ value.first(100).each_with_index.map do |child, index|
63
+ child_path = path ? "#{path}[#{index}]" : "[#{index}]"
64
+ deep_sanitize(child, depth: depth + 1, path: child_path, redacted: redacted)
65
+ end
66
+ when String
67
+ truncate_string(value, cfg.message_max_bytes)
68
+ when Numeric, TrueClass, FalseClass, NilClass
69
+ value
70
+ else
71
+ truncate_string(value.inspect, cfg.message_max_bytes)
72
+ end
73
+ end
74
+
75
+ def merge_redacted_fields!(event, redacted)
76
+ return if redacted.nil? || redacted.empty?
77
+
78
+ privacy = event["privacy"]
79
+ privacy = privacy.is_a?(Hash) ? privacy.dup : {}
80
+ existing = Array(privacy["redacted_fields"]).map(&:to_s)
81
+ privacy["redacted_fields"] = (existing + redacted).uniq
82
+ event["privacy"] = privacy
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Auditea
4
+ module Target
5
+ module_function
6
+
7
+ def from_record(record, type: nil)
8
+ return nil unless record
9
+
10
+ {
11
+ "type" => type || record.class&.name,
12
+ "id" => safe(record, :id) || safe(record, :to_param)
13
+ }.compact
14
+ end
15
+
16
+ def safe(object, method_name)
17
+ return nil unless object.respond_to?(method_name)
18
+
19
+ value = object.public_send(method_name)
20
+ value.nil? || (value.respond_to?(:empty?) && value.empty?) ? nil : value.to_s
21
+ rescue StandardError
22
+ nil
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Auditea
4
+ module Transport
5
+ # Structured delivery outcome. Fail-open to the host app does not imply
6
+ # that a rejected event was "delivered successfully".
7
+ class DeliveryResult
8
+ ATTRS = %i[
9
+ ok status outcome event_id permanent retryable message body results
10
+ ].freeze
11
+
12
+ attr_reader(*ATTRS)
13
+
14
+ def initialize(ok:, outcome:, status: nil, event_id: nil, permanent: false,
15
+ retryable: false, message: nil, body: nil, results: nil)
16
+ @ok = ok
17
+ @status = status
18
+ @outcome = outcome.to_sym
19
+ @event_id = event_id
20
+ @permanent = permanent
21
+ @retryable = retryable
22
+ @message = message
23
+ @body = body
24
+ @results = results
25
+ end
26
+
27
+ def ok?
28
+ !!@ok
29
+ end
30
+
31
+ def permanent?
32
+ !!@permanent
33
+ end
34
+
35
+ def retryable?
36
+ !!@retryable
37
+ end
38
+
39
+ def to_h
40
+ {
41
+ ok: ok?,
42
+ status: status,
43
+ outcome: outcome,
44
+ event_id: event_id,
45
+ permanent: permanent?,
46
+ retryable: retryable?,
47
+ message: message,
48
+ results: results
49
+ }.compact
50
+ end
51
+
52
+ def self.success(status:, outcome:, event_id: nil, body: nil, results: nil)
53
+ new(ok: true, status: status, outcome: outcome, event_id: event_id,
54
+ permanent: false, retryable: false, body: body, results: results)
55
+ end
56
+
57
+ def self.permanent_failure(status:, outcome:, event_id: nil, message: nil, body: nil, results: nil)
58
+ new(ok: false, status: status, outcome: outcome, event_id: event_id,
59
+ permanent: true, retryable: false, message: message, body: body, results: results)
60
+ end
61
+
62
+ def self.transient_failure(outcome:, status: nil, message: nil, event_id: nil)
63
+ new(ok: false, status: status, outcome: outcome, event_id: event_id,
64
+ permanent: false, retryable: true, message: message)
65
+ end
66
+ end
67
+ end
68
+ end