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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +8 -0
- data/DESIGN.md +48 -0
- data/LICENSE +21 -0
- data/README.md +117 -0
- data/RELEASE.md +43 -0
- data/auditea.gemspec +36 -0
- data/examples/manual_capture.rb +50 -0
- data/lib/auditea/actor.rb +26 -0
- data/lib/auditea/client.rb +46 -0
- data/lib/auditea/configuration.rb +125 -0
- data/lib/auditea/current_context.rb +34 -0
- data/lib/auditea/event_builder.rb +81 -0
- data/lib/auditea/instrumentation/active_job.rb +136 -0
- data/lib/auditea/instrumentation/exception.rb +75 -0
- data/lib/auditea/instrumentation/logger_adapter.rb +28 -0
- data/lib/auditea/instrumentation/request.rb +76 -0
- data/lib/auditea/log.rb +35 -0
- data/lib/auditea/middleware/context.rb +35 -0
- data/lib/auditea/railtie.rb +27 -0
- data/lib/auditea/sanitizer.rb +85 -0
- data/lib/auditea/target.rb +25 -0
- data/lib/auditea/transport/delivery_result.rb +68 -0
- data/lib/auditea/transport/http.rb +253 -0
- data/lib/auditea/transport/limits.rb +28 -0
- data/lib/auditea/transport/queue.rb +232 -0
- data/lib/auditea/version.rb +5 -0
- data/lib/auditea.rb +149 -0
- metadata +73 -0
|
@@ -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
|
data/lib/auditea/log.rb
ADDED
|
@@ -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
|