Module 03: Speed & Error Reports

Beyond the overview, the dashboard needs detailed report pages. This module builds two focused reports: a speed report showing Web Vitals distributions and per-page performance, and an error report showing error frequency, trends, and expandable details.

Demo Files

1. Speed Report Design

The speed report answers the question every site owner asks: how fast is my site? It combines three layers of data into a single page:

All data comes from GET /api/performance, which returns per-page aggregates from the performance table. The API accepts start and end query parameters for date filtering.

+--------------------------------------------------+ | Performance Report [Start] [End] [Go] | +--------------------------------------------------+ | | | +------------+ +------------+ +------------+ | | | LCP (p75) | | CLS (p75) | | INP (p75) | | | | 2.1s | | 0.08 | | 180ms | | | | [ GOOD ] | | [ GOOD ] | | [ GOOD ] | | | +------------+ +------------+ +------------+ | | | | URL Load TTFB LCP CLS # | | ─────────────────────────────────────────────── | | /checkout 3200 890 3100 0.12 340 | | /product/123 2800 720 2650 0.05 890 | | /search 2100 510 1900 0.03 1200 | | / 1400 380 1200 0.01 4500 | +--------------------------------------------------+

The card backgrounds use the Web Vitals color scheme: green for good, orange for needs work, red for poor. This lets someone glance at the page and immediately know the site's health without reading any numbers.

2. Web Vitals Thresholds

Google defines three Core Web Vitals, each with specific thresholds that determine whether the user experience is good, needs improvement, or poor. The speed report uses these thresholds to color-code every metric:

Metric Good Needs Work Poor
LCP (Largest Contentful Paint) < 2500ms 2500 – 4000ms > 4000ms
CLS (Cumulative Layout Shift) < 0.1 0.1 – 0.25 > 0.25
INP (Interaction to Next Paint) < 200ms 200 – 500ms > 500ms

The color values used in the report:

These are the official Web Vitals colors from Google's web.dev documentation. Using the standard colors means anyone familiar with Web Vitals will instantly understand the report without a legend.

P75 vs average: Web Vitals are typically reported at the 75th percentile (p75), not the average. This means 75% of user experiences meet or exceed the threshold. The p75 is more representative than the average because it is not skewed by extreme outliers (like a user on a 2G connection loading a 10MB page). Our API returns averages for simplicity, but a production system would compute percentiles with SQL window functions like PERCENTILE_CONT(0.75).

3. Building the Speed Report

The speed-report.html file is a self-contained page with inline CSS and JavaScript. Here is how it works step by step.

Fetching Performance Data

On page load, and whenever the user changes the date range, the page fetches data from the reporting API:

async function loadData() {
    const start = document.getElementById('startDate').value;
    const end = document.getElementById('endDate').value;
    const url = '/api/performance?start=' +
        encodeURIComponent(start) + '&end=' + encodeURIComponent(end);

    const resp = await fetch(url, { credentials: 'include' });
    const json = await resp.json();
    if (!json.success) {
        showError(json.error);
        return;
    }
    renderCards(json.data.byPage);
    renderTable(json.data.byPage);
}

The credentials: 'include' option sends the session cookie with the cross-origin request. Without it, the API would return 401 because the session cookie would not be included.

Rendering Vitals Cards

The card rendering function computes site-wide averages across all pages, then applies the threshold color:

function getVitalColor(metric, value) {
    const thresholds = {
        lcp:  { good: 2500, poor: 4000 },
        cls:  { good: 0.1,  poor: 0.25 },
        inp:  { good: 200,  poor: 500 }
    };
    const t = thresholds[metric];
    if (value < t.good) return '#0cce6b';
    if (value < t.poor) return '#ffa400';
    return '#ff4e42';
}

function renderCards(byPage) {
    // Compute weighted averages across all pages
    let totalLcp = 0, totalCls = 0, totalSamples = 0;
    for (const row of byPage) {
        totalLcp += row.avg_lcp * row.samples;
        totalCls += row.avg_cls * row.samples;
        totalSamples += row.samples;
    }
    const avgLcp = totalSamples ? totalLcp / totalSamples : 0;
    const avgCls = totalSamples ? totalCls / totalSamples : 0;

    // Set card backgrounds
    setCard('lcpCard', avgLcp.toFixed(0) + 'ms', getVitalColor('lcp', avgLcp));
    setCard('clsCard', avgCls.toFixed(3), getVitalColor('cls', avgCls));
}

The setCard helper updates the card's value text and background color. It uses textContent rather than innerHTML to prevent any XSS from unexpected data values.

Rendering the Per-Page Table

The table is built by iterating over the byPage array and creating rows. URLs are inserted using textContent since they come from user-generated data (the pages visitors actually visited):

function renderTable(byPage) {
    const tbody = document.getElementById('perfBody');
    tbody.innerHTML = '';
    for (const row of byPage) {
        const tr = document.createElement('tr');
        if (row.avg_load_ms > 3000) {
            tr.style.borderLeft = '4px solid #ff4e42';
        }

        const urlTd = document.createElement('td');
        urlTd.textContent = row.url;           // XSS-safe
        tr.appendChild(urlTd);

        const fields = ['avg_load_ms', 'avg_ttfb_ms', 'avg_lcp', 'avg_cls', 'samples'];
        for (const f of fields) {
            const td = document.createElement('td');
            td.textContent = row[f];
            tr.appendChild(td);
        }
        tbody.appendChild(tr);
    }
}
XSS safety: The url field comes from the analytics database, which stores whatever URL the visitor was on. A malicious visitor could craft a URL containing <script> tags. Using textContent ensures the URL is rendered as plain text, not parsed as HTML. Never use innerHTML for user-sourced data.

4. Error Report Design

The error report helps developers find and fix JavaScript errors affecting real users. It combines four elements:

+--------------------------------------------------+ | Error Report [Start] [End] [Go] | +--------------------------------------------------+ | | | +--------------------+ | | | Total Errors: 147 | | | +--------------------+ | | | | Errors per Day | | 30 | * | | 20 | * * * * | | 10 | * * * * * * * | | 0 +---+---+---+---+---+--- | | Feb 1 Feb 5 Feb 9 Feb 13 | | | | Error Message Count Last Seen | | ─────────────────────────────────────────────── | | Cannot read property 'x' 42 Feb 15 | | [expanded: full message and details] | | fetch failed: NetworkError 31 Feb 14 | | TypeError: null is not... 28 Feb 15 | +--------------------------------------------------+

Data comes from GET /api/errors, which returns two datasets: byMessage (grouped errors with counts) and trend (daily error counts for the chart).

5. Building the Error Report

The error-report.html file follows the same self-contained pattern. Let's walk through the key pieces.

Fetching Error Data

async function loadData() {
    const start = document.getElementById('startDate').value;
    const end = document.getElementById('endDate').value;
    const url = '/api/errors?start=' +
        encodeURIComponent(start) + '&end=' + encodeURIComponent(end);

    const resp = await fetch(url, { credentials: 'include' });
    const json = await resp.json();
    if (!json.success) {
        showError(json.error);
        return;
    }
    renderSummary(json.data.byMessage);
    renderChart(json.data.trend);
    renderTable(json.data.byMessage);
}

Drawing the Trend Chart

The error trend chart reuses the canvas line chart pattern from the overview page. The key function draws a line graph on a <canvas> element:

function renderChart(trend) {
    const canvas = document.getElementById('trendChart');
    const ctx = canvas.getContext('2d');
    const W = canvas.width, H = canvas.height;
    const pad = { top: 20, right: 20, bottom: 40, left: 50 };

    ctx.clearRect(0, 0, W, H);

    if (trend.length === 0) return;

    const maxVal = Math.max(...trend.map(d => d.error_count));
    const xStep = (W - pad.left - pad.right) / (trend.length - 1 || 1);

    // Draw axes
    ctx.strokeStyle = '#ccc';
    ctx.beginPath();
    ctx.moveTo(pad.left, pad.top);
    ctx.lineTo(pad.left, H - pad.bottom);
    ctx.lineTo(W - pad.right, H - pad.bottom);
    ctx.stroke();

    // Draw line
    ctx.strokeStyle = '#2E86C1';
    ctx.lineWidth = 2;
    ctx.beginPath();
    trend.forEach((d, i) => {
        const x = pad.left + i * xStep;
        const y = H - pad.bottom -
            (d.error_count / (maxVal || 1)) * (H - pad.top - pad.bottom);
        if (i === 0) ctx.moveTo(x, y);
        else ctx.lineTo(x, y);
    });
    ctx.stroke();

    // Draw dots and labels
    // ... (abbreviated — see full source in error-report.html)
}

The chart scales dynamically to the data range. The y-axis maps from 0 to the maximum daily error count, and the x-axis spaces days evenly across the canvas width.

Expandable Table Rows

The error table uses a simple click-to-expand pattern. Each error row is followed by a hidden detail row:

function renderTable(byMessage) {
    const tbody = document.getElementById('errorBody');
    tbody.innerHTML = '';
    for (const row of byMessage) {
        // Summary row (always visible)
        const tr = document.createElement('tr');
        tr.style.cursor = 'pointer';

        const msgTd = document.createElement('td');
        msgTd.textContent = row.error_message;   // XSS-safe
        tr.appendChild(msgTd);

        const countTd = document.createElement('td');
        countTd.textContent = row.occurrences;
        tr.appendChild(countTd);

        const dateTd = document.createElement('td');
        dateTd.textContent = row.last_seen;
        tr.appendChild(dateTd);

        // Detail row (hidden by default)
        const detailTr = document.createElement('tr');
        detailTr.style.display = 'none';
        const detailTd = document.createElement('td');
        detailTd.colSpan = 3;
        detailTd.style.background = '#f8f9fa';
        detailTd.style.whiteSpace = 'pre-wrap';
        detailTd.style.fontFamily = 'monospace';
        detailTd.style.fontSize = '12px';
        detailTd.textContent = row.error_message; // Full message
        detailTr.appendChild(detailTd);

        // Toggle on click
        tr.addEventListener('click', () => {
            detailTr.style.display =
                detailTr.style.display === 'none' ? '' : 'none';
        });

        tbody.appendChild(tr);
        tbody.appendChild(detailTr);
    }
}
Why textContent for errors? Error messages are especially dangerous because attackers can deliberately trigger errors with crafted messages. For example, an attacker could create a URL like https://example.com/<img src=x onerror=alert(1)> — if the page throws an error containing this URL, and you render it with innerHTML, the script executes. Always use textContent.

6. Linking from Dashboard Navigation

These report pages can be integrated into the dashboard in two ways, depending on your architecture:

Option A: Hash-Based SPA Routing

If the dashboard uses single-page app routing with hash fragments, add routes for #/performance and #/errors:

// In the main dashboard router
function handleRoute(hash) {
    switch (hash) {
        case '#/':
            loadOverview();
            break;
        case '#/performance':
            loadPerformanceReport();
            break;
        case '#/errors':
            loadErrorReport();
            break;
        case '#/admin':
            loadAdminPanel();
            break;
        default:
            loadOverview();
    }
}

Each route function would fetch the appropriate API endpoint and render the content into the main content area of the SPA.

Option B: Standalone Pages

The demo files (speed-report.html and error-report.html) work as standalone pages. Link to them directly from the dashboard navigation:

<nav>
    <a href="overview.html">Overview</a>
    <a href="speed-report.html">Performance</a>
    <a href="error-report.html">Errors</a>
    <a href="admin.html">Admin</a>
</nav>

Standalone pages are simpler to build and debug. Each page manages its own state and data fetching. The trade-off is that navigation causes a full page reload rather than a smooth SPA transition. For an analytics dashboard where users spend most of their time on a single report, this trade-off is often acceptable.

Recommendation: Start with standalone pages. You can always refactor to SPA routing later if the navigation feels sluggish. The API calls and rendering logic remain the same either way — only the routing layer changes.