Skip to content

feat(audit): add audit logging middleware, API, and dashboard#242

Open
tinu-hareesswar wants to merge 1 commit intomainfrom
issue-238-audit
Open

feat(audit): add audit logging middleware, API, and dashboard#242
tinu-hareesswar wants to merge 1 commit intomainfrom
issue-238-audit

Conversation

@tinu-hareesswar
Copy link
Copy Markdown
Collaborator

Summary

  • Add Postgres audit_log table migration with indexes on timestamp, endpoint, request_id, and merchant_id
  • Add Axum middleware that captures every API request/response (sanitizes auth headers, truncates large response bodies, extracts merchant_id, logs latency) and writes to audit_log asynchronously via tokio::spawn
  • Add 3 audit API endpoints: GET /audit/stats (per-endpoint hit counts with avg latency and error rates), GET /audit/requests (paginated request list), GET /audit/request/:id (single entry detail)
  • Add React AuditPage dashboard with sortable endpoint stats table, time range filter (1h/6h/24h/7d), search, expandable request explorer with full JSON request/response inspection, and pagination
  • Add "Observability" section to sidebar with Audit Log entry

Test plan

  • Run the Postgres migration (up.sql) against a test database
  • Start the backend and verify /audit/stats, /audit/requests, and /audit/request/:id return valid JSON
  • Make API calls (e.g., /decide-gateway) and verify they appear in the audit log
  • Verify auth headers are redacted in stored request_headers
  • Verify response bodies > 10KB are truncated
  • Open the frontend /audit page and verify the dashboard renders
  • Test time range filter switching, endpoint search, and pagination
  • Click an endpoint row to expand and see individual requests
  • Click a request to expand and see full headers/body JSON

🤖 Generated with Claude Code

Add a complete audit system that captures all API requests/responses:
- Postgres migration for `audit_log` table with indexes
- Axum middleware that logs endpoint, method, headers (sanitized),
  request/response bodies, latency, merchant_id, and request_id
- API endpoints: GET /audit/stats, /audit/requests, /audit/request/:id
- React dashboard with sortable endpoint stats, expandable request
  explorer with JSON syntax highlighting, time range filters, and
  pagination

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 16, 2026 11:56
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an end-to-end audit logging feature: database storage, backend capture/query APIs, and a frontend dashboard for exploring audit data.

Changes:

  • Introduces audit_log Postgres table migration with supporting indexes.
  • Adds Axum audit middleware to capture request/response metadata and new /audit/* API endpoints to query stats and requests.
  • Adds a React AuditPage, sidebar entry, route wiring, and Vite dev proxy for /audit.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
website/vite.config.ts Adds Vite dev proxy rule for /audit to reach the backend locally.
website/src/components/pages/AuditPage.tsx Implements the Audit dashboard UI (stats table + request explorer + pagination).
website/src/components/layout/Sidebar.tsx Adds “Observability” section and Audit Log navigation link.
website/src/App.tsx Registers the /audit route to render AuditPage.
src/routes/audit.rs Adds backend audit query endpoints (/audit/stats, /audit/requests, /audit/request/:id).
src/routes.rs Exposes the new routes::audit module.
src/middleware/mod.rs Exposes the new middleware::audit module.
src/middleware/audit.rs Adds request/response capture middleware and async insert into audit_log.
src/app.rs Wires audit routes and installs the audit middleware into the service stack.
migrations_pg/2024_04_16_000000_create_audit_log/up.sql Creates audit_log table + indexes.
migrations_pg/2024_04_16_000000_create_audit_log/down.sql Drops audit_log table.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/routes/audit.rs
.bind::<Text, _>(ep.clone())
.get_result(&*conn)
.await
.unwrap_or(CountRow { count: 0 });
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The total count query uses unwrap_or(CountRow { count: 0 }), which silently hides database errors and can cause the API to return an incorrect total while still returning request rows. It’s better to propagate the error (500) consistently, or at least log it and return an error response.

Suggested change
.unwrap_or(CountRow { count: 0 });
.map_err(|e| {
logger::error!("Failed to query audit request count: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Database query failed: {}", e),
)
})?;

Copilot uses AI. Check for mistakes.
Comment thread src/routes/audit.rs
))
.get_result(&*conn)
.await
.unwrap_or(CountRow { count: 0 });
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The total count query uses unwrap_or(CountRow { count: 0 }), which suppresses query failures and can mask real DB issues (returning total: 0 even when rows exist). Prefer handling this error the same way as the main query (log + return 500) to keep pagination metadata reliable.

Suggested change
.unwrap_or(CountRow { count: 0 });
.map_err(|e| {
logger::error!("Failed to query audit request count: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Database query failed: {}", e),
)
})?;

Copilot uses AI. Check for mistakes.
Comment on lines +201 to +202
const avgLatency = stats.length > 0
? Math.round(stats.reduce((sum, s) => sum + s.avg_latency_ms, 0) / stats.length)
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The “Avg Latency” summary is computed as a simple average of per-endpoint averages, which is mathematically incorrect when endpoints have different hit counts. If this is meant to represent overall average latency, compute a weighted average using each endpoint’s count (or compute it server-side from raw rows).

Suggested change
const avgLatency = stats.length > 0
? Math.round(stats.reduce((sum, s) => sum + s.avg_latency_ms, 0) / stats.length)
const avgLatency = totalRequests > 0
? Math.round(stats.reduce((sum, s) => sum + (s.avg_latency_ms * s.count), 0) / totalRequests)

Copilot uses AI. Check for mistakes.
setRequestsTotal(data.total)
}
} catch {
setRequests([])
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On request fetch failure, the code clears requests but leaves requestsTotal unchanged. That can show stale totals/pagination from a previously expanded endpoint. Consider also resetting requestsTotal (and possibly requestsPage) in the catch/failure path.

Suggested change
setRequests([])
setRequests([])
setRequestsTotal(0)

Copilot uses AI. Check for mistakes.
Comment thread src/middleware/audit.rs
Comment on lines +74 to +78
Err(e) => {
logger::error!("Failed to read request body for audit: {:?}", e);
let request = Request::from_parts(parts, Body::empty());
return next.run(request).await;
}
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If reading the request body fails (e.g., body exceeds the 1MB limit), the middleware reconstructs the request with an empty body and forwards it. That can break handlers that require the original payload. Consider avoiding consuming the body when it’s too large (e.g., check Content-Length / size hints first and skip body capture), or otherwise ensure the original body is still forwarded unchanged when audit capture fails.

Copilot uses AI. Check for mistakes.
Comment thread src/middleware/audit.rs
Comment on lines +98 to +105
// Extract response body
let (resp_parts, resp_body) = response.into_parts();
let resp_bytes = match to_bytes(resp_body, 1024 * 1024).await {
Ok(bytes) => bytes,
Err(e) => {
logger::error!("Failed to read response body for audit: {:?}", e);
let response = Response::from_parts(resp_parts, Body::empty());
return response;
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If reading the response body fails (commonly when it exceeds the 1MB limit), the middleware returns the response with an empty body. This changes API behavior for callers. Prefer returning the original response body unchanged and skipping audit body capture on failure (e.g., based on Content-Length / size hints or by gracefully falling back without altering the response).

Suggested change
// Extract response body
let (resp_parts, resp_body) = response.into_parts();
let resp_bytes = match to_bytes(resp_body, 1024 * 1024).await {
Ok(bytes) => bytes,
Err(e) => {
logger::error!("Failed to read response body for audit: {:?}", e);
let response = Response::from_parts(resp_parts, Body::empty());
return response;
// Extract response body only when size hints indicate it is safe to do so.
let response_content_length = response
.headers()
.get(axum::http::header::CONTENT_LENGTH)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse::<usize>().ok());
if matches!(response_content_length, Some(len) if len > 1024 * 1024) {
logger::error!(
"Skipping response body audit because Content-Length exceeds read limit: {:?}",
response_content_length
);
return response;
}
let (resp_parts, resp_body) = response.into_parts();
let resp_bytes = match to_bytes(resp_body, 1024 * 1024).await {
Ok(bytes) => bytes,
Err(e) => {
logger::error!("Failed to read response body for audit: {:?}", e);
return Response::from_parts(resp_parts, Body::empty());

Copilot uses AI. Check for mistakes.
Comment thread src/middleware/audit.rs
Comment on lines +132 to +136
tokio::spawn(async move {
if let Err(e) = insert_audit_log(
&endpoint,
&method,
&req_headers_json,
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This spawns a detached task per request to write the audit row. Under load, unbounded tokio::spawn can create large task backlogs and amplify DB pool pressure. Consider a bounded channel/worker (or batching) for audit inserts, with backpressure/drop policy, so audit logging can’t degrade request handling.

Copilot uses AI. Check for mistakes.
Comment thread src/routes/audit.rs
Comment on lines +352 to +357
.map_err(|e| {
logger::error!("Failed to query audit request: {:?}", e);
(
StatusCode::NOT_FOUND,
"Audit log entry not found".to_string(),
)
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

audit_request_by_id maps any Diesel error to a 404 Not Found. That will incorrectly return 404 on transient DB errors/timeouts and makes debugging harder. Consider returning 404 only for diesel::result::Error::NotFound, and otherwise return 500 (while still logging the underlying error).

Suggested change
.map_err(|e| {
logger::error!("Failed to query audit request: {:?}", e);
(
StatusCode::NOT_FOUND,
"Audit log entry not found".to_string(),
)
.map_err(|e| match e {
diesel::result::Error::NotFound => {
logger::error!("Audit log entry not found for id {}: {:?}", id, e);
(
StatusCode::NOT_FOUND,
"Audit log entry not found".to_string(),
)
}
_ => {
logger::error!("Failed to query audit request: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to query audit log entry".to_string(),
)
}

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants