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,253 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
module Auditea
|
|
6
|
+
module Transport
|
|
7
|
+
class Http
|
|
8
|
+
DEFAULT_SLEEPER = ->(seconds) { sleep(seconds) }
|
|
9
|
+
|
|
10
|
+
def initialize(configuration, sleeper: DEFAULT_SLEEPER, random: Random.new)
|
|
11
|
+
@configuration = configuration
|
|
12
|
+
@sleeper = sleeper
|
|
13
|
+
@random = random
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def post_event(event)
|
|
17
|
+
body = JSON.generate("event" => event)
|
|
18
|
+
request(@configuration.events_url, body, single: true, event_id: event["event_id"])
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def post_batch(events)
|
|
22
|
+
body = JSON.generate("events" => events)
|
|
23
|
+
request(@configuration.event_batches_url, body, single: false, events: events)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def request(url, body, single:, event_id: nil, events: nil)
|
|
29
|
+
uri = URI.parse(url)
|
|
30
|
+
attempts = 0
|
|
31
|
+
max_attempts = @configuration.max_retries + 1
|
|
32
|
+
|
|
33
|
+
begin
|
|
34
|
+
attempts += 1
|
|
35
|
+
response = perform(uri, body)
|
|
36
|
+
result = classify(response, single: single, event_id: event_id, events: events)
|
|
37
|
+
return result unless result.retryable?
|
|
38
|
+
|
|
39
|
+
raise TransientError.new(result.message || "retryable HTTP #{result.status}", status: result.status)
|
|
40
|
+
rescue TransientError => error
|
|
41
|
+
if attempts >= max_attempts
|
|
42
|
+
return DeliveryResult.transient_failure(
|
|
43
|
+
outcome: :exhausted,
|
|
44
|
+
message: safe_message(error),
|
|
45
|
+
event_id: event_id,
|
|
46
|
+
status: error.status
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
backoff(attempts)
|
|
51
|
+
retry
|
|
52
|
+
rescue StandardError => error
|
|
53
|
+
raise if fatal_client_error?(error)
|
|
54
|
+
|
|
55
|
+
if attempts >= max_attempts
|
|
56
|
+
return DeliveryResult.transient_failure(
|
|
57
|
+
outcome: :exhausted,
|
|
58
|
+
message: safe_message(error),
|
|
59
|
+
event_id: event_id,
|
|
60
|
+
status: error.respond_to?(:status) ? error.status : nil
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
backoff(attempts)
|
|
65
|
+
retry
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def fatal_client_error?(error)
|
|
70
|
+
error.is_a?(ConfigurationError) ||
|
|
71
|
+
error.is_a?(ArgumentError) ||
|
|
72
|
+
error.is_a?(NoMethodError) ||
|
|
73
|
+
error.is_a?(NameError) ||
|
|
74
|
+
error.is_a?(TypeError)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def perform(uri, body)
|
|
78
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
79
|
+
http.use_ssl = uri.scheme == "https"
|
|
80
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER if http.use_ssl?
|
|
81
|
+
http.open_timeout = @configuration.open_timeout
|
|
82
|
+
http.read_timeout = @configuration.timeout
|
|
83
|
+
http.write_timeout = @configuration.write_timeout if http.respond_to?(:write_timeout=)
|
|
84
|
+
http.max_retries = 0 if http.respond_to?(:max_retries=)
|
|
85
|
+
|
|
86
|
+
req = Net::HTTP::Post.new(uri.request_uri)
|
|
87
|
+
req["Content-Type"] = "application/json"
|
|
88
|
+
req["Accept"] = "application/json"
|
|
89
|
+
req["Authorization"] = "Bearer #{@configuration.source_token}"
|
|
90
|
+
req["User-Agent"] = "auditea-ruby/#{VERSION}"
|
|
91
|
+
req.body = body
|
|
92
|
+
|
|
93
|
+
http.request(req)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def classify(response, single:, event_id:, events:)
|
|
97
|
+
status = response.code.to_i
|
|
98
|
+
parsed = safe_parse(response.body)
|
|
99
|
+
|
|
100
|
+
case status
|
|
101
|
+
when 201
|
|
102
|
+
DeliveryResult.success(
|
|
103
|
+
status: status,
|
|
104
|
+
outcome: :accepted,
|
|
105
|
+
event_id: (parsed["event_id"] if parsed.is_a?(Hash)) || event_id,
|
|
106
|
+
body: parsed
|
|
107
|
+
)
|
|
108
|
+
when 200
|
|
109
|
+
if single
|
|
110
|
+
DeliveryResult.success(
|
|
111
|
+
status: status,
|
|
112
|
+
outcome: :duplicate,
|
|
113
|
+
event_id: (parsed["event_id"] if parsed.is_a?(Hash)) || event_id,
|
|
114
|
+
body: parsed
|
|
115
|
+
)
|
|
116
|
+
else
|
|
117
|
+
classify_batch(status, parsed, events)
|
|
118
|
+
end
|
|
119
|
+
when 409
|
|
120
|
+
DeliveryResult.permanent_failure(
|
|
121
|
+
status: status, outcome: :conflict,
|
|
122
|
+
event_id: (parsed["event_id"] if parsed.is_a?(Hash)) || event_id,
|
|
123
|
+
message: "conflict", body: parsed
|
|
124
|
+
)
|
|
125
|
+
when 422
|
|
126
|
+
DeliveryResult.permanent_failure(
|
|
127
|
+
status: status, outcome: :invalid,
|
|
128
|
+
event_id: (parsed["event_id"] if parsed.is_a?(Hash)) || event_id,
|
|
129
|
+
message: "validation_failed", body: parsed
|
|
130
|
+
)
|
|
131
|
+
when 401
|
|
132
|
+
DeliveryResult.permanent_failure(
|
|
133
|
+
status: status, outcome: :unauthorized,
|
|
134
|
+
event_id: event_id, message: "unauthorized", body: parsed
|
|
135
|
+
)
|
|
136
|
+
when 413
|
|
137
|
+
DeliveryResult.permanent_failure(
|
|
138
|
+
status: status, outcome: :payload_too_large,
|
|
139
|
+
event_id: event_id, message: "payload_too_large", body: parsed
|
|
140
|
+
)
|
|
141
|
+
when 415
|
|
142
|
+
DeliveryResult.permanent_failure(
|
|
143
|
+
status: status, outcome: :unsupported_media,
|
|
144
|
+
event_id: event_id, message: "unsupported_media", body: parsed
|
|
145
|
+
)
|
|
146
|
+
when 429
|
|
147
|
+
DeliveryResult.transient_failure(
|
|
148
|
+
status: status, outcome: :rate_limited,
|
|
149
|
+
message: "rate_limited", event_id: event_id
|
|
150
|
+
)
|
|
151
|
+
when 300..399
|
|
152
|
+
DeliveryResult.permanent_failure(
|
|
153
|
+
status: status, outcome: :redirect,
|
|
154
|
+
event_id: event_id, message: "redirect_not_followed", body: parsed
|
|
155
|
+
)
|
|
156
|
+
when 500..599
|
|
157
|
+
DeliveryResult.transient_failure(
|
|
158
|
+
status: status, outcome: :server_error,
|
|
159
|
+
message: "server_error", event_id: event_id
|
|
160
|
+
)
|
|
161
|
+
else
|
|
162
|
+
DeliveryResult.permanent_failure(
|
|
163
|
+
status: status, outcome: :unexpected_status,
|
|
164
|
+
event_id: event_id, message: "unexpected_status_#{status}", body: parsed
|
|
165
|
+
)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def classify_batch(status, parsed, events)
|
|
170
|
+
rows = Array(parsed.is_a?(Hash) ? parsed["results"] : nil)
|
|
171
|
+
by_index = {}
|
|
172
|
+
rows.each do |row|
|
|
173
|
+
next unless row.is_a?(Hash)
|
|
174
|
+
|
|
175
|
+
idx = row["index"]
|
|
176
|
+
by_index[idx] = row if idx.is_a?(Integer)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
results = (events || []).each_with_index.map do |event, index|
|
|
180
|
+
row = by_index[index]
|
|
181
|
+
if row.nil?
|
|
182
|
+
{
|
|
183
|
+
"index" => index,
|
|
184
|
+
"status" => "missing_result",
|
|
185
|
+
"event_id" => event["event_id"],
|
|
186
|
+
"outcome" => "missing_result",
|
|
187
|
+
"ok" => false,
|
|
188
|
+
"retryable" => false
|
|
189
|
+
}
|
|
190
|
+
else
|
|
191
|
+
status_name = row["status"].to_s
|
|
192
|
+
outcome =
|
|
193
|
+
case status_name
|
|
194
|
+
when "accepted" then "accepted"
|
|
195
|
+
when "duplicate" then "duplicate"
|
|
196
|
+
when "conflict" then "conflict"
|
|
197
|
+
when "invalid" then "invalid"
|
|
198
|
+
else "unknown"
|
|
199
|
+
end
|
|
200
|
+
success = %w[accepted duplicate].include?(outcome)
|
|
201
|
+
{
|
|
202
|
+
"index" => index,
|
|
203
|
+
"status" => status_name,
|
|
204
|
+
"event_id" => row["event_id"] || event["event_id"],
|
|
205
|
+
"outcome" => outcome,
|
|
206
|
+
"ok" => success,
|
|
207
|
+
"retryable" => false
|
|
208
|
+
}
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
DeliveryResult.new(
|
|
213
|
+
ok: results.all? { |row| row["ok"] },
|
|
214
|
+
status: status,
|
|
215
|
+
outcome: :batch,
|
|
216
|
+
permanent: true,
|
|
217
|
+
retryable: false,
|
|
218
|
+
body: parsed,
|
|
219
|
+
results: results,
|
|
220
|
+
message: results.all? { |row| row["ok"] } ? nil : "mixed_batch_results"
|
|
221
|
+
)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def safe_parse(body)
|
|
225
|
+
text = body.to_s.byteslice(0, Limits::MAX_RESPONSE_BYTES).to_s
|
|
226
|
+
return {} if text.empty?
|
|
227
|
+
|
|
228
|
+
JSON.parse(text)
|
|
229
|
+
rescue JSON::ParserError
|
|
230
|
+
{ "parse_error" => true, "snippet" => text.byteslice(0, 120) }
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def backoff(attempt)
|
|
234
|
+
base = 0.05 * (2**(attempt - 1))
|
|
235
|
+
jitter = @random.rand * 0.05
|
|
236
|
+
@sleeper.call(base + jitter)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def safe_message(error)
|
|
240
|
+
"#{error.class}: #{error.message.to_s.byteslice(0, 200)}"
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
class TransientError < ::Auditea::Error
|
|
244
|
+
attr_reader :status
|
|
245
|
+
|
|
246
|
+
def initialize(message, status: nil)
|
|
247
|
+
super(message)
|
|
248
|
+
@status = status
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Auditea
|
|
4
|
+
module Transport
|
|
5
|
+
module Limits
|
|
6
|
+
SINGLE_MAX_BYTES = 128 * 1024
|
|
7
|
+
BATCH_MAX_BYTES = 1024 * 1024
|
|
8
|
+
BATCH_MAX_EVENTS = 100
|
|
9
|
+
MAX_RESPONSE_BYTES = 64 * 1024
|
|
10
|
+
# Leave headroom for {"events":[...]} wrapper and commas.
|
|
11
|
+
BATCH_ENVELOPE_BUDGET = BATCH_MAX_BYTES - 256
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def event_bytes(event)
|
|
16
|
+
JSON.generate(event).bytesize
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def batch_body_bytes(events)
|
|
20
|
+
JSON.generate("events" => events).bytesize
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def oversized_event?(event)
|
|
24
|
+
event_bytes(event) > SINGLE_MAX_BYTES
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Auditea
|
|
4
|
+
module Transport
|
|
5
|
+
# Single worker + bounded queue. Process-aware for pre-fork servers.
|
|
6
|
+
# Replaces legacy Thread-per-event (REWRITE).
|
|
7
|
+
class Queue
|
|
8
|
+
DEFAULT_PID = -> { Process.pid }
|
|
9
|
+
DEFAULT_SLEEPER = ->(seconds) { sleep(seconds) }
|
|
10
|
+
|
|
11
|
+
def initialize(configuration, http:, pid_provider: DEFAULT_PID, sleeper: DEFAULT_SLEEPER)
|
|
12
|
+
@configuration = configuration
|
|
13
|
+
@http = http
|
|
14
|
+
@pid_provider = pid_provider
|
|
15
|
+
@sleeper = sleeper
|
|
16
|
+
@mutex = Mutex.new
|
|
17
|
+
@cv = ConditionVariable.new
|
|
18
|
+
@buffer = []
|
|
19
|
+
@stopped = false
|
|
20
|
+
@dropped = 0
|
|
21
|
+
@inflight = 0
|
|
22
|
+
@thread = nil
|
|
23
|
+
@owner_pid = nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def start!
|
|
27
|
+
return unless @configuration.async?
|
|
28
|
+
|
|
29
|
+
ensure_process!
|
|
30
|
+
@mutex.synchronize do
|
|
31
|
+
return if @thread&.alive?
|
|
32
|
+
|
|
33
|
+
@stopped = false
|
|
34
|
+
@thread = Thread.new { run_loop }
|
|
35
|
+
@thread.name = "auditea-delivery" if @thread.respond_to?(:name=)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def enqueue(event)
|
|
40
|
+
ensure_process!
|
|
41
|
+
if Limits.oversized_event?(event)
|
|
42
|
+
log_drop(event["event_id"], "payload_too_large")
|
|
43
|
+
return false
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
if @configuration.async?
|
|
47
|
+
start!
|
|
48
|
+
@mutex.synchronize do
|
|
49
|
+
if @buffer.size >= @configuration.queue_capacity
|
|
50
|
+
@dropped += 1
|
|
51
|
+
@configuration.logger&.warn("[auditea] queue full; dropping event_id=#{event['event_id']}")
|
|
52
|
+
return false
|
|
53
|
+
end
|
|
54
|
+
@buffer << event
|
|
55
|
+
@cv.signal
|
|
56
|
+
end
|
|
57
|
+
true
|
|
58
|
+
else
|
|
59
|
+
deliver_now([event])
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def flush(timeout: 5)
|
|
64
|
+
ensure_process!
|
|
65
|
+
deadline = monotonic + timeout
|
|
66
|
+
loop do
|
|
67
|
+
batch = nil
|
|
68
|
+
@mutex.synchronize do
|
|
69
|
+
batch = take_batch_locked
|
|
70
|
+
end
|
|
71
|
+
deliver_now(batch, reserved: true) if batch && !batch.empty?
|
|
72
|
+
|
|
73
|
+
done = @mutex.synchronize { @buffer.empty? && @inflight.zero? }
|
|
74
|
+
break if done || monotonic >= deadline
|
|
75
|
+
|
|
76
|
+
@mutex.synchronize do
|
|
77
|
+
remaining = deadline - monotonic
|
|
78
|
+
@cv.wait(@mutex, [remaining, 0.05].min) if remaining.positive? && (@inflight.positive? || !@buffer.empty?)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
@mutex.synchronize { @buffer.empty? && @inflight.zero? }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def shutdown
|
|
85
|
+
ensure_process!
|
|
86
|
+
@mutex.synchronize do
|
|
87
|
+
@stopped = true
|
|
88
|
+
@cv.broadcast
|
|
89
|
+
end
|
|
90
|
+
flush(timeout: 5)
|
|
91
|
+
thread = nil
|
|
92
|
+
@mutex.synchronize { thread = @thread }
|
|
93
|
+
thread&.join(2)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def stats
|
|
97
|
+
ensure_process!
|
|
98
|
+
@mutex.synchronize { { size: @buffer.size, dropped: @dropped, inflight: @inflight, pid: @owner_pid } }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def ensure_process!
|
|
104
|
+
pid = @pid_provider.call
|
|
105
|
+
@mutex.synchronize do
|
|
106
|
+
if @owner_pid.nil?
|
|
107
|
+
@owner_pid = pid
|
|
108
|
+
elsif @owner_pid != pid
|
|
109
|
+
# Child after fork: drop inherited buffer/worker; do not deliver parent data.
|
|
110
|
+
@buffer = []
|
|
111
|
+
@inflight = 0
|
|
112
|
+
@dropped = 0
|
|
113
|
+
@stopped = false
|
|
114
|
+
@thread = nil
|
|
115
|
+
@owner_pid = pid
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def run_loop
|
|
121
|
+
loop do
|
|
122
|
+
batch = nil
|
|
123
|
+
@mutex.synchronize do
|
|
124
|
+
@cv.wait(@mutex, @configuration.flush_interval) while @buffer.empty? && !@stopped
|
|
125
|
+
break if @stopped && @buffer.empty? && @inflight.zero?
|
|
126
|
+
|
|
127
|
+
batch = take_batch_locked
|
|
128
|
+
end
|
|
129
|
+
break if batch.nil? && @stopped && empty_and_idle?
|
|
130
|
+
|
|
131
|
+
deliver_now(batch, reserved: true) if batch && !batch.empty?
|
|
132
|
+
end
|
|
133
|
+
rescue StandardError => error
|
|
134
|
+
@configuration.logger&.warn("[auditea] worker error: #{error.class}: #{error.message.to_s.byteslice(0, 200)}")
|
|
135
|
+
retry unless @stopped
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def empty_and_idle?
|
|
139
|
+
@mutex.synchronize { @buffer.empty? && @inflight.zero? }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Caller must hold @mutex. Reserving in-flight ownership while removing the
|
|
143
|
+
# batch closes the gap where flush could observe an empty buffer before the
|
|
144
|
+
# worker had marked that batch as being delivered.
|
|
145
|
+
def take_batch_locked
|
|
146
|
+
return [] if @buffer.empty?
|
|
147
|
+
|
|
148
|
+
max_count = [@configuration.batch_size, Limits::BATCH_MAX_EVENTS].min
|
|
149
|
+
batch = []
|
|
150
|
+
while !@buffer.empty? && batch.size < max_count
|
|
151
|
+
candidate = @buffer.first
|
|
152
|
+
if Limits.oversized_event?(candidate)
|
|
153
|
+
dropped = @buffer.shift
|
|
154
|
+
@dropped += 1
|
|
155
|
+
log_drop(dropped["event_id"], "payload_too_large")
|
|
156
|
+
next
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
trial = batch + [candidate]
|
|
160
|
+
break if !batch.empty? && Limits.batch_body_bytes(trial) > Limits::BATCH_ENVELOPE_BUDGET
|
|
161
|
+
|
|
162
|
+
if batch.empty? && Limits.batch_body_bytes(trial) > Limits::BATCH_ENVELOPE_BUDGET
|
|
163
|
+
# Single event fits SINGLE_MAX but somehow exceeds batch budget with wrapper —
|
|
164
|
+
# still attempt as single-event POST path later if batch size becomes 1.
|
|
165
|
+
batch << @buffer.shift
|
|
166
|
+
break
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
batch << @buffer.shift
|
|
170
|
+
end
|
|
171
|
+
@inflight += 1 unless batch.empty?
|
|
172
|
+
batch
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def deliver_now(events, reserved: false)
|
|
176
|
+
return true if events.nil? || events.empty?
|
|
177
|
+
|
|
178
|
+
@mutex.synchronize { @inflight += 1 } unless reserved
|
|
179
|
+
begin
|
|
180
|
+
result =
|
|
181
|
+
if events.size == 1
|
|
182
|
+
@http.post_event(events.first)
|
|
183
|
+
else
|
|
184
|
+
@http.post_batch(events)
|
|
185
|
+
end
|
|
186
|
+
handle_result(events, result)
|
|
187
|
+
result
|
|
188
|
+
rescue StandardError => error
|
|
189
|
+
@configuration.logger&.warn("[auditea] delivery failed: #{error.class}: #{error.message.to_s.byteslice(0,
|
|
190
|
+
200)}")
|
|
191
|
+
raise if @configuration.raise_errors
|
|
192
|
+
|
|
193
|
+
false
|
|
194
|
+
ensure
|
|
195
|
+
@mutex.synchronize do
|
|
196
|
+
@inflight -= 1 if @inflight.positive?
|
|
197
|
+
@cv.broadcast
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def handle_result(events, result)
|
|
203
|
+
return unless result.is_a?(DeliveryResult)
|
|
204
|
+
|
|
205
|
+
if result.results
|
|
206
|
+
result.results.each do |row|
|
|
207
|
+
next if row["ok"]
|
|
208
|
+
|
|
209
|
+
@configuration.logger&.warn(
|
|
210
|
+
"[auditea] event_id=#{row['event_id']} outcome=#{row['outcome']} reason=batch_row"
|
|
211
|
+
)
|
|
212
|
+
end
|
|
213
|
+
elsif !result.ok?
|
|
214
|
+
@configuration.logger&.warn(
|
|
215
|
+
"[auditea] event_id=#{result.event_id || events.first&.dig('event_id')} " \
|
|
216
|
+
"outcome=#{result.outcome} reason=#{result.message}"
|
|
217
|
+
)
|
|
218
|
+
end
|
|
219
|
+
# Permanent and mixed batch outcomes are not re-queued (accepted siblings must not retry).
|
|
220
|
+
nil
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def log_drop(event_id, reason)
|
|
224
|
+
@configuration.logger&.warn("[auditea] event_id=#{event_id} reason=#{reason}")
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def monotonic
|
|
228
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
data/lib/auditea.rb
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "time"
|
|
6
|
+
require "timeout"
|
|
7
|
+
require "uri"
|
|
8
|
+
require "net/http"
|
|
9
|
+
require "logger"
|
|
10
|
+
require "openssl"
|
|
11
|
+
|
|
12
|
+
require_relative "auditea/version"
|
|
13
|
+
|
|
14
|
+
module Auditea
|
|
15
|
+
class Error < StandardError; end
|
|
16
|
+
class ConfigurationError < Error; end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
require_relative "auditea/configuration"
|
|
20
|
+
require_relative "auditea/sanitizer"
|
|
21
|
+
require_relative "auditea/event_builder"
|
|
22
|
+
require_relative "auditea/actor"
|
|
23
|
+
require_relative "auditea/target"
|
|
24
|
+
require_relative "auditea/current_context"
|
|
25
|
+
require_relative "auditea/transport/limits"
|
|
26
|
+
require_relative "auditea/transport/delivery_result"
|
|
27
|
+
require_relative "auditea/transport/http"
|
|
28
|
+
require_relative "auditea/transport/queue"
|
|
29
|
+
require_relative "auditea/client"
|
|
30
|
+
require_relative "auditea/log"
|
|
31
|
+
require_relative "auditea/instrumentation/request"
|
|
32
|
+
require_relative "auditea/instrumentation/exception"
|
|
33
|
+
require_relative "auditea/instrumentation/logger_adapter"
|
|
34
|
+
require_relative "auditea/instrumentation/active_job"
|
|
35
|
+
require_relative "auditea/middleware/context"
|
|
36
|
+
require_relative "auditea/railtie" if defined?(Rails::Railtie)
|
|
37
|
+
|
|
38
|
+
# AudiTea Ruby SDK — capture, structure, sanitize, enrich, transport.
|
|
39
|
+
# Taxonomy / dictionary / human labels remain server-side.
|
|
40
|
+
module Auditea
|
|
41
|
+
class << self
|
|
42
|
+
def configuration
|
|
43
|
+
@configuration ||= Configuration.new
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def configure
|
|
47
|
+
yield configuration
|
|
48
|
+
configuration.validate!
|
|
49
|
+
@client&.reset!
|
|
50
|
+
self
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def reset_configuration!
|
|
54
|
+
@configuration = Configuration.new
|
|
55
|
+
@client&.shutdown
|
|
56
|
+
@client = nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def client
|
|
60
|
+
@client ||= Client.new
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Explicit business / technical event capture.
|
|
64
|
+
#
|
|
65
|
+
# @param action [String] machine key, e.g. "project.approved"
|
|
66
|
+
# @return [Hash] delivery outcome
|
|
67
|
+
def capture(action, category: nil, severity: nil, actor: nil, target: nil,
|
|
68
|
+
context: nil, evidence: nil, provenance: nil, metadata: nil,
|
|
69
|
+
privacy: nil, occurred_at: nil, event_id: nil)
|
|
70
|
+
deliver_built(
|
|
71
|
+
EventBuilder.build(
|
|
72
|
+
action: action,
|
|
73
|
+
category: category,
|
|
74
|
+
severity: severity,
|
|
75
|
+
actor: actor,
|
|
76
|
+
target: target,
|
|
77
|
+
context: context,
|
|
78
|
+
evidence: evidence,
|
|
79
|
+
provenance: provenance,
|
|
80
|
+
metadata: metadata,
|
|
81
|
+
privacy: privacy,
|
|
82
|
+
occurred_at: occurred_at,
|
|
83
|
+
event_id: event_id
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
rescue StandardError => error
|
|
87
|
+
handle_error(error)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Alias kept for legacy familiarity (KEEP CONCEPT).
|
|
91
|
+
alias track capture
|
|
92
|
+
|
|
93
|
+
# Structured application log as a first-class event (not Rails.logger auto-forward).
|
|
94
|
+
def log(level, message, context: {}, metadata: {})
|
|
95
|
+
Log.record(level, message, context: context, metadata: metadata)
|
|
96
|
+
rescue StandardError => error
|
|
97
|
+
handle_error(error)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def flush(timeout: 5)
|
|
101
|
+
client.flush(timeout: timeout)
|
|
102
|
+
rescue StandardError => error
|
|
103
|
+
handle_error(error)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def shutdown
|
|
107
|
+
client.shutdown
|
|
108
|
+
rescue StandardError => error
|
|
109
|
+
handle_error(error)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def handle_error(error)
|
|
113
|
+
configuration.logger&.warn("[auditea] #{error.class}: #{error.message}")
|
|
114
|
+
raise error if configuration.raise_errors
|
|
115
|
+
|
|
116
|
+
{ ok: false, skipped: true, error: error.message }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
def deliver_built(event)
|
|
122
|
+
return { ok: false, skipped: true, reason: "disabled" } unless configuration.enabled?
|
|
123
|
+
return { ok: false, skipped: true, reason: "not_configured" } unless configuration.configured?
|
|
124
|
+
|
|
125
|
+
sanitized = Sanitizer.sanitize_event(event)
|
|
126
|
+
if Transport::Limits.oversized_event?(sanitized)
|
|
127
|
+
configuration.logger&.warn(
|
|
128
|
+
"[auditea] event_id=#{sanitized['event_id']} reason=payload_too_large"
|
|
129
|
+
)
|
|
130
|
+
return { ok: false, skipped: true, reason: "payload_too_large", event_id: sanitized["event_id"] }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
result = client.enqueue(sanitized)
|
|
134
|
+
if configuration.async?
|
|
135
|
+
if result
|
|
136
|
+
{ ok: true, event_id: sanitized["event_id"], queued: true }
|
|
137
|
+
else
|
|
138
|
+
{ ok: false, skipped: true, reason: "delivery_failed", event_id: sanitized["event_id"] }
|
|
139
|
+
end
|
|
140
|
+
elsif result.is_a?(Transport::DeliveryResult)
|
|
141
|
+
result.to_h.merge(event_id: sanitized["event_id"])
|
|
142
|
+
elsif result
|
|
143
|
+
{ ok: true, event_id: sanitized["event_id"] }
|
|
144
|
+
else
|
|
145
|
+
{ ok: false, skipped: true, reason: "delivery_failed", event_id: sanitized["event_id"] }
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|