auditry 0.1.0__py3-none-any.whl
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.
- auditry/__init__.py +19 -0
- auditry/correlation.py +21 -0
- auditry/logging_config.py +63 -0
- auditry/middleware.py +355 -0
- auditry/models.py +71 -0
- auditry/py.typed +0 -0
- auditry/redaction.py +144 -0
- auditry/unified_middleware.py +74 -0
- auditry-0.1.0.dist-info/LICENSE +21 -0
- auditry-0.1.0.dist-info/METADATA +392 -0
- auditry-0.1.0.dist-info/RECORD +13 -0
- auditry-0.1.0.dist-info/WHEEL +5 -0
- auditry-0.1.0.dist-info/top_level.txt +1 -0
auditry/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from .logging_config import configure_logging, get_logger
|
|
2
|
+
from .correlation import get_correlation_id
|
|
3
|
+
from .unified_middleware import ObservabilityMiddleware
|
|
4
|
+
from .models import ObservabilityConfig, BusinessEventConfig
|
|
5
|
+
|
|
6
|
+
__version__ = "0.1.0"
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
# Logging configuration
|
|
10
|
+
"configure_logging",
|
|
11
|
+
"get_logger",
|
|
12
|
+
# Middleware
|
|
13
|
+
"ObservabilityMiddleware",
|
|
14
|
+
# Configuration
|
|
15
|
+
"ObservabilityConfig",
|
|
16
|
+
"BusinessEventConfig",
|
|
17
|
+
# Utilities
|
|
18
|
+
"get_correlation_id",
|
|
19
|
+
]
|
auditry/correlation.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Correlation ID management using asgi-correlation-id.
|
|
3
|
+
|
|
4
|
+
Provides a simple function to get the current correlation ID from context.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from asgi_correlation_id import correlation_id
|
|
10
|
+
|
|
11
|
+
__all__ = ["get_correlation_id"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_correlation_id() -> Optional[str]:
|
|
15
|
+
"""
|
|
16
|
+
Get the current correlation ID from context.
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
The correlation ID string, or None if not set
|
|
20
|
+
"""
|
|
21
|
+
return correlation_id.get()
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Structured logging configuration using structlog.
|
|
3
|
+
|
|
4
|
+
This module provides JSON-formatted logging that integrates with
|
|
5
|
+
correlation IDs and is optimized for log aggregators like Datadog.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import structlog
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def configure_logging(level: str = "INFO") -> None:
|
|
13
|
+
"""
|
|
14
|
+
Configure application-wide structured logging using structlog.
|
|
15
|
+
|
|
16
|
+
Sets up JSON-formatted logging with timestamps, log levels, and
|
|
17
|
+
correlation IDs. Should be called once at application startup.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
|
21
|
+
"""
|
|
22
|
+
# Configure structlog processors
|
|
23
|
+
structlog.configure(
|
|
24
|
+
processors=[
|
|
25
|
+
# Add log level to event dict
|
|
26
|
+
structlog.stdlib.add_log_level,
|
|
27
|
+
# Add timestamp in ISO format
|
|
28
|
+
structlog.processors.TimeStamper(fmt="iso"),
|
|
29
|
+
# Add correlation_id from context if available
|
|
30
|
+
structlog.contextvars.merge_contextvars,
|
|
31
|
+
# Format exceptions
|
|
32
|
+
structlog.processors.format_exc_info,
|
|
33
|
+
# Render as JSON
|
|
34
|
+
structlog.processors.JSONRenderer(),
|
|
35
|
+
],
|
|
36
|
+
# Use standard library logging
|
|
37
|
+
wrapper_class=structlog.stdlib.BoundLogger,
|
|
38
|
+
logger_factory=structlog.stdlib.LoggerFactory(),
|
|
39
|
+
cache_logger_on_first_use=True,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Configure standard library logging
|
|
43
|
+
logging.basicConfig(
|
|
44
|
+
format="%(message)s",
|
|
45
|
+
level=getattr(logging, level.upper()),
|
|
46
|
+
force=True,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_logger(name: str) -> structlog.stdlib.BoundLogger:
|
|
51
|
+
"""
|
|
52
|
+
Get a structlog logger instance.
|
|
53
|
+
|
|
54
|
+
This logger automatically includes correlation IDs and outputs
|
|
55
|
+
structured JSON logs.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
name: Logger name (typically __name__ of the module)
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Configured structlog logger
|
|
62
|
+
"""
|
|
63
|
+
return structlog.get_logger(name)
|
auditry/middleware.py
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Request/Response logging middleware with sensitive data redaction.
|
|
3
|
+
|
|
4
|
+
This middleware integrates with asgi-correlation-id for correlation tracking
|
|
5
|
+
and uses structlog for structured logging output.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
import time
|
|
11
|
+
from typing import Any, Dict, Optional
|
|
12
|
+
|
|
13
|
+
import structlog
|
|
14
|
+
from fastapi import Request, Response
|
|
15
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
16
|
+
from starlette.responses import StreamingResponse
|
|
17
|
+
|
|
18
|
+
from .correlation import get_correlation_id
|
|
19
|
+
from .redaction import redact_data, redact_headers
|
|
20
|
+
from .models import ObservabilityConfig
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
logger = structlog.get_logger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class RequestResponseLoggingMiddleware(BaseHTTPMiddleware):
|
|
27
|
+
"""
|
|
28
|
+
Middleware to automatically log all request/response activity.
|
|
29
|
+
|
|
30
|
+
Captures comprehensive request and response data including payloads,
|
|
31
|
+
headers, timing, and user context. Integrates with asgi-correlation-id
|
|
32
|
+
for distributed tracing.
|
|
33
|
+
|
|
34
|
+
Note: This middleware should be added AFTER CorrelationIdMiddleware
|
|
35
|
+
from asgi-correlation-id to ensure correlation IDs are available.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, app, config: Optional[ObservabilityConfig] = None):
|
|
39
|
+
"""
|
|
40
|
+
Initialize logging middleware.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
app: FastAPI application instance
|
|
44
|
+
config: Optional configuration for logging behavior
|
|
45
|
+
"""
|
|
46
|
+
super().__init__(app)
|
|
47
|
+
self.config = config or ObservabilityConfig()
|
|
48
|
+
|
|
49
|
+
async def dispatch(self, request: Request, call_next):
|
|
50
|
+
"""
|
|
51
|
+
Process request/response cycle with comprehensive logging.
|
|
52
|
+
|
|
53
|
+
Captures request details, processes through application handlers,
|
|
54
|
+
captures response, and logs complete interaction with timing.
|
|
55
|
+
|
|
56
|
+
Logging strategy:
|
|
57
|
+
- On success: Log once with both request and response data
|
|
58
|
+
- On failure: Log the request data with error details
|
|
59
|
+
"""
|
|
60
|
+
# Get correlation ID from context (set by CorrelationIdMiddleware)
|
|
61
|
+
correlation_id = get_correlation_id()
|
|
62
|
+
|
|
63
|
+
execution_start_time = time.time()
|
|
64
|
+
|
|
65
|
+
request_data = await self._capture_request(request, correlation_id)
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
# Process the request
|
|
69
|
+
response = await call_next(request)
|
|
70
|
+
|
|
71
|
+
execution_duration_ms = (time.time() - execution_start_time) * 1000
|
|
72
|
+
|
|
73
|
+
# Capture response details
|
|
74
|
+
response_data = await self._capture_response(response, execution_duration_ms)
|
|
75
|
+
|
|
76
|
+
# Add correlation ID to response headers
|
|
77
|
+
if correlation_id:
|
|
78
|
+
response.headers[self.config.correlation_id_header] = correlation_id
|
|
79
|
+
|
|
80
|
+
# Check if this is a business event and extract context
|
|
81
|
+
business_event_data = self._extract_business_event(
|
|
82
|
+
request, request_data, response_data
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# Log successful request completion with both request and response data
|
|
86
|
+
log_data = {
|
|
87
|
+
"service": self.config.service_name,
|
|
88
|
+
"request": request_data,
|
|
89
|
+
"response": response_data,
|
|
90
|
+
"execution_duration_ms": execution_duration_ms,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
# Add business event data if this endpoint is tracked
|
|
94
|
+
if business_event_data:
|
|
95
|
+
log_data.update(business_event_data)
|
|
96
|
+
|
|
97
|
+
logger.info(
|
|
98
|
+
f"Request completed: {request.method} {request.url.path} - "
|
|
99
|
+
f"Status: {response.status_code} - Duration: {execution_duration_ms:.2f}ms",
|
|
100
|
+
**log_data,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
return response
|
|
104
|
+
|
|
105
|
+
except Exception as e:
|
|
106
|
+
execution_duration_ms = (time.time() - execution_start_time) * 1000
|
|
107
|
+
|
|
108
|
+
# Log error with full request context
|
|
109
|
+
logger.error(
|
|
110
|
+
f"Request failed: {request.method} {request.url.path} - "
|
|
111
|
+
f"Error: {type(e).__name__}: {str(e)} - Duration: {execution_duration_ms:.2f}ms",
|
|
112
|
+
service=self.config.service_name,
|
|
113
|
+
request=request_data,
|
|
114
|
+
exception_type=type(e).__name__,
|
|
115
|
+
exception_message=str(e),
|
|
116
|
+
execution_duration_ms=execution_duration_ms,
|
|
117
|
+
exc_info=True,
|
|
118
|
+
)
|
|
119
|
+
# Re-raise to let FastAPI's exception handlers deal with it
|
|
120
|
+
raise
|
|
121
|
+
|
|
122
|
+
async def _capture_request(self, request: Request, correlation_id: Optional[str]) -> Dict[str, Any]:
|
|
123
|
+
"""
|
|
124
|
+
Capture all relevant request details for logging.
|
|
125
|
+
|
|
126
|
+
Extracts method, path, headers, query params, body, and user info.
|
|
127
|
+
Applies redaction and size limits to protect sensitive data.
|
|
128
|
+
"""
|
|
129
|
+
# Extract user ID from request state (set by auth middleware/dependencies)
|
|
130
|
+
# Simplified to support two common patterns:
|
|
131
|
+
# 1. request.state.user_id (direct ID)
|
|
132
|
+
# 2. request.state.user.id (AuthenticatedUser object)
|
|
133
|
+
user_id = None
|
|
134
|
+
if hasattr(request.state, "user_id"):
|
|
135
|
+
user_id = request.state.user_id
|
|
136
|
+
elif hasattr(request.state, "user") and hasattr(request.state.user, "id"):
|
|
137
|
+
user_id = request.state.user.id
|
|
138
|
+
|
|
139
|
+
# Capture headers (with redaction)
|
|
140
|
+
headers = None
|
|
141
|
+
if self.config.log_request_headers:
|
|
142
|
+
headers = redact_headers(dict(request.headers))
|
|
143
|
+
|
|
144
|
+
# Capture query params
|
|
145
|
+
query_params = None
|
|
146
|
+
if self.config.log_query_params:
|
|
147
|
+
query_params = dict(request.query_params)
|
|
148
|
+
|
|
149
|
+
# Capture request body
|
|
150
|
+
body = await self._capture_request_body(request)
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
"method": request.method,
|
|
154
|
+
"path": request.url.path,
|
|
155
|
+
"query_params": query_params,
|
|
156
|
+
"headers": headers,
|
|
157
|
+
"body": body,
|
|
158
|
+
"user_id": user_id,
|
|
159
|
+
"correlation_id": correlation_id,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
def _parse_body_bytes(self, body_bytes: bytes) -> Optional[Any]:
|
|
163
|
+
"""
|
|
164
|
+
Parse body bytes with size limiting and JSON detection.
|
|
165
|
+
|
|
166
|
+
Truncates large payloads and attempts JSON parsing with redaction.
|
|
167
|
+
"""
|
|
168
|
+
if not body_bytes:
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
# Check size limit
|
|
172
|
+
if len(body_bytes) > self.config.payload_size_limit:
|
|
173
|
+
return {
|
|
174
|
+
"_truncated": True,
|
|
175
|
+
"_original_size": len(body_bytes),
|
|
176
|
+
"_preview": body_bytes[: self.config.payload_size_limit].decode(
|
|
177
|
+
"utf-8", errors="replace"
|
|
178
|
+
),
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
# Try to parse as JSON
|
|
182
|
+
try:
|
|
183
|
+
body_json = json.loads(body_bytes)
|
|
184
|
+
# Apply redaction to body
|
|
185
|
+
return redact_data(body_json, self.config.additional_redaction_patterns)
|
|
186
|
+
except json.JSONDecodeError:
|
|
187
|
+
# Not JSON, return as string
|
|
188
|
+
return body_bytes.decode("utf-8", errors="replace")
|
|
189
|
+
|
|
190
|
+
async def _capture_request_body(self, request: Request) -> Optional[Any]:
|
|
191
|
+
"""
|
|
192
|
+
Capture and parse request body with size limiting.
|
|
193
|
+
|
|
194
|
+
Attempts to parse JSON bodies. Truncates large payloads to
|
|
195
|
+
prevent excessive log volume.
|
|
196
|
+
"""
|
|
197
|
+
try:
|
|
198
|
+
body_bytes = await request.body()
|
|
199
|
+
return self._parse_body_bytes(body_bytes)
|
|
200
|
+
except Exception as e:
|
|
201
|
+
logger.warning(f"Failed to capture request body: {e}")
|
|
202
|
+
return None
|
|
203
|
+
|
|
204
|
+
async def _capture_response(self, response: Response, duration_ms: float) -> Dict[str, Any]:
|
|
205
|
+
"""
|
|
206
|
+
Capture response details for logging.
|
|
207
|
+
|
|
208
|
+
Extracts status code, headers, and body. Handles streaming
|
|
209
|
+
responses by capturing body chunks.
|
|
210
|
+
"""
|
|
211
|
+
# Capture headers (with redaction)
|
|
212
|
+
headers = None
|
|
213
|
+
if self.config.log_response_headers:
|
|
214
|
+
headers = redact_headers(dict(response.headers))
|
|
215
|
+
|
|
216
|
+
# Capture response body
|
|
217
|
+
body = await self._capture_response_body(response)
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
"status_code": response.status_code,
|
|
221
|
+
"headers": headers,
|
|
222
|
+
"body": body,
|
|
223
|
+
"duration_ms": duration_ms,
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async def _capture_response_body(self, response: Response) -> Optional[Any]:
|
|
227
|
+
"""
|
|
228
|
+
Capture and parse response body with size limiting.
|
|
229
|
+
|
|
230
|
+
Handles regular responses and streaming responses. Truncates
|
|
231
|
+
large payloads to prevent excessive log volume.
|
|
232
|
+
"""
|
|
233
|
+
try:
|
|
234
|
+
# For streaming responses, we can't easily capture the body without consuming it
|
|
235
|
+
if isinstance(response, StreamingResponse):
|
|
236
|
+
return {"_streaming": True, "_message": "Streaming response body not captured"}
|
|
237
|
+
|
|
238
|
+
# Get body if available
|
|
239
|
+
if hasattr(response, "body"):
|
|
240
|
+
return self._parse_body_bytes(response.body)
|
|
241
|
+
|
|
242
|
+
return None
|
|
243
|
+
|
|
244
|
+
except Exception as e:
|
|
245
|
+
logger.warning(f"Failed to capture response body: {e}")
|
|
246
|
+
return None
|
|
247
|
+
|
|
248
|
+
def _extract_business_event(
|
|
249
|
+
self,
|
|
250
|
+
request: Request,
|
|
251
|
+
request_data: Dict[str, Any],
|
|
252
|
+
response_data: Dict[str, Any],
|
|
253
|
+
) -> Optional[Dict[str, Any]]:
|
|
254
|
+
"""
|
|
255
|
+
Check if this request matches a configured business event and extract relevant fields.
|
|
256
|
+
|
|
257
|
+
Returns dict with event_type and business_context if matched, None otherwise.
|
|
258
|
+
"""
|
|
259
|
+
if not self.config.business_events:
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
# Build endpoint pattern: "METHOD /path"
|
|
263
|
+
endpoint_pattern = f"{request.method} {request.url.path}"
|
|
264
|
+
|
|
265
|
+
# Check each configured business event
|
|
266
|
+
for pattern, event_config in self.config.business_events.items():
|
|
267
|
+
if self._matches_endpoint_pattern(endpoint_pattern, pattern):
|
|
268
|
+
# Extract business context fields
|
|
269
|
+
business_context = {}
|
|
270
|
+
|
|
271
|
+
# Extract from request body
|
|
272
|
+
if event_config.extract_from_request and request_data.get("body"):
|
|
273
|
+
request_body = request_data["body"]
|
|
274
|
+
if isinstance(request_body, dict):
|
|
275
|
+
for field in event_config.extract_from_request:
|
|
276
|
+
if field in request_body:
|
|
277
|
+
business_context[field] = request_body[field]
|
|
278
|
+
|
|
279
|
+
# Extract from response body
|
|
280
|
+
if event_config.extract_from_response and response_data.get("body"):
|
|
281
|
+
response_body = response_data["body"]
|
|
282
|
+
if isinstance(response_body, dict):
|
|
283
|
+
for field in event_config.extract_from_response:
|
|
284
|
+
if field in response_body:
|
|
285
|
+
business_context[field] = response_body[field]
|
|
286
|
+
|
|
287
|
+
# Extract from path parameters
|
|
288
|
+
if event_config.extract_from_path:
|
|
289
|
+
path_params = self._extract_path_params(request.url.path, pattern)
|
|
290
|
+
for param in event_config.extract_from_path:
|
|
291
|
+
if param in path_params:
|
|
292
|
+
business_context[param] = path_params[param]
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
"event_type": event_config.event_type,
|
|
296
|
+
"business_context": business_context,
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return None
|
|
300
|
+
|
|
301
|
+
def _matches_endpoint_pattern(self, endpoint: str, pattern: str) -> bool:
|
|
302
|
+
"""
|
|
303
|
+
Check if an endpoint matches a pattern.
|
|
304
|
+
|
|
305
|
+
Supports exact matches and path parameter patterns like /folders/{folder_id}
|
|
306
|
+
"""
|
|
307
|
+
# Extract method and path
|
|
308
|
+
endpoint_parts = endpoint.split(" ", 1)
|
|
309
|
+
pattern_parts = pattern.split(" ", 1)
|
|
310
|
+
|
|
311
|
+
if len(endpoint_parts) != 2 or len(pattern_parts) != 2:
|
|
312
|
+
return False
|
|
313
|
+
|
|
314
|
+
endpoint_method, endpoint_path = endpoint_parts
|
|
315
|
+
pattern_method, pattern_path = pattern_parts
|
|
316
|
+
|
|
317
|
+
# Method must match exactly
|
|
318
|
+
if endpoint_method != pattern_method:
|
|
319
|
+
return False
|
|
320
|
+
|
|
321
|
+
# Convert pattern to regex (handle {param} placeholders)
|
|
322
|
+
pattern_regex = re.sub(r"\{[^}]+\}", r"[^/]+", pattern_path)
|
|
323
|
+
pattern_regex = f"^{pattern_regex}$"
|
|
324
|
+
|
|
325
|
+
return bool(re.match(pattern_regex, endpoint_path))
|
|
326
|
+
|
|
327
|
+
def _extract_path_params(self, path: str, pattern: str) -> Dict[str, str]:
|
|
328
|
+
"""
|
|
329
|
+
Extract path parameters from a path based on a pattern.
|
|
330
|
+
|
|
331
|
+
Example:
|
|
332
|
+
path = "/folders/123"
|
|
333
|
+
pattern = "DELETE /folders/{folder_id}"
|
|
334
|
+
returns {"folder_id": "123"}
|
|
335
|
+
"""
|
|
336
|
+
# Extract just the path part from pattern
|
|
337
|
+
pattern_path = pattern.split(" ", 1)[1] if " " in pattern else pattern
|
|
338
|
+
|
|
339
|
+
# Find parameter names in pattern
|
|
340
|
+
param_names = re.findall(r"\{([^}]+)\}", pattern_path)
|
|
341
|
+
|
|
342
|
+
if not param_names:
|
|
343
|
+
return {}
|
|
344
|
+
|
|
345
|
+
# Convert pattern to regex with capture groups
|
|
346
|
+
pattern_regex = re.sub(r"\{[^}]+\}", r"([^/]+)", pattern_path)
|
|
347
|
+
pattern_regex = f"^{pattern_regex}$"
|
|
348
|
+
|
|
349
|
+
# Match and extract values
|
|
350
|
+
match = re.match(pattern_regex, path)
|
|
351
|
+
if not match:
|
|
352
|
+
return {}
|
|
353
|
+
|
|
354
|
+
# Build dict of param_name -> value
|
|
355
|
+
return dict(zip(param_names, match.groups()))
|
auditry/models.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from typing import Optional, Dict, List
|
|
2
|
+
from pydantic import BaseModel, Field, field_validator
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class BusinessEventConfig(BaseModel):
|
|
6
|
+
"""
|
|
7
|
+
Configuration for a business event to track.
|
|
8
|
+
|
|
9
|
+
Defines what event type to tag and what fields to extract for analytics.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
event_type: str = Field(
|
|
13
|
+
description="Business event type (e.g., 'folder.created', 'file.uploaded')"
|
|
14
|
+
)
|
|
15
|
+
extract_from_request: Optional[List[str]] = Field(
|
|
16
|
+
default=None,
|
|
17
|
+
description="Field names to extract from request body for business context"
|
|
18
|
+
)
|
|
19
|
+
extract_from_response: Optional[List[str]] = Field(
|
|
20
|
+
default=None,
|
|
21
|
+
description="Field names to extract from response body for business context"
|
|
22
|
+
)
|
|
23
|
+
extract_from_path: Optional[List[str]] = Field(
|
|
24
|
+
default=None,
|
|
25
|
+
description="Path parameter names to extract (e.g., ['folder_id'] for /folders/{folder_id})"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ObservabilityConfig(BaseModel):
|
|
30
|
+
"""
|
|
31
|
+
Configuration options for observability logging behavior.
|
|
32
|
+
|
|
33
|
+
Allows services to customize logging behavior including payload
|
|
34
|
+
size limits, redaction patterns, and verbosity.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
service_name: str = Field(
|
|
38
|
+
min_length=1,
|
|
39
|
+
description="Name of the service for logging context (e.g., 'vault-api', 'auth-service')"
|
|
40
|
+
)
|
|
41
|
+
correlation_id_header: str = Field(
|
|
42
|
+
default="X-Correlation-ID",
|
|
43
|
+
description="HTTP header name for correlation ID (default: X-Correlation-ID)",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
@field_validator("service_name")
|
|
47
|
+
@classmethod
|
|
48
|
+
def validate_service_name(cls, v: str) -> str:
|
|
49
|
+
"""Validate service name is not empty or whitespace."""
|
|
50
|
+
if not v or not v.strip():
|
|
51
|
+
raise ValueError("service_name cannot be empty or whitespace")
|
|
52
|
+
return v.strip()
|
|
53
|
+
business_events: Optional[Dict[str, BusinessEventConfig]] = Field(
|
|
54
|
+
default=None,
|
|
55
|
+
description="Map of endpoint patterns to business event configurations for analytics tracking"
|
|
56
|
+
)
|
|
57
|
+
payload_size_limit: int = Field(
|
|
58
|
+
default=10_240,
|
|
59
|
+
description="Maximum size in bytes for logged request/response bodies (default 10KB)",
|
|
60
|
+
)
|
|
61
|
+
additional_redaction_patterns: Optional[list[str]] = Field(
|
|
62
|
+
default=None,
|
|
63
|
+
description="Additional field name patterns to redact beyond defaults",
|
|
64
|
+
)
|
|
65
|
+
log_request_headers: bool = Field(
|
|
66
|
+
default=True, description="Whether to log request headers (redacted)"
|
|
67
|
+
)
|
|
68
|
+
log_response_headers: bool = Field(
|
|
69
|
+
default=False, description="Whether to log response headers (redacted)"
|
|
70
|
+
)
|
|
71
|
+
log_query_params: bool = Field(default=True, description="Whether to log query parameters")
|
auditry/py.typed
ADDED
|
File without changes
|
auditry/redaction.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
from typing import Any, Dict, List, Set
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
DEFAULT_REDACTION_PATTERNS: List[str] = [
|
|
5
|
+
"password",
|
|
6
|
+
"token",
|
|
7
|
+
"api_key",
|
|
8
|
+
"apikey",
|
|
9
|
+
"secret",
|
|
10
|
+
"ssn",
|
|
11
|
+
"social_security_number",
|
|
12
|
+
"credit_card",
|
|
13
|
+
"creditcard",
|
|
14
|
+
"authorization",
|
|
15
|
+
"x-api-key",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
REDACTED_VALUE = "[REDACTED]"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SensitiveDataRedactor:
|
|
22
|
+
"""
|
|
23
|
+
Redacts sensitive data from dictionaries and JSON-serializable objects.
|
|
24
|
+
|
|
25
|
+
Uses field name pattern matching to identify and replace sensitive
|
|
26
|
+
values with a redaction marker. Supports nested dictionaries and lists.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, additional_patterns: List[str] = None):
|
|
30
|
+
"""
|
|
31
|
+
Initialize redactor with field patterns to redact.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
additional_patterns: Additional field name patterns beyond defaults
|
|
35
|
+
"""
|
|
36
|
+
self.patterns: Set[str] = set(pattern.lower() for pattern in DEFAULT_REDACTION_PATTERNS)
|
|
37
|
+
if additional_patterns:
|
|
38
|
+
self.patterns.update(pattern.lower() for pattern in additional_patterns)
|
|
39
|
+
|
|
40
|
+
def should_redact(self, field_name: str) -> bool:
|
|
41
|
+
"""
|
|
42
|
+
Determine if a field should be redacted based on its name.
|
|
43
|
+
|
|
44
|
+
Performs case-insensitive matching against known sensitive
|
|
45
|
+
field patterns.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
field_name: Name of the field to check
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
True if field should be redacted, False otherwise
|
|
52
|
+
"""
|
|
53
|
+
field_lower = field_name.lower()
|
|
54
|
+
# Exact match
|
|
55
|
+
if field_lower in self.patterns:
|
|
56
|
+
return True
|
|
57
|
+
# Partial match (e.g., "user_password" matches "password")
|
|
58
|
+
return any(pattern in field_lower for pattern in self.patterns)
|
|
59
|
+
|
|
60
|
+
def redact(self, data: Any) -> Any:
|
|
61
|
+
"""
|
|
62
|
+
Recursively redact sensitive data from a data structure.
|
|
63
|
+
|
|
64
|
+
Handles dictionaries, lists, and primitive types. Creates a new
|
|
65
|
+
structure with sensitive values replaced, leaving original unchanged.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
data: Data structure to redact (dict, list, or primitive)
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
New data structure with sensitive values redacted
|
|
72
|
+
"""
|
|
73
|
+
if isinstance(data, dict):
|
|
74
|
+
return self._redact_dict(data)
|
|
75
|
+
elif isinstance(data, list):
|
|
76
|
+
return self._redact_list(data)
|
|
77
|
+
else:
|
|
78
|
+
# Primitive type, return as-is
|
|
79
|
+
return data
|
|
80
|
+
|
|
81
|
+
def _redact_dict(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
82
|
+
"""
|
|
83
|
+
Redact sensitive fields in a dictionary.
|
|
84
|
+
|
|
85
|
+
Creates new dictionary with sensitive field values replaced
|
|
86
|
+
with redaction marker. Recursively processes nested structures.
|
|
87
|
+
"""
|
|
88
|
+
redacted = {}
|
|
89
|
+
for key, value in data.items():
|
|
90
|
+
if self.should_redact(key):
|
|
91
|
+
redacted[key] = REDACTED_VALUE
|
|
92
|
+
elif isinstance(value, dict):
|
|
93
|
+
redacted[key] = self._redact_dict(value)
|
|
94
|
+
elif isinstance(value, list):
|
|
95
|
+
redacted[key] = self._redact_list(value)
|
|
96
|
+
else:
|
|
97
|
+
redacted[key] = value
|
|
98
|
+
return redacted
|
|
99
|
+
|
|
100
|
+
def _redact_list(self, data: List[Any]) -> List[Any]:
|
|
101
|
+
"""
|
|
102
|
+
Redact sensitive data in a list.
|
|
103
|
+
|
|
104
|
+
Recursively processes each list element, handling nested
|
|
105
|
+
dictionaries and lists.
|
|
106
|
+
"""
|
|
107
|
+
return [self.redact(item) for item in data]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
_default_redactor = SensitiveDataRedactor()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def redact_headers(headers: Dict[str, str]) -> Dict[str, str]:
|
|
114
|
+
"""
|
|
115
|
+
Redact sensitive headers for logging.
|
|
116
|
+
|
|
117
|
+
Specifically handles common authentication and API key headers
|
|
118
|
+
that should never appear in logs.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
headers: Dictionary of HTTP headers
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
New dictionary with sensitive headers redacted
|
|
125
|
+
"""
|
|
126
|
+
return _default_redactor.redact(headers)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def redact_data(data: Any, additional_patterns: List[str] = None) -> Any:
|
|
130
|
+
"""
|
|
131
|
+
Convenience function to redact sensitive data.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
data: Data structure to redact
|
|
135
|
+
additional_patterns: Optional additional field patterns to redact
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
Redacted copy of the data
|
|
139
|
+
"""
|
|
140
|
+
if additional_patterns:
|
|
141
|
+
# Only create new instance if custom patterns are needed
|
|
142
|
+
redactor = SensitiveDataRedactor(additional_patterns)
|
|
143
|
+
return redactor.redact(data)
|
|
144
|
+
return _default_redactor.redact(data)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unified observability middleware that handles both correlation IDs and request/response logging.
|
|
3
|
+
|
|
4
|
+
This middleware combines correlation ID management and comprehensive logging into a single,
|
|
5
|
+
easy-to-use middleware that requires minimal configuration.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from asgi_correlation_id import CorrelationIdMiddleware as BaseCorrelationIdMiddleware
|
|
11
|
+
from starlette.applications import Starlette
|
|
12
|
+
|
|
13
|
+
from .middleware import RequestResponseLoggingMiddleware
|
|
14
|
+
from .models import ObservabilityConfig
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ObservabilityMiddleware:
|
|
18
|
+
"""
|
|
19
|
+
Unified middleware for comprehensive observability.
|
|
20
|
+
|
|
21
|
+
Combines correlation ID management and request/response logging into a single
|
|
22
|
+
middleware that's easy to configure. Automatically handles proper ordering
|
|
23
|
+
of sub-middlewares.
|
|
24
|
+
|
|
25
|
+
Example:
|
|
26
|
+
app = FastAPI()
|
|
27
|
+
app.add_middleware(
|
|
28
|
+
ObservabilityMiddleware,
|
|
29
|
+
config=ObservabilityConfig(service_name="vault-api")
|
|
30
|
+
)
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
app: Starlette,
|
|
36
|
+
config: Optional[ObservabilityConfig] = None,
|
|
37
|
+
):
|
|
38
|
+
"""
|
|
39
|
+
Initialize the unified observability middleware.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
app: FastAPI/Starlette application instance
|
|
43
|
+
config: Observability configuration with service name and options
|
|
44
|
+
"""
|
|
45
|
+
if config is None:
|
|
46
|
+
raise ValueError(
|
|
47
|
+
"ObservabilityConfig is required"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
self.app = app
|
|
51
|
+
self.config = config
|
|
52
|
+
|
|
53
|
+
# Wrap the app with sub-middlewares in the correct order
|
|
54
|
+
# Order matters: Correlation ID must be set first, then logging can use it
|
|
55
|
+
|
|
56
|
+
# 1. First, wrap with logging middleware
|
|
57
|
+
logging_app = RequestResponseLoggingMiddleware(app, config=config)
|
|
58
|
+
|
|
59
|
+
# 2. Then wrap with correlation ID middleware
|
|
60
|
+
# This ensures correlation ID is available to the logging middleware
|
|
61
|
+
correlation_app = BaseCorrelationIdMiddleware(
|
|
62
|
+
logging_app.app,
|
|
63
|
+
header_name=config.correlation_id_header,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
self.wrapped_app = correlation_app
|
|
67
|
+
|
|
68
|
+
async def __call__(self, scope, receive, send):
|
|
69
|
+
"""
|
|
70
|
+
ASGI application interface.
|
|
71
|
+
|
|
72
|
+
Delegates to the wrapped middleware stack.
|
|
73
|
+
"""
|
|
74
|
+
await self.wrapped_app(scope, receive, send)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Liv Stark
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: auditry
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: FastAPI middleware for request/response logging, correlation IDs, and sensitive data redaction
|
|
5
|
+
Author-email: Liv Stark <livstark.work@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/lst4rk/auditry
|
|
8
|
+
Project-URL: Repository, https://github.com/lst4rk/auditry
|
|
9
|
+
Project-URL: Issues, https://github.com/lst4rk/auditry/issues
|
|
10
|
+
Keywords: fastapi,observability,logging,middleware,correlation-id,redaction
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Framework :: FastAPI
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Classifier: Topic :: System :: Logging
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Requires-Dist: fastapi>=0.100.0
|
|
25
|
+
Requires-Dist: pydantic>=2.0.0
|
|
26
|
+
Requires-Dist: structlog>=23.0.0
|
|
27
|
+
Requires-Dist: asgi-correlation-id>=4.0.0
|
|
28
|
+
|
|
29
|
+
# auditry
|
|
30
|
+
|
|
31
|
+
FastAPI observability middleware with automatic request/response logging, correlation IDs, and sensitive data redaction.
|
|
32
|
+
|
|
33
|
+
[](https://badge.fury.io/py/auditry)
|
|
34
|
+
[](https://pypi.org/project/auditry/)
|
|
35
|
+
[](https://opensource.org/licenses/MIT)
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install auditry
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Quick Start
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from fastapi import FastAPI
|
|
47
|
+
from auditry import configure_logging, ObservabilityMiddleware, ObservabilityConfig
|
|
48
|
+
from auditry import get_logger
|
|
49
|
+
|
|
50
|
+
# Configure structured logging at startup
|
|
51
|
+
configure_logging(level="INFO")
|
|
52
|
+
|
|
53
|
+
app = FastAPI()
|
|
54
|
+
|
|
55
|
+
# Add observability middleware (single line!)
|
|
56
|
+
app.add_middleware(
|
|
57
|
+
ObservabilityMiddleware,
|
|
58
|
+
config=ObservabilityConfig(
|
|
59
|
+
service_name="my-service-name",
|
|
60
|
+
),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
logger = get_logger(__name__) # always use the get_logger from this package
|
|
64
|
+
|
|
65
|
+
@app.get("/")
|
|
66
|
+
async def root():
|
|
67
|
+
|
|
68
|
+
logger.info("Hello World")
|
|
69
|
+
|
|
70
|
+
return {"message": "Hello World"}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Configuration
|
|
74
|
+
|
|
75
|
+
### Required Configuration
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
config = ObservabilityConfig(
|
|
79
|
+
service_name="your-service-name", # REQUIRED
|
|
80
|
+
)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Full Configuration Options
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
config = ObservabilityConfig(
|
|
87
|
+
# REQUIRED: Service name for log filtering
|
|
88
|
+
service_name="my-service-name",
|
|
89
|
+
|
|
90
|
+
# Correlation ID header name (default: X-Correlation-ID)
|
|
91
|
+
# Use this if your org uses a different header, such as X-Request-ID
|
|
92
|
+
correlation_id_header="X-Correlation-ID",
|
|
93
|
+
|
|
94
|
+
# Maximum request/response body size to log (default: 10KB)
|
|
95
|
+
payload_size_limit=10_240,
|
|
96
|
+
|
|
97
|
+
# Additional sensitive field patterns to redact
|
|
98
|
+
additional_redaction_patterns=["internal_id", "employee_ssn"],
|
|
99
|
+
|
|
100
|
+
# Whether to log request headers (default: True)
|
|
101
|
+
log_request_headers=True,
|
|
102
|
+
|
|
103
|
+
# Whether to log response headers (default: False)
|
|
104
|
+
log_response_headers=False,
|
|
105
|
+
|
|
106
|
+
# Whether to log query parameters (default: True)
|
|
107
|
+
log_query_params=True,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
app.add_middleware(ObservabilityMiddleware, config=config)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Correlation IDs
|
|
114
|
+
|
|
115
|
+
Correlation IDs are automatically handled:
|
|
116
|
+
|
|
117
|
+
- **Incoming requests**: Extracts from `X-Correlation-ID` header (or your custom header)
|
|
118
|
+
- **Generated if missing**: Creates a new UUID if no correlation ID provided
|
|
119
|
+
- **Added to response**: Returns the correlation ID in the response header
|
|
120
|
+
- **Included in logs**: Automatically included in all structured logs
|
|
121
|
+
|
|
122
|
+
### Using Correlation IDs in Your Code
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
from auditry import get_logger, get_correlation_id
|
|
126
|
+
|
|
127
|
+
logger = get_logger(__name__)
|
|
128
|
+
|
|
129
|
+
@app.get("/users/{user_id}")
|
|
130
|
+
async def get_user(user_id: str):
|
|
131
|
+
# Correlation ID is automatically available
|
|
132
|
+
correlation_id = get_correlation_id()
|
|
133
|
+
|
|
134
|
+
# All logs automatically include the correlation ID
|
|
135
|
+
logger.info(f"Fetching user {user_id}")
|
|
136
|
+
|
|
137
|
+
return {"user_id": user_id, "correlation_id": correlation_id}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Propagating to Downstream Services
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
import httpx
|
|
144
|
+
from auditry import get_correlation_id
|
|
145
|
+
|
|
146
|
+
@app.get("/proxy")
|
|
147
|
+
async def proxy_request():
|
|
148
|
+
# Get the current correlation ID
|
|
149
|
+
correlation_id = get_correlation_id()
|
|
150
|
+
|
|
151
|
+
# Pass it to downstream services
|
|
152
|
+
async with httpx.AsyncClient() as client:
|
|
153
|
+
response = await client.get(
|
|
154
|
+
"https://downstream-service.com/api/data",
|
|
155
|
+
headers={"X-Correlation-ID": correlation_id} # Use your org's header name
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
return response.json()
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## User Tracking
|
|
162
|
+
|
|
163
|
+
The middleware automatically extracts user IDs from FastAPI dependencies and includes them in logs.
|
|
164
|
+
|
|
165
|
+
### Supported User Patterns
|
|
166
|
+
|
|
167
|
+
The middleware automatically detects user IDs from these patterns:
|
|
168
|
+
|
|
169
|
+
1. **Object with `id` attribute**: `current_user.id`
|
|
170
|
+
2. **Object with `user_id` attribute**: `current_user.user_id`
|
|
171
|
+
3. **Dict with `id` key**: `user["id"]`
|
|
172
|
+
4. **Dict with `user_id` key**: `user["user_id"]`
|
|
173
|
+
5. **Dict with `sub` key**: `user["sub"]` (JWT standard)
|
|
174
|
+
6. **Request state**: `request.state.user_id`
|
|
175
|
+
|
|
176
|
+
**You don't need to modify any existing code** - the middleware automatically finds the user ID!
|
|
177
|
+
|
|
178
|
+
## Business Event Tagging (For Analytics)
|
|
179
|
+
|
|
180
|
+
Tag specific endpoints as "business events" to make analytics queries easier for your sales/product teams.
|
|
181
|
+
|
|
182
|
+
### Configuration
|
|
183
|
+
|
|
184
|
+
Tag endpoints in your middleware config - zero code changes needed in your actual endpoints:
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
from auditry import ObservabilityMiddleware, ObservabilityConfig, BusinessEventConfig
|
|
188
|
+
|
|
189
|
+
app.add_middleware(
|
|
190
|
+
ObservabilityMiddleware,
|
|
191
|
+
config=ObservabilityConfig(
|
|
192
|
+
service_name="my-service-name",
|
|
193
|
+
|
|
194
|
+
# Define which endpoints to tag for analytics
|
|
195
|
+
business_events={
|
|
196
|
+
"POST /workflows": BusinessEventConfig(
|
|
197
|
+
event_type="workflow.created",
|
|
198
|
+
extract_from_request=["file_id"], # Pull file_id from request body
|
|
199
|
+
extract_from_response=["id"], # Pull workflow id from response
|
|
200
|
+
),
|
|
201
|
+
"DELETE /workflows/{workflow_id}": BusinessEventConfig(
|
|
202
|
+
event_type="workflow.deleted",
|
|
203
|
+
extract_from_path=["workflow_id"], # Pull workflow_id from URL path
|
|
204
|
+
),
|
|
205
|
+
},
|
|
206
|
+
),
|
|
207
|
+
)
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
### Log Output with Event Tags
|
|
211
|
+
|
|
212
|
+
Regular log (no tagging):
|
|
213
|
+
```json
|
|
214
|
+
{
|
|
215
|
+
"service": "my-service-name",
|
|
216
|
+
"message": "Request completed: POST /workflows - Status: 201",
|
|
217
|
+
"request": {...},
|
|
218
|
+
"response": {...}
|
|
219
|
+
}
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Tagged business event log:
|
|
223
|
+
```json
|
|
224
|
+
{
|
|
225
|
+
"service": "my-service-name",
|
|
226
|
+
"message": "Request completed: POST /workflows - Status: 201",
|
|
227
|
+
"event_type": "workflow.created", // ← Filterable in log platform
|
|
228
|
+
"business_context": {
|
|
229
|
+
"file_id": "file_123", // ← From request body
|
|
230
|
+
"id": "workflow_789" // ← From response body
|
|
231
|
+
},
|
|
232
|
+
"request": {...},
|
|
233
|
+
"response": {...}
|
|
234
|
+
}
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
### Supported Extract Locations
|
|
238
|
+
|
|
239
|
+
- `extract_from_request`: Fields from request JSON body
|
|
240
|
+
- `extract_from_response`: Fields from response JSON body
|
|
241
|
+
- `extract_from_path`: Parameters from URL path (e.g., `/workflows/{workflow_id}`)
|
|
242
|
+
|
|
243
|
+
## Log Output
|
|
244
|
+
|
|
245
|
+
All logs are structured JSON, ready for log aggregators:
|
|
246
|
+
|
|
247
|
+
```json
|
|
248
|
+
{
|
|
249
|
+
"timestamp": "2025-10-28T12:34:56.789012+00:00",
|
|
250
|
+
"level": "INFO",
|
|
251
|
+
"service": "my-service-name",
|
|
252
|
+
"correlation_id": "550e8400-e29b-41d4-a716-446655440000",
|
|
253
|
+
"message": "Request completed: POST /workflows - Status: 201 - Duration: 45.23ms",
|
|
254
|
+
"request": {
|
|
255
|
+
"method": "POST",
|
|
256
|
+
"path": "/workflows",
|
|
257
|
+
"query_params": {},
|
|
258
|
+
"headers": {"user-agent": "curl/7.64.1", "authorization": "[REDACTED]"},
|
|
259
|
+
"body": {"name": "My Workflow", "password": "[REDACTED]"},
|
|
260
|
+
"user_id": "user_12345"
|
|
261
|
+
},
|
|
262
|
+
"response": {
|
|
263
|
+
"status_code": 201,
|
|
264
|
+
"duration_ms": 45.23,
|
|
265
|
+
"body": {"id": "workflow_789", "name": "My Workflow"}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
## Sensitive Data Redaction
|
|
271
|
+
|
|
272
|
+
Automatically redacts these sensitive field patterns:
|
|
273
|
+
|
|
274
|
+
- `password`
|
|
275
|
+
- `token`
|
|
276
|
+
- `api_key` / `apikey`
|
|
277
|
+
- `secret`
|
|
278
|
+
- `authorization`
|
|
279
|
+
- `ssn` / `social_security_number`
|
|
280
|
+
- `credit_card` / `creditcard`
|
|
281
|
+
- `x-api-key`
|
|
282
|
+
|
|
283
|
+
Add custom patterns via configuration:
|
|
284
|
+
|
|
285
|
+
```python
|
|
286
|
+
config = ObservabilityConfig(
|
|
287
|
+
service_name="my-service-name",
|
|
288
|
+
additional_redaction_patterns=["internal_token", "employee_id"],
|
|
289
|
+
)
|
|
290
|
+
```
|
|
291
|
+
## Best Practices
|
|
292
|
+
|
|
293
|
+
### 1. Configure Logging Early
|
|
294
|
+
|
|
295
|
+
Call `configure_logging()` at application startup, before any other code:
|
|
296
|
+
|
|
297
|
+
```python
|
|
298
|
+
from auditry import configure_logging
|
|
299
|
+
|
|
300
|
+
# First thing in your app
|
|
301
|
+
configure_logging(level="INFO")
|
|
302
|
+
|
|
303
|
+
app = FastAPI()
|
|
304
|
+
# ... rest of your app
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
### 2. Use Structured Logging
|
|
308
|
+
|
|
309
|
+
Always use `get_logger(__name__)` instead of standard Python logging:
|
|
310
|
+
|
|
311
|
+
```python
|
|
312
|
+
from auditry import get_logger
|
|
313
|
+
|
|
314
|
+
logger = get_logger(__name__)
|
|
315
|
+
|
|
316
|
+
# Good - structured with correlation ID
|
|
317
|
+
logger.info("Processing payment", amount=100.50, currency="USD")
|
|
318
|
+
|
|
319
|
+
# Bad - loses structured data
|
|
320
|
+
import logging
|
|
321
|
+
logging.info("Processing payment")
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
### 3. Propagate Correlation IDs
|
|
325
|
+
|
|
326
|
+
When calling downstream services, always pass the correlation ID:
|
|
327
|
+
|
|
328
|
+
```python
|
|
329
|
+
from auditry import get_correlation_id
|
|
330
|
+
|
|
331
|
+
correlation_id = get_correlation_id()
|
|
332
|
+
headers = {"X-Correlation-ID": correlation_id} # Use your org's header name
|
|
333
|
+
response = await client.get(url, headers=headers)
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
### 4. Customize for Your Organization
|
|
337
|
+
|
|
338
|
+
Match your org's conventions:
|
|
339
|
+
|
|
340
|
+
```python
|
|
341
|
+
config = ObservabilityConfig(
|
|
342
|
+
service_name="my-service-name",
|
|
343
|
+
correlation_id_header="X-Request-ID", # If your org uses this header instead
|
|
344
|
+
additional_redaction_patterns=["ssn", "tax_id"], # Your sensitive fields
|
|
345
|
+
)
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
## Example Output: Success vs Failure
|
|
349
|
+
|
|
350
|
+
### Successful Request
|
|
351
|
+
|
|
352
|
+
```json
|
|
353
|
+
{
|
|
354
|
+
"level": "INFO",
|
|
355
|
+
"service": "my-service-name",
|
|
356
|
+
"correlation_id": "abc-123",
|
|
357
|
+
"message": "Request completed: POST /workflows - Status: 201 - Duration: 45ms",
|
|
358
|
+
"request": {...},
|
|
359
|
+
"response": {...}
|
|
360
|
+
}
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
### Failed Request
|
|
364
|
+
|
|
365
|
+
```json
|
|
366
|
+
{
|
|
367
|
+
"level": "ERROR",
|
|
368
|
+
"service": "my-service-name",
|
|
369
|
+
"correlation_id": "abc-123",
|
|
370
|
+
"message": "Request failed: POST /workflows - Error: ValueError: Invalid name",
|
|
371
|
+
"request": {...},
|
|
372
|
+
"exception_type": "ValueError",
|
|
373
|
+
"exception_message": "Invalid name",
|
|
374
|
+
"execution_duration_ms": 12.34
|
|
375
|
+
}
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
## License
|
|
379
|
+
|
|
380
|
+
MIT License - see [LICENSE](LICENSE) file for details.
|
|
381
|
+
|
|
382
|
+
## Contributing
|
|
383
|
+
|
|
384
|
+
Contributions welcome! Please submit a Pull Request.
|
|
385
|
+
|
|
386
|
+
## Support
|
|
387
|
+
|
|
388
|
+
For issues and questions: [GitHub Issues](https://github.com/lst4rk/auditry/issues)
|
|
389
|
+
|
|
390
|
+
## Author
|
|
391
|
+
|
|
392
|
+
**Liv Stark** - [livstark.work@gmail.com](mailto:livstark.work@gmail.com)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
auditry/__init__.py,sha256=dOYQFaeKndof_cmHVBKMwRIW6Fviu4IBRWSHsVg5AD0,491
|
|
2
|
+
auditry/correlation.py,sha256=NHBWtxkRa155a9iEMZy6hjrOE_GDatIkt53HkXwST74,457
|
|
3
|
+
auditry/logging_config.py,sha256=jQ05tPBhoBGAL-4Z3MdQf-gbU0vZZrVq90waYiE-9Qo,1859
|
|
4
|
+
auditry/middleware.py,sha256=F1f1Nrsvf4Tp5028Pq0v9z4hXUqyVtut2Qm2nvpvAMY,13081
|
|
5
|
+
auditry/models.py,sha256=GRjgvfzNb0_X3aaqf-BGcYf6xTezgiR0zC3aaso1Ksk,2666
|
|
6
|
+
auditry/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
auditry/redaction.py,sha256=otD1qAFVb7_S-wITWfmMEjNP75Bc5DydiCiw9Ppgcp0,4344
|
|
8
|
+
auditry/unified_middleware.py,sha256=FDtqpKqke8IJFb63LAklWI4yhgg70cHgikuBWov0eFw,2324
|
|
9
|
+
auditry-0.1.0.dist-info/LICENSE,sha256=tYlACwTkk8vCn2Q5wpldpGI17PmyRYy48Ssa_zKN0Ow,1065
|
|
10
|
+
auditry-0.1.0.dist-info/METADATA,sha256=hx6LsWn-zDxLT201ToVp_eECmwq3rmWVcvajcjSuJhQ,10643
|
|
11
|
+
auditry-0.1.0.dist-info/WHEEL,sha256=tZoeGjtWxWRfdplE7E3d45VPlLNQnvbKiYnx7gwAy8A,92
|
|
12
|
+
auditry-0.1.0.dist-info/top_level.txt,sha256=ZsUpu_j54GA1Cj1nsfiMm5Ino9h_Gv4F9QTVbGspIp8,8
|
|
13
|
+
auditry-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
auditry
|