diff --git a/CHANGELOG.md b/CHANGELOG.md index 760d38f..eea66a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,28 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- +## v26.05.10 (2026-05-31) + +### Admin dashboard — HTTP request analytics on Traces + +- The **Traces** view now leads with **live request analytics** computed from the + trace stream: + - **Stat cards**: Total Requests, Avg Duration, **Error Rate** (4xx+5xx %, tinted + amber/red when elevated), and Max Latency. + - **Status Mix**: a segmented bar + legend showing the 2xx/3xx/4xx/5xx split with + counts and percentages. + - **Latency Distribution**: a histogram across latency buckets (<10 ms … ≥1 s) + plus a **p50 / p90 / p95 / p99** percentile strip. +- Everything updates live as requests arrive (debounced) and resets on **Clear**. +- The client trace buffer is now **bounded to 500 entries** (matching the server + ring buffer): the in-memory array, the table DOM and the per-refresh analytics + cost no longer grow without bound on a long-lived dashboard tab. +- Responsive (cards/charts stack and resize on mobile, dark + light themes) and + accessible (the decorative mix bar is `aria-hidden`; the legend carries the + numbers). Avg Duration reads `--` (not `0.0 ms`) when no trace carries a duration. + +--- + ## v26.05.09 (2026-05-31) ### Admin dashboard — live time-series metrics diff --git a/docs/modules/admin.md b/docs/modules/admin.md index 3f58f0a..6c2fa81 100644 --- a/docs/modules/admin.md +++ b/docs/modules/admin.md @@ -238,7 +238,7 @@ stream for live updates. |------|-----------|-------------| | **Metrics** | `metrics` | Built-in process metrics (CPU, memory, threads, GC, uptime) always available without external dependencies. Optional Prometheus metrics included when `prometheus_client` is installed. Selecting a numeric metric opens a **live time-series trend** — a rolling chart polled at the configured refresh interval with a Value / Rate (Δ/s) toggle, pause/resume, Current/Min/Max/Avg summary, a measurement selector for multi-series (tagged) metrics, and a live-refreshing measurements table. Non-numeric metrics show a snapshot instead. | | **Scheduled Tasks** | `scheduled` | All `@scheduled` tasks with cron expressions, fixed-rate/delay configuration, and execution status. | -| **HTTP Traces** | `traces` | Recent HTTP request/response traces captured by `TraceCollectorFilter`. Shows method, path, status code, duration, query string, client host, content type, user agent, and response content-length. Click-to-detail panel. Status code filter pills (All, 2xx, 3xx, 4xx, 5xx). Real-time SSE for new traces. Ring buffer of 500 entries. | +| **HTTP Traces** | `traces` | Recent HTTP request/response traces captured by `TraceCollectorFilter`. **Live request analytics**: total requests, average/max latency, error rate (4xx+5xx), a status-code mix bar (2xx/3xx/4xx/5xx), a latency-distribution histogram and latency percentiles (p50/p90/p95/p99) — all updating live as requests arrive. Shows method, path, status code, duration, query string, client host, content type, user agent, and response content-length. Click-to-detail panel. Status code filter pills (All, 2xx, 3xx, 4xx, 5xx). Real-time SSE for new traces. Client buffer bounded to the 500-entry ring buffer. | ### Infrastructure diff --git a/pyproject.toml b/pyproject.toml index e92fceb..df303fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "pyfly" # CalVer YY.MM.PATCH — package metadata uses PEP 440 normalized form (26.5.4); # git tag, GitHub release and human-readable display use leading-zero form # (v26.05.04) to match the Java/.NET/Go siblings. -version = "26.5.9" +version = "26.5.10" description = "The official Python implementation of the Firefly Framework — DI, CQRS, EDA, hexagonal architecture, and more." readme = "README.md" license = "Apache-2.0" diff --git a/src/pyfly/__init__.py b/src/pyfly/__init__.py index c937997..6e35aaa 100644 --- a/src/pyfly/__init__.py +++ b/src/pyfly/__init__.py @@ -13,4 +13,4 @@ # limitations under the License. """PyFly — Enterprise Python Framework.""" -__version__ = "26.05.09" +__version__ = "26.05.10" diff --git a/src/pyfly/admin/static/css/admin.css b/src/pyfly/admin/static/css/admin.css index d57ea0e..48fc530 100644 --- a/src/pyfly/admin/static/css/admin.css +++ b/src/pyfly/admin/static/css/admin.css @@ -2010,3 +2010,52 @@ body.wallboard-mode .admin-content { grid-template-columns: repeat(2, 1fr); } } + +/* ── Traces: status-mix bar ─────────────────────────────────────── */ +.status-bar { + display: flex; + height: 14px; + border-radius: 999px; + overflow: hidden; + background: var(--admin-bg-subtle); + border: 1px solid var(--admin-border-subtle); +} + +.status-bar-seg { + height: 100%; + transition: width var(--admin-transition); + min-width: 0; +} + +.status-legend { + display: flex; + flex-wrap: wrap; + gap: 10px 20px; + margin-top: 16px; +} + +.status-legend-item { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.8rem; + color: var(--admin-text-secondary); +} + +.status-legend-dot { + width: 10px; + height: 10px; + border-radius: 3px; + flex-shrink: 0; +} + +.status-legend-count { + font-family: var(--admin-font-mono); + font-weight: 600; + color: var(--admin-text); +} + +.status-legend-pct { + color: var(--admin-text-muted); + font-size: 0.72rem; +} diff --git a/src/pyfly/admin/static/js/views/traces.js b/src/pyfly/admin/static/js/views/traces.js index a398132..85cb10f 100644 --- a/src/pyfly/admin/static/js/views/traces.js +++ b/src/pyfly/admin/static/js/views/traces.js @@ -1,15 +1,17 @@ /** * PyFly Admin — Traces View. * - * Real-time HTTP trace viewer with SSE live updates, - * pause/resume toggle, clear functionality, status filter pills, - * and click-to-detail panel. + * Real-time HTTP trace viewer with SSE live updates, pause/resume, + * clear, status filter pills, click-to-detail panel — plus live + * request analytics: latency percentiles, status-code mix, and a + * latency-distribution histogram. * * Data sources: - * GET /admin/api/traces -> { traces: [...], total: N } - * SSE /admin/api/sse/traces -> event type "trace" + * GET /admin/api/traces?limit=500 -> { traces: [...], total: N } + * SSE /admin/api/sse/traces -> event type "trace" */ +import { createBarChart } from '../charts.js'; import { createMethodBadge } from '../components/status-badge.js'; import { sse } from '../sse.js'; @@ -34,6 +36,17 @@ function formatTime(ts) { } } +/** + * Format a duration in milliseconds compactly (ms, or seconds when large). + * @param {number|null} ms + * @returns {string} + */ +function formatMs(ms) { + if (ms == null || !Number.isFinite(ms)) return '--'; + if (ms >= 1000) return (ms / 1000).toFixed(2) + ' s'; + return ms.toFixed(1) + ' ms'; +} + /** * Return a CSS class for an HTTP status code. * @param {number} status @@ -72,6 +85,141 @@ function statusGroup(status) { return 'other'; } +/* ── Analytics ────────────────────────────────────────────────── */ + +/** Status groups shown in the mix bar/legend, with theme colours. */ +const STATUS_META = [ + { key: '2xx', label: '2xx Success', color: '--admin-success' }, + { key: '3xx', label: '3xx Redirect', color: '--admin-info' }, + { key: '4xx', label: '4xx Client', color: '--admin-warning' }, + { key: '5xx', label: '5xx Server', color: '--admin-danger' }, + { key: 'other', label: 'Other', color: '--admin-text-muted' }, +]; + +/** Latency histogram buckets (upper bound exclusive, in ms). */ +const LATENCY_BUCKETS = [ + { label: '<10', max: 10 }, + { label: '10–50', max: 50 }, + { label: '50–100', max: 100 }, + { label: '100–250', max: 250 }, + { label: '250–500', max: 500 }, + { label: '0.5–1s', max: 1000 }, + { label: '≥1s', max: Infinity }, +]; + +/** + * Client-side cap on the live trace buffer (mirrors the server ring buffer). + * Bounds memory, table DOM size, and per-refresh analytics cost on a + * long-lived dashboard tab, and keeps the analytics window a stable "last N" + * set consistent with the ?limit=500 initial fetch. + */ +const MAX_TRACES = 500; + +/** + * Nearest-rank percentile over an ascending-sorted array. + * @param {number[]} sorted Ascending durations. + * @param {number} p Percentile (0-100). + * @returns {number|null} + */ +function percentile(sorted, p) { + if (sorted.length === 0) return null; + if (sorted.length === 1) return sorted[0]; + const rank = Math.ceil((p / 100) * sorted.length); + const idx = Math.min(sorted.length - 1, Math.max(0, rank - 1)); + return sorted[idx]; +} + +/** + * Bucket ascending durations into the LATENCY_BUCKETS counts. + * @param {number[]} sorted + * @returns {number[]} + */ +function bucketize(sorted) { + const counts = LATENCY_BUCKETS.map(() => 0); + for (const d of sorted) { + for (let i = 0; i < LATENCY_BUCKETS.length; i++) { + if (d < LATENCY_BUCKETS[i].max) { counts[i]++; break; } + } + } + return counts; +} + +/** + * Compute request analytics over the current trace buffer. + * @param {object[]} traces + */ +function computeAnalytics(traces) { + const durations = traces + .map((t) => t.duration_ms) + .filter((d) => typeof d === 'number' && Number.isFinite(d)) + .sort((a, b) => a - b); + + const counts = { '2xx': 0, '3xx': 0, '4xx': 0, '5xx': 0, other: 0 }; + for (const t of traces) counts[statusGroup(t.status)]++; + + const total = traces.length; + const errors = counts['4xx'] + counts['5xx']; + const avg = durations.length + ? durations.reduce((a, b) => a + b, 0) / durations.length + : null; + + return { + total, + avg, + p50: percentile(durations, 50), + p90: percentile(durations, 90), + p95: percentile(durations, 95), + p99: percentile(durations, 99), + max: durations.length ? durations[durations.length - 1] : null, + errorRate: total ? (errors / total) * 100 : 0, + counts, + buckets: bucketize(durations), + }; +} + +/* ── Small builders ───────────────────────────────────────────── */ + +/** + * Build a stat card; returns the card and its value element. + * @param {string} label + * @returns {{ card: HTMLElement, valueEl: HTMLElement }} + */ +function buildStatCard(label) { + const card = document.createElement('div'); + card.className = 'stat-card'; + const content = document.createElement('div'); + content.className = 'stat-card-content'; + const valueEl = document.createElement('div'); + valueEl.className = 'stat-card-value'; + valueEl.textContent = '--'; + content.appendChild(valueEl); + const labelEl = document.createElement('div'); + labelEl.className = 'stat-card-label'; + labelEl.textContent = label; + content.appendChild(labelEl); + card.appendChild(content); + return { card, valueEl }; +} + +/** + * Build one labelled stat in the percentile strip. + * @param {string} label + * @returns {{ el: HTMLElement, set: (t: string) => void }} + */ +function buildTrendStat(label) { + const el = document.createElement('div'); + el.className = 'trend-stat'; + const lab = document.createElement('div'); + lab.className = 'trend-stat-label'; + lab.textContent = label; + el.appendChild(lab); + const val = document.createElement('div'); + val.className = 'trend-stat-value'; + val.textContent = '--'; + el.appendChild(val); + return { el, set: (t) => { val.textContent = t; } }; +} + /* ── Detail Panel ────────────────────────────────────────────── */ function createDetailPanel() { @@ -177,18 +325,15 @@ function createTraceRow(trace, onClick) { tr.classList.add('clickable'); tr.addEventListener('click', () => onClick(trace)); - // Time const tdTime = document.createElement('td'); tdTime.textContent = formatTime(trace.timestamp); tdTime.className = 'text-mono text-sm'; tr.appendChild(tdTime); - // Method const tdMethod = document.createElement('td'); tdMethod.appendChild(createMethodBadge(trace.method)); tr.appendChild(tdMethod); - // Path const tdPath = document.createElement('td'); const pathSpan = document.createElement('span'); pathSpan.className = 'mono'; @@ -196,12 +341,10 @@ function createTraceRow(trace, onClick) { tdPath.appendChild(pathSpan); tr.appendChild(tdPath); - // Status const tdStatus = document.createElement('td'); tdStatus.appendChild(createStatusCodeBadge(trace.status)); tr.appendChild(tdStatus); - // Duration const tdDuration = document.createElement('td'); tdDuration.className = 'text-mono text-sm'; const ms = trace.duration_ms != null ? trace.duration_ms.toFixed(1) : '--'; @@ -217,7 +360,7 @@ function createTraceRow(trace, onClick) { * Render the traces view. * @param {HTMLElement} container * @param {import('../api.js').AdminAPI} api - * @returns {function} Cleanup function to disconnect SSE. + * @returns {function} Cleanup function. */ export async function render(container, api) { container.replaceChildren(); @@ -262,10 +405,10 @@ export async function render(container, api) { wrapper.appendChild(loader); container.appendChild(wrapper); - // Fetch initial traces + // Fetch initial traces (a wider window for meaningful analytics). let data; try { - data = await api.get('/traces'); + data = await api.get('/traces?limit=500'); } catch (err) { wrapper.removeChild(loader); const errCard = document.createElement('div'); @@ -278,7 +421,7 @@ export async function render(container, api) { errBody.appendChild(errText); errCard.appendChild(errBody); wrapper.appendChild(errCard); - return; + return () => {}; } wrapper.removeChild(loader); @@ -291,45 +434,116 @@ export async function render(container, api) { wrapper.appendChild(overlay); wrapper.appendChild(panel); - // ── Stats row ──────────────────────────────────────────── + // ── Stat cards row ─────────────────────────────────────── const statsRow = document.createElement('div'); - statsRow.className = 'grid-2 mb-lg'; - - const totalCard = document.createElement('div'); - totalCard.className = 'stat-card'; - const totalContent = document.createElement('div'); - totalContent.className = 'stat-card-content'; - const totalVal = document.createElement('div'); - totalVal.className = 'stat-card-value'; - totalVal.textContent = String(traces.length); - totalContent.appendChild(totalVal); - const totalLabel = document.createElement('div'); - totalLabel.className = 'stat-card-label'; - totalLabel.textContent = 'Total Traces'; - totalContent.appendChild(totalLabel); - totalCard.appendChild(totalContent); - statsRow.appendChild(totalCard); - - const avgCard = document.createElement('div'); - avgCard.className = 'stat-card'; - const avgContent = document.createElement('div'); - avgContent.className = 'stat-card-content'; - const avgVal = document.createElement('div'); - avgVal.className = 'stat-card-value'; - const avgDuration = traces.length > 0 - ? (traces.reduce((sum, t) => sum + (t.duration_ms || 0), 0) / traces.length).toFixed(1) - : '0.0'; - avgVal.textContent = avgDuration + ' ms'; - avgContent.appendChild(avgVal); - const avgLabel = document.createElement('div'); - avgLabel.className = 'stat-card-label'; - avgLabel.textContent = 'Avg Duration'; - avgContent.appendChild(avgLabel); - avgCard.appendChild(avgContent); - statsRow.appendChild(avgCard); - + statsRow.className = 'grid-4 mb-lg'; + const totalStat = buildStatCard('Total Requests'); + const avgStat = buildStatCard('Avg Duration'); + const errStat = buildStatCard('Error Rate'); + const maxStat = buildStatCard('Max Latency'); + statsRow.appendChild(totalStat.card); + statsRow.appendChild(avgStat.card); + statsRow.appendChild(errStat.card); + statsRow.appendChild(maxStat.card); wrapper.appendChild(statsRow); + // ── Analytics row (status mix + latency distribution) ──── + const analyticsRow = document.createElement('div'); + analyticsRow.className = 'grid-2 mb-lg'; + + // Status mix card + const mixCard = document.createElement('div'); + mixCard.className = 'admin-card'; + const mixHeader = document.createElement('div'); + mixHeader.className = 'admin-card-header'; + const mixTitle = document.createElement('h3'); + mixTitle.textContent = 'Status Mix'; + mixHeader.appendChild(mixTitle); + mixCard.appendChild(mixHeader); + const mixBody = document.createElement('div'); + mixBody.className = 'admin-card-body'; + + const statusBar = document.createElement('div'); + statusBar.className = 'status-bar'; + // Decorative — the per-group counts/percentages are read from the legend below. + statusBar.setAttribute('aria-hidden', 'true'); + const statusLegend = document.createElement('div'); + statusLegend.className = 'status-legend'; + + // Pre-build a segment + legend item per status group. + const mixRefs = {}; + for (const meta of STATUS_META) { + const seg = document.createElement('div'); + seg.className = 'status-bar-seg'; + seg.style.background = `var(${meta.color})`; + seg.style.width = '0%'; + seg.title = meta.label; + statusBar.appendChild(seg); + + const item = document.createElement('div'); + item.className = 'status-legend-item'; + const dot = document.createElement('span'); + dot.className = 'status-legend-dot'; + dot.style.background = `var(${meta.color})`; + item.appendChild(dot); + const lab = document.createElement('span'); + lab.textContent = meta.label; + item.appendChild(lab); + const count = document.createElement('span'); + count.className = 'status-legend-count'; + count.textContent = '0'; + item.appendChild(count); + const pct = document.createElement('span'); + pct.className = 'status-legend-pct'; + pct.textContent = '(0%)'; + item.appendChild(pct); + + mixRefs[meta.key] = { seg, item, count, pct }; + statusLegend.appendChild(item); + } + mixBody.appendChild(statusBar); + mixBody.appendChild(statusLegend); + mixCard.appendChild(mixBody); + analyticsRow.appendChild(mixCard); + + // Latency distribution card + const latCard = document.createElement('div'); + latCard.className = 'admin-card'; + const latHeader = document.createElement('div'); + latHeader.className = 'admin-card-header'; + const latTitle = document.createElement('h3'); + latTitle.textContent = 'Latency Distribution'; + latHeader.appendChild(latTitle); + latCard.appendChild(latHeader); + const latBody = document.createElement('div'); + latBody.className = 'admin-card-body'; + + const latCanvasWrap = document.createElement('div'); + latCanvasWrap.style.height = '180px'; + const latCanvas = document.createElement('canvas'); + latCanvasWrap.appendChild(latCanvas); + latBody.appendChild(latCanvasWrap); + + // Percentile strip + const pctStrip = document.createElement('div'); + pctStrip.className = 'trend-stats'; + const pctStats = { + p50: buildTrendStat('p50'), + p90: buildTrendStat('p90'), + p95: buildTrendStat('p95'), + p99: buildTrendStat('p99'), + }; + pctStrip.appendChild(pctStats.p50.el); + pctStrip.appendChild(pctStats.p90.el); + pctStrip.appendChild(pctStats.p95.el); + pctStrip.appendChild(pctStats.p99.el); + latBody.appendChild(pctStrip); + + latCard.appendChild(latBody); + analyticsRow.appendChild(latCard); + + wrapper.appendChild(analyticsRow); + // ── Status filter pills ────────────────────────────────── let activeFilter = ''; const pillBar = document.createElement('div'); @@ -405,6 +619,7 @@ export async function render(container, api) { if (filtered.length === 0) { const tr = document.createElement('tr'); + tr.className = 'trace-empty-row'; const td = document.createElement('td'); td.colSpan = 5; td.style.textAlign = 'center'; @@ -426,35 +641,90 @@ export async function render(container, api) { tableCard.appendChild(tableWrap); wrapper.appendChild(tableCard); - // ── Stats updater ──────────────────────────────────────── - function updateStats() { - totalVal.textContent = String(traces.length); - if (traces.length > 0) { - const avg = traces.reduce((s, t) => s + (t.duration_ms || 0), 0) / traces.length; - avgVal.textContent = avg.toFixed(1) + ' ms'; - } else { - avgVal.textContent = '0.0 ms'; + // ── Analytics rendering ────────────────────────────────── + let latChart = null; + + function refreshAnalytics() { + const a = computeAnalytics(traces); + + totalStat.valueEl.textContent = String(a.total); + avgStat.valueEl.textContent = formatMs(a.avg); + maxStat.valueEl.textContent = formatMs(a.max); + + errStat.valueEl.textContent = a.errorRate.toFixed(1) + '%'; + // Tint the error rate when elevated. + errStat.valueEl.style.color = + a.errorRate >= 5 ? 'var(--admin-danger)' + : a.errorRate > 0 ? 'var(--admin-warning)' + : ''; + + // Status mix bar + legend. + for (const meta of STATUS_META) { + const c = a.counts[meta.key] || 0; + const refs = mixRefs[meta.key]; + const pctNum = a.total ? (c / a.total) * 100 : 0; + refs.seg.style.width = pctNum + '%'; + refs.count.textContent = String(c); + refs.pct.textContent = `(${pctNum.toFixed(0)}%)`; + // Hide empty "other"/legend rows for groups with no traffic to reduce noise. + refs.item.style.display = c === 0 && meta.key === 'other' ? 'none' : ''; + } + + // Percentile strip. + pctStats.p50.set(formatMs(a.p50)); + pctStats.p90.set(formatMs(a.p90)); + pctStats.p95.set(formatMs(a.p95)); + pctStats.p99.set(formatMs(a.p99)); + + // Latency histogram. + if (latChart) { + latChart.update([...a.buckets], LATENCY_BUCKETS.map((b) => b.label)); } } - // ── SSE: Real-time trace updates ───────────────────────── + // Build the histogram once the canvas is laid out, then paint analytics. + requestAnimationFrame(() => { + if (!latCanvas.isConnected) return; + const a0 = computeAnalytics(traces); + latChart = createBarChart(latCanvas, { + data: a0.buckets, + labels: LATENCY_BUCKETS.map((b) => b.label), + }); + refreshAnalytics(); + }); + // Paint the non-chart analytics immediately (chart fills in on next frame). + refreshAnalytics(); + + // ── SSE: real-time trace updates (analytics refresh debounced) ── let paused = false; + let analyticsTimer = null; + + function scheduleAnalyticsRefresh() { + if (analyticsTimer !== null) return; + analyticsTimer = setTimeout(() => { + analyticsTimer = null; + refreshAnalytics(); + }, 400); + } sse.connectTyped('/traces', 'trace', (traceData) => { if (paused) return; traces.unshift(traceData); + // Bound the in-memory window (newest kept). + if (traces.length > MAX_TRACES) traces.length = MAX_TRACES; - // Only insert into DOM if the trace matches the active filter + // Only insert into DOM if the trace matches the active filter. if (!activeFilter || statusGroup(traceData.status) === activeFilter) { - const newRow = createTraceRow(traceData, show); - if (tbody.firstChild) { - tbody.insertBefore(newRow, tbody.firstChild); - } else { - tbody.replaceChildren(); - tbody.appendChild(newRow); + // Drop the empty-state placeholder row before inserting real data. + if (tbody.querySelector('.trace-empty-row')) tbody.replaceChildren(); + // insertBefore(node, null) appends when the table is empty. + tbody.insertBefore(createTraceRow(traceData, show), tbody.firstChild); + // Bound the DOM in lockstep with the buffer. + while (tbody.childElementCount > MAX_TRACES) { + tbody.removeChild(tbody.lastElementChild); } } - updateStats(); + scheduleAnalyticsRefresh(); }); // ── Pause/Resume ───────────────────────────────────────── @@ -474,11 +744,19 @@ export async function render(container, api) { clearBtn.addEventListener('click', () => { traces.length = 0; renderTraces(); - updateStats(); + refreshAnalytics(); }); // ── Cleanup ────────────────────────────────────────────── return function cleanup() { sse.disconnect('/traces'); + if (analyticsTimer !== null) { + clearTimeout(analyticsTimer); + analyticsTimer = null; + } + if (latChart) { + latChart.destroy(); + latChart = null; + } }; } diff --git a/uv.lock b/uv.lock index 5d29a38..af2d523 100644 --- a/uv.lock +++ b/uv.lock @@ -1589,7 +1589,7 @@ wheels = [ [[package]] name = "pyfly" -version = "26.5.9" +version = "26.5.10" source = { editable = "." } dependencies = [ { name = "pydantic" },