Files
haproxy-openmanager/frontend/src/components/RequestLog.js
T
mustafa.ulukaya ef26860df9 feat(logging): unified request/response log with configurable retention (v1.11.0)
Until now the only record of what happened was `user_activity_logs`, which
stores non-GET 2xx operations with no bodies. When something failed you could
see that a counter went up, never what was sent or what came back.

This adds one queryable timeline covering both directions:

- inbound: every API call, including GETs and including 4xx/5xx, with the
  user, client IP, status, duration and — redacted, size-capped — the request
  and response bodies.
- outbound: every HTTP call the backend makes, tagged with who it went to
  (ACME/Let's Encrypt, Cloudflare, GoDaddy, HAProxy stats, agents, the ACME
  diagnostics probe).

Outbound rows inherit the inbound request's id, so one operator action and the
CA/DNS calls it triggered read as a single trace: opening a failed "Request
Certificate" shows the exact POST /acme/new-order and the CA's 429 underneath.

Implementation notes:

- Capture is a pure-ASGI middleware that TEES the request and response streams
  rather than draining them. `await request.body()` inside a BaseHTTPMiddleware
  would consume the receive channel and break the raw-body agent heartbeat
  handler. Registered last so it is outermost: it then sees the final
  client-visible response and seeds correlation_id_context before the error
  handler reads it.
- Rows are written by a batching background writer with a bounded queue, so the
  request path never awaits the database and a saturated logger drops rows
  visibly (surfaced on the page) instead of blocking. Redaction runs on the
  writer, off the request coroutine.
- Secrets never land: headers are an allowlist with Authorization/Cookie kept
  only as a presence marker; body keys and value shapes are redacted
  (passwords, tokens, api_token, API keys, private-key PEMs, JWTs); the ACME
  JWS request body is never stored, because a stored protected+signature pair
  is a replayable credential — a summary is logged instead; DNS-provider errors
  record only the exception type; the ACME HTTP-01 challenge endpoint is
  excluded so key_authorization is never captured.
- Retention is operator-configurable in Settings -> Request Log: separate day
  counts for successful and failed rows (7 / 30) plus a hard row cap (500k),
  whichever is reached first. Pruned in batches under a Postgres advisory lock,
  with the day counts bound as parameters, never interpolated.
- New permissions requestlog.read / requestlog.manage. super_admin and
  security_admin get both, operator gets read, viewer gets neither.

Schema: one new table (request_logs) plus its settings seed, SCHEMA_VERSION
10 -> 11, auto-migrated. No existing table altered, no agent or rendered-config
change. Kill switches: REQUEST_LOG_ENABLED=false (middleware never registered)
or the `enabled` toggle in Settings.

Tests: 245 new (7 backend files + 1 frontend), full suite 1655 backend +
17 frontend passing.
2026-08-11 02:36:03 +03:00

607 lines
21 KiB
JavaScript

/**
* v1.11.0 — Request Log.
*
* One timeline for both directions of HTTP traffic:
* - inbound: which user called which API endpoint, with what result
* - outbound: which CA / DNS provider / agent this backend called, and what
* came back
*
* Both live in the same table, so opening one inbound request shows the
* outbound calls it triggered (they share a request_id) — that "related" list
* is the whole point of the page. Bodies are captured redacted and size-capped
* by the backend; nothing is unredacted here.
*
* Pagination is SERVER-side (a first for this frontend — every other table
* filters an already-fetched array). The table can hold millions of rows, so
* fetching them to slice client-side is not an option.
*/
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
Alert, Button, Card, DatePicker, Descriptions, Empty, Input, Modal, Select,
Space, Spin, Statistic, Switch, Table, Tag, Tooltip, Typography, message, theme
} from 'antd';
import {
ApiOutlined, ClockCircleOutlined, CloudDownloadOutlined, DeleteOutlined,
EyeOutlined, ReloadOutlined, UserOutlined, WarningOutlined
} from '@ant-design/icons';
import axios from 'axios';
import { extractApiError } from '../utils/apiError';
import { useAuth } from '../contexts/AuthContext';
const { Text, Paragraph } = Typography;
const DIRECTION_OPTIONS = [
{ label: 'Inbound (API calls to us)', value: 'inbound' },
{ label: 'Outbound (calls we made)', value: 'outbound' },
];
const STATUS_OPTIONS = [
{ label: '2xx Success', value: 2 },
{ label: '3xx Redirect', value: 3 },
{ label: '4xx Client error', value: 4 },
{ label: '5xx Server error', value: 5 },
{ label: 'No response (transport error)', value: 0 },
];
const METHOD_OPTIONS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'].map((m) => ({
label: m, value: m,
}));
// Mirrors utils/http_instrumentation.py's TARGET_* constants.
const TARGET_LABELS = {
acme: "ACME / Let's Encrypt",
acme_diag: 'ACME diagnostics probe',
letsencrypt_ca: "Let's Encrypt CA chain",
dns_cloudflare: 'Cloudflare DNS',
dns_godaddy: 'GoDaddy DNS',
agent: 'HAProxy agent',
haproxy_stats: 'HAProxy stats',
settings_probe: 'ACME directory probe',
};
const statusColor = (statusClass) => {
if (statusClass === 2) return 'green';
if (statusClass === 3) return 'blue';
if (statusClass === 4) return 'orange';
if (statusClass >= 5) return 'red';
return 'red';
};
const isFailure = (row) => row?.status_class === 0 || row?.status_class >= 4;
const formatTime = (value) => {
if (!value) return '—';
// created_at is TIMESTAMPTZ, so the ISO string already carries an offset —
// no manual 'Z' suffix needed here (unlike the naive-TIMESTAMP columns
// elsewhere in this app).
const d = new Date(value);
return Number.isNaN(d.getTime()) ? String(value) : d.toLocaleString();
};
const JsonBlock = ({ value, token }) => {
if (value === null || value === undefined) {
return <Text type="secondary">Not captured</Text>;
}
return (
<pre
style={{
fontSize: 11,
margin: 0,
maxHeight: 260,
overflow: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
background: token.colorFillQuaternary,
border: `1px solid ${token.colorBorderSecondary}`,
borderRadius: token.borderRadius,
padding: 8,
}}
>
{typeof value === 'string' ? value : JSON.stringify(value, null, 2)}
</pre>
);
};
const RequestLog = () => {
const { token } = theme.useToken();
const { hasPermission, isAdmin } = useAuth();
const canRead = hasPermission('requestlog', 'read') || isAdmin();
const canManage = hasPermission('requestlog', 'manage') || isAdmin();
const [rows, setRows] = useState([]);
const [total, setTotal] = useState(0);
const [totalIsEstimate, setTotalIsEstimate] = useState(false);
const [scopedToSelf, setScopedToSelf] = useState(false);
const [loading, setLoading] = useState(false);
const [loadError, setLoadError] = useState(null);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(50);
const [direction, setDirection] = useState(undefined);
const [statusClass, setStatusClass] = useState(undefined);
const [methods, setMethods] = useState([]);
const [target, setTarget] = useState(undefined);
const [errorsOnly, setErrorsOnly] = useState(false);
const [range, setRange] = useState(null);
const [searchTyped, setSearchTyped] = useState('');
const [search, setSearch] = useState('');
const [stats, setStats] = useState(null);
const [purging, setPurging] = useState(false);
const [detailOpen, setDetailOpen] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
const [detailError, setDetailError] = useState(null);
const [detail, setDetail] = useState(null);
const params = useMemo(() => {
const p = { limit: pageSize, offset: (page - 1) * pageSize };
if (direction) p.direction = direction;
if (statusClass !== undefined && statusClass !== null) p.status_class = statusClass;
// The API takes one method; a single selection is the common case and
// keeps the query index-friendly.
if (methods.length === 1) p.method = methods[0];
if (target) p.target = target;
if (errorsOnly) p.errors_only = true;
if (search) p.q = search;
if (range && range[0]) p.since = range[0].toISOString();
if (range && range[1]) p.until = range[1].toISOString();
return p;
}, [page, pageSize, direction, statusClass, methods, target, errorsOnly, search, range]);
const fetchLogs = useCallback(async () => {
if (!canRead) return;
setLoading(true);
setLoadError(null);
try {
const res = await axios.get('/api/request-logs', { params });
setRows(res.data?.logs || []);
setTotal(res.data?.total || 0);
setTotalIsEstimate(Boolean(res.data?.total_is_estimate));
setScopedToSelf(Boolean(res.data?.scoped_to_self));
} catch (err) {
const msg = extractApiError(err, 'Failed to load request logs');
setLoadError(msg);
setRows([]);
setTotal(0);
} finally {
setLoading(false);
}
}, [canRead, params]);
const fetchStats = useCallback(async () => {
if (!canRead) return;
try {
const res = await axios.get('/api/request-logs/stats', { params: { hours: 24 } });
setStats(res.data || null);
} catch (err) {
// Stats are a nice-to-have header; a failure here must not hide the table.
setStats(null);
}
}, [canRead]);
useEffect(() => { fetchLogs(); }, [fetchLogs]);
useEffect(() => { fetchStats(); }, [fetchStats]);
const openDetail = useCallback(async (id) => {
setDetailOpen(true);
setDetailLoading(true);
setDetailError(null);
setDetail(null);
try {
const res = await axios.get(`/api/request-logs/${id}`);
setDetail(res.data || null);
} catch (err) {
setDetailError(extractApiError(err, 'Failed to load this request'));
} finally {
setDetailLoading(false);
}
}, []);
const runPurge = useCallback(() => {
Modal.confirm({
title: 'Apply retention now?',
icon: <DeleteOutlined />,
content:
'This runs the configured retention immediately instead of waiting for the next ' +
'scheduled pass. It removes rows that are already past their retention window or ' +
'beyond the row cap — it does not delete everything.',
okText: 'Run retention pass',
onOk: async () => {
setPurging(true);
try {
const res = await axios.post('/api/request-logs/purge');
const removed = res.data?.removed || {};
const count = (removed.success || 0) + (removed.error || 0) + (removed.overflow || 0);
message.success(`Retention pass completed — ${count} row(s) removed`);
fetchLogs();
fetchStats();
} catch (err) {
message.error(extractApiError(err, 'Retention pass failed'));
} finally {
setPurging(false);
}
},
});
}, [fetchLogs, fetchStats]);
const columns = useMemo(() => ([
{
title: 'Time',
dataIndex: 'created_at',
width: 180,
render: (v) => <Text style={{ fontSize: 12 }}>{formatTime(v)}</Text>,
},
{
title: 'Direction',
dataIndex: 'direction',
width: 110,
render: (v) => (
<Tag color={v === 'inbound' ? 'blue' : 'purple'}>{v === 'inbound' ? 'IN' : 'OUT'}</Tag>
),
},
{
title: 'Method',
dataIndex: 'method',
width: 90,
render: (v) => <Tag>{v}</Tag>,
},
{
title: 'URL',
dataIndex: 'url',
ellipsis: true,
render: (v) => (
<Tooltip title={v} placement="topLeft">
<Text style={{ fontSize: 12 }} ellipsis>{v}</Text>
</Tooltip>
),
},
{
title: 'Status',
dataIndex: 'status_code',
width: 100,
render: (v, row) => (
<Tag color={statusColor(row.status_class)}>{v ?? 'ERR'}</Tag>
),
},
{
title: 'Duration',
dataIndex: 'duration_ms',
width: 110,
render: (v) => (
// 1000ms matches the backend's slow-request threshold, so "red here"
// means "logged as slow there".
<Text type={v > 1000 ? 'danger' : undefined} style={{ fontSize: 12 }}>{v} ms</Text>
),
},
{
title: 'Who / Where',
key: 'who',
width: 200,
render: (_, row) => {
if (row.direction === 'inbound') {
return (
<Space size={4}>
<UserOutlined />
<Text style={{ fontSize: 12 }}>{row.username || (row.user_id ? `#${row.user_id}` : 'anonymous')}</Text>
</Space>
);
}
return (
<Tooltip title={TARGET_LABELS[row.target] || row.target}>
<Tag icon={<ApiOutlined />}>{row.target || '—'}</Tag>
</Tooltip>
);
},
},
{
title: 'Error',
dataIndex: 'error',
width: 200,
ellipsis: true,
render: (v) => (v ? (
<Tooltip title={v}><Text type="danger" style={{ fontSize: 12 }}>{v}</Text></Tooltip>
) : <Text type="secondary">—</Text>),
},
{
title: '',
key: 'actions',
width: 90,
fixed: 'right',
render: (_, row) => (
<Button size="small" icon={<EyeOutlined />} onClick={() => openDetail(row.id)}>
Detail
</Button>
),
},
]), [openDetail]);
if (!canRead) {
return (
<Card>
<Alert
type="error"
showIcon
message="Access denied"
description="You need the requestlog.read permission to view the request log."
/>
</Card>
);
}
const inboundStats = stats?.by_direction?.find((d) => d.direction === 'inbound');
const outboundStats = stats?.by_direction?.find((d) => d.direction === 'outbound');
const dropped = stats?.sink?.dropped || 0;
const detailRow = detail?.log;
return (
<div>
<Card
title={<Space><ClockCircleOutlined />Request Log</Space>}
extra={
<Space>
<Button icon={<ReloadOutlined />} onClick={() => { fetchLogs(); fetchStats(); }} loading={loading}>
Refresh
</Button>
{canManage && (
<Button icon={<DeleteOutlined />} onClick={runPurge} loading={purging}>
Apply retention now
</Button>
)}
</Space>
}
>
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="Every API call in, and every HTTP call out"
description={
<>
Inbound rows show which user called which endpoint and what came back.
Outbound rows show which CA, DNS provider or agent this backend contacted.
Bodies are captured <strong>redacted and size-capped</strong> — credentials,
tokens, private keys and ACME signatures are never stored. Retention is
configured in <Text code>Settings → Request Log</Text>.
</>
}
/>
{scopedToSelf && (
<Alert
type="warning"
showIcon
style={{ marginBottom: 16 }}
message="Showing your own requests only"
description="Seeing every user's traffic, and all outbound calls, requires the requestlog.manage permission."
/>
)}
{dropped > 0 && (
<Alert
type="warning"
showIcon
icon={<WarningOutlined />}
style={{ marginBottom: 16 }}
message={`${dropped} row(s) dropped by this worker`}
description="The writer queue filled up. Lower the sampling rate, turn off body capture, or raise REQUEST_LOG_QUEUE_MAX."
/>
)}
{stats && (
<Space size="large" wrap style={{ marginBottom: 16 }}>
<Statistic
title="Inbound (24h)"
value={inboundStats?.total || 0}
suffix={inboundStats?.errors ? <Text type="danger" style={{ fontSize: 14 }}>{`/ ${inboundStats.errors} failed`}</Text> : null}
/>
<Statistic
title="Outbound (24h)"
value={outboundStats?.total || 0}
suffix={outboundStats?.errors ? <Text type="danger" style={{ fontSize: 14 }}>{`/ ${outboundStats.errors} failed`}</Text> : null}
/>
<Statistic title="Rows stored" value={stats.total_rows || 0} />
<Statistic title="Oldest entry" valueRender={() => <span style={{ fontSize: 16 }}>{formatTime(stats.oldest_at)}</span>} />
</Space>
)}
<Space style={{ marginBottom: 16 }} wrap>
<Select
placeholder="All directions"
allowClear
style={{ width: 220 }}
value={direction}
onChange={(v) => { setDirection(v); setPage(1); }}
options={DIRECTION_OPTIONS}
/>
<Select
placeholder="All statuses"
allowClear
style={{ width: 210 }}
value={statusClass}
onChange={(v) => { setStatusClass(v); setPage(1); }}
options={STATUS_OPTIONS}
/>
<Select
placeholder="All methods"
allowClear
mode="multiple"
maxTagCount={1}
style={{ width: 180 }}
value={methods}
onChange={(v) => { setMethods(v); setPage(1); }}
options={METHOD_OPTIONS}
/>
<Select
placeholder="All targets"
allowClear
style={{ width: 220 }}
value={target}
disabled={direction === 'inbound'}
onChange={(v) => { setTarget(v); setPage(1); }}
options={Object.entries(TARGET_LABELS).map(([value, label]) => ({ value, label }))}
/>
<DatePicker.RangePicker
showTime
value={range}
onChange={(v) => { setRange(v); setPage(1); }}
/>
<Input.Search
placeholder="Search URL…"
allowClear
enterButton
style={{ width: 300 }}
value={searchTyped}
onChange={(e) => setSearchTyped(e.target.value)}
onSearch={(v) => { setSearch(v); setPage(1); }}
/>
<Space size={4}>
<Switch
checked={errorsOnly}
onChange={(v) => { setErrorsOnly(v); setPage(1); }}
checkedChildren="Errors"
unCheckedChildren="All"
/>
</Space>
</Space>
{loadError && (
<Alert type="error" showIcon style={{ marginBottom: 16 }} message={loadError} />
)}
<Table
rowKey="id"
size="small"
loading={loading}
dataSource={rows}
columns={columns}
scroll={{ x: 1400 }}
rowClassName={(row) => (isFailure(row) ? 'request-log-error-row' : '')}
locale={{
emptyText: <Empty description="No requests match these filters" />,
}}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
pageSizeOptions: ['25', '50', '100', '200'],
showTotal: (t, r) => `${r[0]}-${r[1]} of ${totalIsEstimate ? `${t}+` : t} requests`,
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
}}
/>
</Card>
<Modal
open={detailOpen}
onCancel={() => setDetailOpen(false)}
width={960}
destroyOnClose
title="Request detail"
footer={<Button onClick={() => setDetailOpen(false)}>Close</Button>}
>
{detailLoading ? (
<div style={{ textAlign: 'center', padding: 48 }}><Spin /></div>
) : detailError ? (
<Alert type="error" showIcon message={detailError} />
) : !detailRow ? (
<Empty description="Nothing to show" />
) : (
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Descriptions size="small" column={2} bordered>
<Descriptions.Item label="Time">{formatTime(detailRow.created_at)}</Descriptions.Item>
<Descriptions.Item label="Direction">
<Tag color={detailRow.direction === 'inbound' ? 'blue' : 'purple'}>{detailRow.direction}</Tag>
</Descriptions.Item>
<Descriptions.Item label="Method"><Tag>{detailRow.method}</Tag></Descriptions.Item>
<Descriptions.Item label="Status">
<Tag color={statusColor(detailRow.status_class)}>{detailRow.status_code ?? 'no response'}</Tag>
</Descriptions.Item>
<Descriptions.Item label="URL" span={2}>
<Text copyable style={{ fontSize: 12 }}>{detailRow.url}</Text>
</Descriptions.Item>
<Descriptions.Item label="Duration">{detailRow.duration_ms} ms</Descriptions.Item>
<Descriptions.Item label="Target">
{detailRow.target ? (TARGET_LABELS[detailRow.target] || detailRow.target) : '—'}
</Descriptions.Item>
<Descriptions.Item label="User">
{detailRow.username || (detailRow.user_id ? `#${detailRow.user_id}` : 'anonymous')}
</Descriptions.Item>
<Descriptions.Item label="Client IP">{detailRow.client_ip || '—'}</Descriptions.Item>
<Descriptions.Item label="Request id" span={2}>
<Text code copyable style={{ fontSize: 11 }}>{detailRow.request_id}</Text>
</Descriptions.Item>
{detailRow.error && (
<Descriptions.Item label="Error" span={2}>
<Text type="danger">{detailRow.error}</Text>
</Descriptions.Item>
)}
</Descriptions>
{detailRow.truncated && (
<Alert
type="warning"
showIcon
message="Body truncated"
description={
`Only the first part of the body was captured (request ${detailRow.request_body_bytes} bytes, ` +
`response ${detailRow.response_body_bytes} bytes on the wire). Raise the body cap in ` +
`Settings → Request Log if you need more.`
}
/>
)}
<Card size="small" title="Request headers">
<JsonBlock value={detailRow.request_headers} token={token} />
</Card>
<Card size="small" title="Request body">
<JsonBlock value={detailRow.request_body} token={token} />
</Card>
<Card size="small" title="Response headers">
<JsonBlock value={detailRow.response_headers} token={token} />
</Card>
<Card size="small" title="Response body">
<JsonBlock value={detailRow.response_body} token={token} />
</Card>
<Card
size="small"
title={<Space><CloudDownloadOutlined />Calls triggered by this request</Space>}
>
{detail?.related?.length ? (
<Table
rowKey="id"
size="small"
pagination={false}
dataSource={detail.related}
columns={[
{ title: 'Dir', dataIndex: 'direction', width: 70,
render: (v) => <Tag color={v === 'inbound' ? 'blue' : 'purple'}>{v === 'inbound' ? 'IN' : 'OUT'}</Tag> },
{ title: 'Target', dataIndex: 'target', width: 140, render: (v) => v || '—' },
{ title: 'Method', dataIndex: 'method', width: 80 },
{ title: 'URL', dataIndex: 'url', ellipsis: true },
{ title: 'Status', dataIndex: 'status_code', width: 80,
render: (v, r) => <Tag color={statusColor(r.status_class)}>{v ?? 'ERR'}</Tag> },
{ title: '', key: 'go', width: 70,
render: (_, r) => <Button size="small" type="link" onClick={() => openDetail(r.id)}>Open</Button> },
]}
/>
) : (
<Paragraph type="secondary" style={{ margin: 0 }}>
No other calls share this request id.
</Paragraph>
)}
</Card>
</Space>
)}
</Modal>
</div>
);
};
export default RequestLog;