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 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
+ [![PyPI version](https://badge.fury.io/py/auditry.svg)](https://badge.fury.io/py/auditry)
34
+ [![Python Versions](https://img.shields.io/pypi/pyversions/auditry.svg)](https://pypi.org/project/auditry/)
35
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: bdist_wheel (0.45.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ auditry