dilisense_pep_client 0.1.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.
- checksums.yaml +7 -0
- data/.env.example +2 -0
- data/CLAUDE.md +141 -0
- data/LICENSE +21 -0
- data/Makefile +98 -0
- data/README.md +500 -0
- data/Rakefile +37 -0
- data/dilisense_pep_client.gemspec +51 -0
- data/lib/dilisense_pep_client/audit_logger.rb +653 -0
- data/lib/dilisense_pep_client/circuit_breaker.rb +257 -0
- data/lib/dilisense_pep_client/client.rb +254 -0
- data/lib/dilisense_pep_client/configuration.rb +15 -0
- data/lib/dilisense_pep_client/errors.rb +488 -0
- data/lib/dilisense_pep_client/logger.rb +207 -0
- data/lib/dilisense_pep_client/metrics.rb +505 -0
- data/lib/dilisense_pep_client/validator.rb +456 -0
- data/lib/dilisense_pep_client/version.rb +5 -0
- data/lib/dilisense_pep_client.rb +107 -0
- metadata +246 -0
@@ -0,0 +1,488 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module DilisensePepClient
|
4
|
+
# Base error class for all DilisensePepClient exceptions
|
5
|
+
# Provides enhanced error handling with context, timestamps, and request tracking
|
6
|
+
# All other error classes inherit from this base class
|
7
|
+
#
|
8
|
+
# @example Catching any gem error
|
9
|
+
# begin
|
10
|
+
# DilisensePepClient.check_individual(names: "John")
|
11
|
+
# rescue DilisensePepClient::Error => e
|
12
|
+
# puts "Error: #{e.message}"
|
13
|
+
# puts "Error code: #{e.error_code}"
|
14
|
+
# puts "Request ID: #{e.request_id}"
|
15
|
+
# end
|
16
|
+
class Error < StandardError
|
17
|
+
attr_reader :error_code, :context, :timestamp, :request_id
|
18
|
+
|
19
|
+
def initialize(message, error_code: nil, context: {}, request_id: nil)
|
20
|
+
super(message)
|
21
|
+
@error_code = error_code
|
22
|
+
@context = context || {}
|
23
|
+
@timestamp = Time.now.utc.iso8601
|
24
|
+
@request_id = request_id || generate_request_id
|
25
|
+
|
26
|
+
log_error
|
27
|
+
end
|
28
|
+
|
29
|
+
def to_h
|
30
|
+
{
|
31
|
+
error_type: self.class.name,
|
32
|
+
message: message,
|
33
|
+
error_code: error_code,
|
34
|
+
context: context,
|
35
|
+
timestamp: timestamp,
|
36
|
+
request_id: request_id,
|
37
|
+
backtrace: backtrace&.first(10)
|
38
|
+
}
|
39
|
+
end
|
40
|
+
|
41
|
+
def retryable?
|
42
|
+
false
|
43
|
+
end
|
44
|
+
|
45
|
+
def security_event?
|
46
|
+
false
|
47
|
+
end
|
48
|
+
|
49
|
+
private
|
50
|
+
|
51
|
+
def log_error
|
52
|
+
return unless defined?(DilisensePepClient::Logger)
|
53
|
+
|
54
|
+
severity = security_event? ? :error : :warn
|
55
|
+
|
56
|
+
DilisensePepClient::Logger.logger.send(severity, "#{self.class.name} raised", {
|
57
|
+
error_code: error_code,
|
58
|
+
message: message,
|
59
|
+
context: context,
|
60
|
+
request_id: request_id,
|
61
|
+
retryable: retryable?
|
62
|
+
})
|
63
|
+
end
|
64
|
+
|
65
|
+
def generate_request_id
|
66
|
+
require "securerandom"
|
67
|
+
SecureRandom.hex(8)
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
# Raised when there's a problem with gem configuration
|
72
|
+
# Usually means the API key is missing or invalid
|
73
|
+
#
|
74
|
+
# @example Common cause - missing API key
|
75
|
+
# # This will raise ConfigurationError if API key not set
|
76
|
+
# client = DilisensePepClient::Client.new
|
77
|
+
class ConfigurationError < Error
|
78
|
+
def initialize(message, config_key: nil, config_value: nil, **options)
|
79
|
+
context = {
|
80
|
+
config_key: config_key,
|
81
|
+
config_value: sanitize_config_value(config_value)
|
82
|
+
}.merge(options.fetch(:context, {}))
|
83
|
+
|
84
|
+
super(message, error_code: "CONFIG_ERROR", context: context, **options)
|
85
|
+
end
|
86
|
+
|
87
|
+
def security_event?
|
88
|
+
context[:config_key]&.to_s&.match?(/api_key|secret|token/)
|
89
|
+
end
|
90
|
+
|
91
|
+
private
|
92
|
+
|
93
|
+
def sanitize_config_value(value)
|
94
|
+
return "[REDACTED]" if value.to_s.match?(/api_key|secret|token|password/i)
|
95
|
+
value.to_s.length > 50 ? "#{value.to_s[0..10]}..." : value
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
# Raised when the API returns an error response
|
100
|
+
# Contains HTTP status code, response body, and headers for debugging
|
101
|
+
#
|
102
|
+
# @example Handling API errors
|
103
|
+
# begin
|
104
|
+
# results = client.check_individual(names: "Test")
|
105
|
+
# rescue DilisensePepClient::APIError => e
|
106
|
+
# puts "API error: #{e.message}"
|
107
|
+
# puts "Status: #{e.status}"
|
108
|
+
# puts "Retryable? #{e.retryable?}"
|
109
|
+
# end
|
110
|
+
class APIError < Error
|
111
|
+
attr_reader :status, :body, :headers
|
112
|
+
|
113
|
+
def initialize(message, status: nil, body: nil, headers: {}, **options)
|
114
|
+
@status = status
|
115
|
+
@body = sanitize_body(body)
|
116
|
+
@headers = sanitize_headers(headers)
|
117
|
+
|
118
|
+
context = {
|
119
|
+
status: status,
|
120
|
+
response_size: body&.to_s&.length,
|
121
|
+
endpoint: options[:endpoint]
|
122
|
+
}.merge(options.fetch(:context, {}))
|
123
|
+
|
124
|
+
error_code = determine_error_code(status)
|
125
|
+
|
126
|
+
super(message, error_code: error_code, context: context, **options)
|
127
|
+
end
|
128
|
+
|
129
|
+
def retryable?
|
130
|
+
case status
|
131
|
+
when 429, 502, 503, 504 then true
|
132
|
+
when 500..599 then true
|
133
|
+
else false
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
def client_error?
|
138
|
+
status.to_i.between?(400, 499)
|
139
|
+
end
|
140
|
+
|
141
|
+
def server_error?
|
142
|
+
status.to_i.between?(500, 599)
|
143
|
+
end
|
144
|
+
|
145
|
+
private
|
146
|
+
|
147
|
+
def determine_error_code(status)
|
148
|
+
case status
|
149
|
+
when 400 then "BAD_REQUEST"
|
150
|
+
when 401 then "UNAUTHORIZED"
|
151
|
+
when 403 then "FORBIDDEN"
|
152
|
+
when 404 then "NOT_FOUND"
|
153
|
+
when 429 then "RATE_LIMITED"
|
154
|
+
when 500 then "INTERNAL_SERVER_ERROR"
|
155
|
+
when 502 then "BAD_GATEWAY"
|
156
|
+
when 503 then "SERVICE_UNAVAILABLE"
|
157
|
+
when 504 then "GATEWAY_TIMEOUT"
|
158
|
+
else "API_ERROR"
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
def sanitize_body(body)
|
163
|
+
return nil unless body
|
164
|
+
body_str = body.to_s
|
165
|
+
return body_str if body_str.length <= 1000
|
166
|
+
"#{body_str[0..1000]}... (truncated)"
|
167
|
+
end
|
168
|
+
|
169
|
+
def sanitize_headers(headers)
|
170
|
+
return {} unless headers.is_a?(Hash)
|
171
|
+
|
172
|
+
headers.transform_values do |value|
|
173
|
+
case value.to_s.downcase
|
174
|
+
when /authorization|api.?key|token|secret/
|
175
|
+
"[REDACTED]"
|
176
|
+
else
|
177
|
+
value.to_s.length > 100 ? "#{value.to_s[0..100]}..." : value
|
178
|
+
end
|
179
|
+
end
|
180
|
+
end
|
181
|
+
end
|
182
|
+
|
183
|
+
# Raised when there's a network problem (timeout, connection failure, etc.)
|
184
|
+
# These errors are usually retryable after a delay
|
185
|
+
#
|
186
|
+
# @example Network timeout
|
187
|
+
# begin
|
188
|
+
# results = client.check_individual(names: "Test")
|
189
|
+
# rescue DilisensePepClient::NetworkError => e
|
190
|
+
# puts "Network problem: #{e.message}"
|
191
|
+
# # Usually safe to retry after a delay
|
192
|
+
# end
|
193
|
+
class NetworkError < Error
|
194
|
+
def initialize(message, network_error: nil, **options)
|
195
|
+
context = {
|
196
|
+
network_error_class: network_error&.class&.name,
|
197
|
+
network_error_message: network_error&.message
|
198
|
+
}.merge(options.fetch(:context, {}))
|
199
|
+
|
200
|
+
super(message, error_code: "NETWORK_ERROR", context: context, **options)
|
201
|
+
end
|
202
|
+
|
203
|
+
def retryable?
|
204
|
+
true
|
205
|
+
end
|
206
|
+
end
|
207
|
+
|
208
|
+
# Raised specifically for authentication failures (401 status)
|
209
|
+
# Usually means the API key is invalid or has been revoked
|
210
|
+
#
|
211
|
+
# @example Invalid API key
|
212
|
+
# # This raises AuthenticationError for invalid API key
|
213
|
+
# DilisensePepClient.configure do |config|
|
214
|
+
# config.api_key = "invalid_key"
|
215
|
+
# end
|
216
|
+
# DilisensePepClient.check_individual(names: "Test") # => AuthenticationError
|
217
|
+
class AuthenticationError < APIError
|
218
|
+
def initialize(message, **options)
|
219
|
+
super(message, error_code: "AUTH_ERROR", **options)
|
220
|
+
end
|
221
|
+
|
222
|
+
def security_event?
|
223
|
+
true
|
224
|
+
end
|
225
|
+
|
226
|
+
def retryable?
|
227
|
+
false # Auth errors should not be retried automatically
|
228
|
+
end
|
229
|
+
end
|
230
|
+
|
231
|
+
# Raised when input parameters fail validation
|
232
|
+
# Contains details about which validation rules failed
|
233
|
+
#
|
234
|
+
# @example Invalid parameters
|
235
|
+
# # This raises ValidationError - can't use both parameters
|
236
|
+
# client.check_individual(names: "John", search_all: "John")
|
237
|
+
#
|
238
|
+
# @example Missing required parameters
|
239
|
+
# # This raises ValidationError - need at least one search param
|
240
|
+
# client.check_individual()
|
241
|
+
class ValidationError < Error
|
242
|
+
attr_reader :validation_errors
|
243
|
+
|
244
|
+
def initialize(message, validation_errors: [], field: nil, **options)
|
245
|
+
@validation_errors = Array(validation_errors)
|
246
|
+
|
247
|
+
context = {
|
248
|
+
field: field,
|
249
|
+
validation_errors: @validation_errors,
|
250
|
+
error_count: @validation_errors.size
|
251
|
+
}.merge(options.fetch(:context, {}))
|
252
|
+
|
253
|
+
super(message, error_code: "VALIDATION_ERROR", context: context, **options)
|
254
|
+
end
|
255
|
+
|
256
|
+
def retryable?
|
257
|
+
false # Validation errors require user intervention
|
258
|
+
end
|
259
|
+
end
|
260
|
+
|
261
|
+
# Rate limiting errors
|
262
|
+
class RateLimitError < APIError
|
263
|
+
attr_reader :retry_after, :limit, :remaining
|
264
|
+
|
265
|
+
def initialize(message, retry_after: nil, limit: nil, remaining: nil, **options)
|
266
|
+
@retry_after = retry_after
|
267
|
+
@limit = limit
|
268
|
+
@remaining = remaining
|
269
|
+
|
270
|
+
context = {
|
271
|
+
retry_after: retry_after,
|
272
|
+
rate_limit: limit,
|
273
|
+
rate_remaining: remaining,
|
274
|
+
reset_time: retry_after ? Time.now + retry_after : nil
|
275
|
+
}.merge(options.fetch(:context, {}))
|
276
|
+
|
277
|
+
super(message, error_code: "RATE_LIMITED", context: context, **options)
|
278
|
+
end
|
279
|
+
|
280
|
+
def retryable?
|
281
|
+
true
|
282
|
+
end
|
283
|
+
|
284
|
+
def suggested_retry_delay
|
285
|
+
@retry_after || 60 # Default to 60 seconds if not specified
|
286
|
+
end
|
287
|
+
end
|
288
|
+
|
289
|
+
# Timeout-related errors
|
290
|
+
class TimeoutError < NetworkError
|
291
|
+
attr_reader :timeout_duration
|
292
|
+
|
293
|
+
def initialize(message, timeout_duration: nil, **options)
|
294
|
+
@timeout_duration = timeout_duration
|
295
|
+
|
296
|
+
context = {
|
297
|
+
timeout_duration: timeout_duration,
|
298
|
+
timeout_type: determine_timeout_type(message)
|
299
|
+
}.merge(options.fetch(:context, {}))
|
300
|
+
|
301
|
+
super(message, error_code: "TIMEOUT_ERROR", context: context, **options)
|
302
|
+
end
|
303
|
+
|
304
|
+
def retryable?
|
305
|
+
true
|
306
|
+
end
|
307
|
+
|
308
|
+
private
|
309
|
+
|
310
|
+
def determine_timeout_type(message)
|
311
|
+
case message.downcase
|
312
|
+
when /connection/ then "connection_timeout"
|
313
|
+
when /read/ then "read_timeout"
|
314
|
+
when /write/ then "write_timeout"
|
315
|
+
else "general_timeout"
|
316
|
+
end
|
317
|
+
end
|
318
|
+
end
|
319
|
+
|
320
|
+
# Circuit breaker errors (from our circuit breaker implementation)
|
321
|
+
class CircuitBreakerError < Error
|
322
|
+
attr_reader :service_name, :circuit_state, :next_attempt_time
|
323
|
+
|
324
|
+
def initialize(message, service_name:, circuit_state:, next_attempt_time: nil, **options)
|
325
|
+
@service_name = service_name
|
326
|
+
@circuit_state = circuit_state
|
327
|
+
@next_attempt_time = next_attempt_time
|
328
|
+
|
329
|
+
context = {
|
330
|
+
service_name: service_name,
|
331
|
+
circuit_state: circuit_state,
|
332
|
+
next_attempt_time: next_attempt_time
|
333
|
+
}.merge(options.fetch(:context, {}))
|
334
|
+
|
335
|
+
super(message, error_code: "CIRCUIT_BREAKER_OPEN", context: context, **options)
|
336
|
+
end
|
337
|
+
|
338
|
+
def retryable?
|
339
|
+
circuit_state == :half_open
|
340
|
+
end
|
341
|
+
|
342
|
+
def security_event?
|
343
|
+
true # Circuit breaker events are significant for monitoring
|
344
|
+
end
|
345
|
+
end
|
346
|
+
|
347
|
+
# Data processing errors
|
348
|
+
class DataProcessingError < Error
|
349
|
+
attr_reader :data_type, :processing_stage
|
350
|
+
|
351
|
+
def initialize(message, data_type: nil, processing_stage: nil, **options)
|
352
|
+
@data_type = data_type
|
353
|
+
@processing_stage = processing_stage
|
354
|
+
|
355
|
+
context = {
|
356
|
+
data_type: data_type,
|
357
|
+
processing_stage: processing_stage
|
358
|
+
}.merge(options.fetch(:context, {}))
|
359
|
+
|
360
|
+
super(message, error_code: "DATA_PROCESSING_ERROR", context: context, **options)
|
361
|
+
end
|
362
|
+
end
|
363
|
+
|
364
|
+
# Compliance and audit-related errors
|
365
|
+
class ComplianceError < Error
|
366
|
+
attr_reader :compliance_rule, :severity_level
|
367
|
+
|
368
|
+
def initialize(message, compliance_rule:, severity_level: :medium, **options)
|
369
|
+
@compliance_rule = compliance_rule
|
370
|
+
@severity_level = severity_level
|
371
|
+
|
372
|
+
context = {
|
373
|
+
compliance_rule: compliance_rule,
|
374
|
+
severity_level: severity_level,
|
375
|
+
requires_escalation: severity_level == :critical
|
376
|
+
}.merge(options.fetch(:context, {}))
|
377
|
+
|
378
|
+
super(message, error_code: "COMPLIANCE_VIOLATION", context: context, **options)
|
379
|
+
end
|
380
|
+
|
381
|
+
def security_event?
|
382
|
+
true
|
383
|
+
end
|
384
|
+
|
385
|
+
def critical?
|
386
|
+
severity_level == :critical
|
387
|
+
end
|
388
|
+
end
|
389
|
+
|
390
|
+
# Error factory for consistent error creation
|
391
|
+
class ErrorFactory
|
392
|
+
class << self
|
393
|
+
def create_from_response(response, endpoint: nil, request_id: nil)
|
394
|
+
case response.status
|
395
|
+
when 401
|
396
|
+
AuthenticationError.new(
|
397
|
+
"API authentication failed",
|
398
|
+
status: response.status,
|
399
|
+
body: response.body,
|
400
|
+
headers: response.headers,
|
401
|
+
endpoint: endpoint,
|
402
|
+
request_id: request_id
|
403
|
+
)
|
404
|
+
when 429
|
405
|
+
retry_after = extract_retry_after(response.headers)
|
406
|
+
RateLimitError.new(
|
407
|
+
"API rate limit exceeded",
|
408
|
+
status: response.status,
|
409
|
+
body: response.body,
|
410
|
+
headers: response.headers,
|
411
|
+
retry_after: retry_after,
|
412
|
+
endpoint: endpoint,
|
413
|
+
request_id: request_id
|
414
|
+
)
|
415
|
+
when 400..499
|
416
|
+
APIError.new(
|
417
|
+
"Client error: #{response.status}",
|
418
|
+
status: response.status,
|
419
|
+
body: response.body,
|
420
|
+
headers: response.headers,
|
421
|
+
endpoint: endpoint,
|
422
|
+
request_id: request_id
|
423
|
+
)
|
424
|
+
when 500..599
|
425
|
+
APIError.new(
|
426
|
+
"Server error: #{response.status}",
|
427
|
+
status: response.status,
|
428
|
+
body: response.body,
|
429
|
+
headers: response.headers,
|
430
|
+
endpoint: endpoint,
|
431
|
+
request_id: request_id
|
432
|
+
)
|
433
|
+
else
|
434
|
+
APIError.new(
|
435
|
+
"Unexpected response: #{response.status}",
|
436
|
+
status: response.status,
|
437
|
+
body: response.body,
|
438
|
+
headers: response.headers,
|
439
|
+
endpoint: endpoint,
|
440
|
+
request_id: request_id
|
441
|
+
)
|
442
|
+
end
|
443
|
+
end
|
444
|
+
|
445
|
+
def create_network_error(original_error, context: {})
|
446
|
+
case original_error
|
447
|
+
when ::Faraday::TimeoutError
|
448
|
+
TimeoutError.new(
|
449
|
+
"Request timeout",
|
450
|
+
network_error: original_error,
|
451
|
+
context: context
|
452
|
+
)
|
453
|
+
when ::Faraday::ConnectionFailed
|
454
|
+
NetworkError.new(
|
455
|
+
"Connection failed",
|
456
|
+
network_error: original_error,
|
457
|
+
context: context
|
458
|
+
)
|
459
|
+
else
|
460
|
+
NetworkError.new(
|
461
|
+
"Network error: #{original_error.class.name}",
|
462
|
+
network_error: original_error,
|
463
|
+
context: context
|
464
|
+
)
|
465
|
+
end
|
466
|
+
end
|
467
|
+
|
468
|
+
private
|
469
|
+
|
470
|
+
def extract_retry_after(headers)
|
471
|
+
retry_after = headers["retry-after"] || headers["Retry-After"]
|
472
|
+
return nil unless retry_after
|
473
|
+
|
474
|
+
# Handle both seconds and HTTP-date formats
|
475
|
+
if retry_after.match?(/^\d+$/)
|
476
|
+
retry_after.to_i
|
477
|
+
else
|
478
|
+
# Parse HTTP-date and calculate seconds until then
|
479
|
+
begin
|
480
|
+
Time.parse(retry_after) - Time.now
|
481
|
+
rescue ArgumentError
|
482
|
+
nil
|
483
|
+
end
|
484
|
+
end
|
485
|
+
end
|
486
|
+
end
|
487
|
+
end
|
488
|
+
end
|
@@ -0,0 +1,207 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "semantic_logger"
|
4
|
+
require "json"
|
5
|
+
|
6
|
+
module DilisensePepClient
|
7
|
+
# Industrial-grade structured logging system for compliance and monitoring
|
8
|
+
#
|
9
|
+
# This class provides enterprise-level logging capabilities specifically designed for
|
10
|
+
# financial services and FinTech applications that require comprehensive audit trails,
|
11
|
+
# security monitoring, and regulatory compliance.
|
12
|
+
#
|
13
|
+
# Features:
|
14
|
+
# - Structured JSON logging in production environments
|
15
|
+
# - Automatic PII anonymization and data sanitization
|
16
|
+
# - Security event classification and severity levels
|
17
|
+
# - Compliance-ready audit trail generation
|
18
|
+
# - Request correlation through unique request IDs
|
19
|
+
# - Environment-specific log levels and formatting
|
20
|
+
# - Integration with SemanticLogger for enterprise features
|
21
|
+
#
|
22
|
+
# The logger automatically handles different environments:
|
23
|
+
# - Production: JSON structured logs to stdout, INFO level
|
24
|
+
# - Staging: Colored logs to stdout, INFO level
|
25
|
+
# - Development: Colored logs to stdout, DEBUG level
|
26
|
+
#
|
27
|
+
# All sensitive data (API keys, tokens, PII) is automatically sanitized before logging.
|
28
|
+
# Security events are classified by severity and logged with appropriate detail levels.
|
29
|
+
#
|
30
|
+
# @example Basic API request logging
|
31
|
+
# Logger.log_api_request(
|
32
|
+
# endpoint: "/v1/checkIndividual",
|
33
|
+
# params: { names: "John Smith" },
|
34
|
+
# duration: 1.5,
|
35
|
+
# response_status: 200
|
36
|
+
# )
|
37
|
+
#
|
38
|
+
# @example Security event logging
|
39
|
+
# Logger.log_security_event(
|
40
|
+
# event_type: "authentication_failure",
|
41
|
+
# details: { user_id: "user123", reason: "invalid_api_key" },
|
42
|
+
# severity: :high
|
43
|
+
# )
|
44
|
+
class Logger
|
45
|
+
class << self
|
46
|
+
# Initialize the logging system with environment-specific configuration
|
47
|
+
# Sets up SemanticLogger with appropriate appenders, formatters, and log levels
|
48
|
+
#
|
49
|
+
# @param environment [String] The application environment (production, staging, development)
|
50
|
+
# @return [SemanticLogger::Logger] Configured logger instance
|
51
|
+
def setup!(environment = ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "development")
|
52
|
+
configure_semantic_logger(environment)
|
53
|
+
@logger = SemanticLogger["DilisensePepClient"]
|
54
|
+
end
|
55
|
+
|
56
|
+
# Get the configured logger instance, initializing if necessary
|
57
|
+
# Uses lazy initialization to ensure logger is configured when first accessed
|
58
|
+
#
|
59
|
+
# @return [SemanticLogger::Logger] The configured logger instance
|
60
|
+
def logger
|
61
|
+
@logger ||= setup!
|
62
|
+
end
|
63
|
+
|
64
|
+
# Log API requests for performance monitoring and debugging
|
65
|
+
# Creates structured logs with sanitized parameters and error details
|
66
|
+
#
|
67
|
+
# @param endpoint [String] The API endpoint being called (e.g., "/v1/checkIndividual")
|
68
|
+
# @param params [Hash] Request parameters (will be sanitized automatically)
|
69
|
+
# @param duration [Float, nil] Request duration in seconds
|
70
|
+
# @param response_status [Integer, nil] HTTP response status code
|
71
|
+
# @param error [Exception, nil] Error object if request failed
|
72
|
+
# @param request_id [String, nil] Unique request identifier (generated if not provided)
|
73
|
+
# @return [Hash] The structured log payload
|
74
|
+
def log_api_request(endpoint:, params:, duration: nil, response_status: nil, error: nil, request_id: nil)
|
75
|
+
payload = {
|
76
|
+
event_type: "api_request",
|
77
|
+
endpoint: endpoint,
|
78
|
+
params: sanitize_params(params), # Remove sensitive data from parameters
|
79
|
+
duration_ms: duration&.*(1000)&.round(2), # Convert to milliseconds for easier reading
|
80
|
+
response_status: response_status,
|
81
|
+
request_id: request_id || generate_request_id,
|
82
|
+
timestamp: Time.now.iso8601,
|
83
|
+
environment: Rails.env rescue "unknown"
|
84
|
+
}
|
85
|
+
|
86
|
+
if error
|
87
|
+
# Include error details for failed requests
|
88
|
+
payload[:error] = {
|
89
|
+
class: error.class.name,
|
90
|
+
message: error.message,
|
91
|
+
backtrace: error.backtrace&.first(5) # Limited backtrace for log size management
|
92
|
+
}
|
93
|
+
logger.error("API request failed", payload)
|
94
|
+
else
|
95
|
+
logger.info("API request completed", payload)
|
96
|
+
end
|
97
|
+
|
98
|
+
payload
|
99
|
+
end
|
100
|
+
|
101
|
+
def log_screening_event(type:, query:, results_count:, duration: nil, user_id: nil, request_id: nil)
|
102
|
+
payload = {
|
103
|
+
event_type: "screening_event",
|
104
|
+
screening_type: type,
|
105
|
+
query_hash: hash_pii(query),
|
106
|
+
results_count: results_count,
|
107
|
+
duration_ms: duration&.*(1000)&.round(2),
|
108
|
+
user_id: user_id,
|
109
|
+
request_id: request_id || generate_request_id,
|
110
|
+
timestamp: Time.now.iso8601,
|
111
|
+
compliance_metadata: {
|
112
|
+
retention_category: "pep_screening",
|
113
|
+
data_classification: "restricted"
|
114
|
+
}
|
115
|
+
}
|
116
|
+
|
117
|
+
logger.info("Screening completed", payload)
|
118
|
+
payload
|
119
|
+
end
|
120
|
+
|
121
|
+
def log_configuration_change(config_key:, old_value:, new_value:, user_id: nil)
|
122
|
+
payload = {
|
123
|
+
event_type: "configuration_change",
|
124
|
+
config_key: config_key,
|
125
|
+
old_value: sanitize_config_value(old_value),
|
126
|
+
new_value: sanitize_config_value(new_value),
|
127
|
+
user_id: user_id,
|
128
|
+
timestamp: Time.now.iso8601
|
129
|
+
}
|
130
|
+
|
131
|
+
logger.warn("Configuration changed", payload)
|
132
|
+
payload
|
133
|
+
end
|
134
|
+
|
135
|
+
def log_security_event(event_type:, details:, severity: :medium, user_id: nil)
|
136
|
+
payload = {
|
137
|
+
event_type: "security_event",
|
138
|
+
security_event_type: event_type,
|
139
|
+
severity: severity,
|
140
|
+
details: details,
|
141
|
+
user_id: user_id,
|
142
|
+
timestamp: Time.now.iso8601,
|
143
|
+
source_ip: Thread.current[:request_ip]
|
144
|
+
}
|
145
|
+
|
146
|
+
case severity
|
147
|
+
when :critical
|
148
|
+
logger.fatal("Critical security event", payload)
|
149
|
+
when :high
|
150
|
+
logger.error("High severity security event", payload)
|
151
|
+
when :medium
|
152
|
+
logger.warn("Medium severity security event", payload)
|
153
|
+
else
|
154
|
+
logger.info("Security event", payload)
|
155
|
+
end
|
156
|
+
|
157
|
+
payload
|
158
|
+
end
|
159
|
+
|
160
|
+
private
|
161
|
+
|
162
|
+
def configure_semantic_logger(environment)
|
163
|
+
SemanticLogger.application = "DilisensePepClient"
|
164
|
+
|
165
|
+
case environment.to_s.downcase
|
166
|
+
when "production"
|
167
|
+
SemanticLogger.default_level = :info
|
168
|
+
SemanticLogger.add_appender(io: $stdout, formatter: :json)
|
169
|
+
when "staging"
|
170
|
+
SemanticLogger.default_level = :info
|
171
|
+
SemanticLogger.add_appender(io: $stdout, formatter: :color)
|
172
|
+
else
|
173
|
+
SemanticLogger.default_level = :debug
|
174
|
+
SemanticLogger.add_appender(io: $stdout, formatter: :color)
|
175
|
+
end
|
176
|
+
end
|
177
|
+
|
178
|
+
def sanitize_params(params)
|
179
|
+
return {} unless params.is_a?(Hash)
|
180
|
+
|
181
|
+
params.transform_values do |value|
|
182
|
+
case value.to_s.downcase
|
183
|
+
when /api_key|token|secret|password/
|
184
|
+
"[REDACTED]"
|
185
|
+
else
|
186
|
+
value.is_a?(String) && value.length > 100 ? "#{value[0..100]}..." : value
|
187
|
+
end
|
188
|
+
end
|
189
|
+
end
|
190
|
+
|
191
|
+
def sanitize_config_value(value)
|
192
|
+
return "[REDACTED]" if value.to_s.match?(/api_key|token|secret|password/i)
|
193
|
+
value.to_s.length > 50 ? "#{value.to_s[0..50]}..." : value
|
194
|
+
end
|
195
|
+
|
196
|
+
def hash_pii(data)
|
197
|
+
require "digest"
|
198
|
+
Digest::SHA256.hexdigest(data.to_s)[0..16]
|
199
|
+
end
|
200
|
+
|
201
|
+
def generate_request_id
|
202
|
+
require "securerandom"
|
203
|
+
SecureRandom.hex(8)
|
204
|
+
end
|
205
|
+
end
|
206
|
+
end
|
207
|
+
end
|