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,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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Auditea
4
+ VERSION = "0.1.0.beta.1"
5
+ 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