Reporting API

The reporting API sits between the database and the dashboard. It translates SQL queries into JSON endpoints that the front-end can consume. It also handles authentication — only authorized users should see analytics data.

1. Why a Separate Reporting API

You could embed SQL queries directly in the dashboard, but that couples your front-end to your database schema. A separate API layer creates a clean boundary: the dashboard is pure client-side JavaScript that fetches JSON, and the API handles all data access and authentication on the server.

Separation of Concerns

The dashboard should not know that pageviews are stored in a MySQL table called pageviews, that timestamps are in the server_timestamp column, or that session duration requires a TIMESTAMPDIFF calculation. The dashboard should ask "give me pageviews for the last 7 days" and receive a clean JSON response. If you later switch from MySQL to PostgreSQL, or restructure your tables, or add caching — the dashboard does not change.

Multiple Clients

An API can serve more than one consumer. Today it powers your web dashboard. Tomorrow it could power a mobile app, a CLI tool for quick checks, a Slack bot that posts daily summaries, or an export script that generates CSV reports. Every consumer speaks the same JSON protocol. Without an API, each client would need its own database connection and query logic.

Independent Testing, Security, and Scaling

┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │ Dashboard │ │ Reporting API │ │ Database │ │ (Browser JS) │──────>│ (Node / PHP) │──────>│ (MySQL) │ │ │<──────│ │<──────│ │ │ fetch('/api/ │ JSON │ SQL queries │ rows │ pageviews │ │ pageviews') │ │ auth checks │ │ sessions │ │ │ │ rate limiting │ │ errors │ └────────────────┘ └────────────────┘ │ performance │ │ users │ ┌──────────┐ │ └────────────────┘ │ Mobile │────────────────────┘ │ App │ Same JSON endpoints └──────────┘ ┌──────────┐ │ │ CLI Tool │────────────────────┘ └──────────┘
The API is the single point of access to analytics data. No client — whether browser, mobile app, or script — should connect to the database directly. Every data request goes through the API, where authentication, validation, and rate limiting are enforced uniformly.

2. Endpoint Design

The API follows RESTful conventions: resources are nouns, HTTP methods indicate the action, and responses use standard status codes. Every analytics endpoint uses GET because dashboards only read data. The only POST endpoints are for authentication.

Method Endpoint Description
GET /api/dashboard Summary metrics: total pageviews, total sessions, average load time, error count. Used by the dashboard's top-level overview cards.
GET /api/pageviews Pageviews over time (grouped by day or hour) and top pages by view count. Powers the main traffic chart and "Top Pages" table.
GET /api/performance Web Vitals percentiles (p50, p75, p95) and load time distributions. Powers the performance panel with LCP, CLS, and INP breakdowns.
GET /api/errors Error frequency over time and top error messages. Powers the error tracking panel with trend lines and grouped error counts.
GET /api/sessions Session statistics: total sessions, average duration, average pages per session, bounce rate. Powers the audience overview.
POST /api/login Authenticate a user with email and password. Returns a session cookie on success.
POST /api/logout Destroy the current session. Clears the session cookie.

This is a small, focused API. Each analytics endpoint maps to one panel in the dashboard. The /api/dashboard endpoint is a convenience route that aggregates the summary numbers from several tables into a single response, so the dashboard can populate its overview cards with one request instead of four.

Why not a single /api/analytics endpoint? Separate endpoints let the dashboard fetch only the data it needs. When a user navigates to the performance tab, the dashboard calls /api/performance without loading pageview or error data. This reduces response size, server load, and page latency.

3. Query Parameters

Every analytics endpoint accepts query parameters for date filtering, pagination, and grouping. These parameters are consistent across all GET endpoints so the dashboard can apply the same date picker and pagination controls everywhere.

Date Range Filtering

All analytics endpoints accept start and end parameters in YYYY-MM-DD format. If omitted, the API defaults to the last 7 days.

GET /api/pageviews?start=2026-02-01&end=2026-02-14

Pagination

Endpoints that return lists (top pages, error messages) accept page and limit parameters. The default is page 1 with 20 results per page.

GET /api/errors?page=2&limit=10

Grouping

Time-series endpoints accept a group parameter to control the granularity of the data points. Valid values are day and hour. The default is day.

GET /api/pageviews?start=2026-02-10&end=2026-02-11&group=hour

Example Request and Response

// Request
GET /api/pageviews?start=2026-02-01&end=2026-02-07&group=day&page=1&limit=5

// Response
{
  "success": true,
  "data": {
    "timeseries": [
      { "date": "2026-02-01", "views": 1243 },
      { "date": "2026-02-02", "views": 987 },
      { "date": "2026-02-03", "views": 1102 },
      { "date": "2026-02-04", "views": 1455 },
      { "date": "2026-02-05", "views": 1321 },
      { "date": "2026-02-06", "views": 1189 },
      { "date": "2026-02-07", "views": 1067 }
    ],
    "top_pages": [
      { "url": "/", "views": 2847 },
      { "url": "/about", "views": 1203 },
      { "url": "/blog", "views": 891 },
      { "url": "/contact", "views": 654 },
      { "url": "/pricing", "views": 512 }
    ]
  },
  "meta": {
    "total": 23,
    "page": 1,
    "limit": 5
  }
}
Always validate query parameters on the server. A malicious client can send limit=999999 or start=1970-01-01. Cap limit at a reasonable maximum (100), validate date formats, and reject ranges longer than your retention period. Never pass user input directly into SQL.

4. Response Format

Every API response uses a consistent JSON envelope. The dashboard can rely on the same structure regardless of which endpoint it calls. This makes error handling predictable and simplifies the front-end code.

Success Response

{
  "success": true,
  "data": { ... },
  "meta": {
    "total": 142,
    "page": 1,
    "limit": 20
  }
}

The data field contains the actual payload — its shape varies by endpoint. The meta field contains pagination information. For endpoints that do not paginate (like /api/dashboard), meta can be omitted or empty.

Error Response

{
  "success": false,
  "error": "Invalid date range: start must be before end"
}

HTTP Status Codes

The API returns proper HTTP status codes alongside the JSON body. The front-end should check the status code first, then parse the body.

Status Meaning When
200 OK Request succeeded, data returned
400 Bad Request Invalid query parameters (bad date format, negative page number)
401 Unauthorized No session cookie or session expired
403 Forbidden Authenticated but insufficient role (viewer trying to manage users)
429 Too Many Requests Rate limit exceeded
500 Internal Server Error Database error, unhandled exception
Why a success field when we have status codes? Because front-end developers sometimes forget to check response.ok and go straight to parsing JSON. The success boolean in the body provides a second safety net. It also makes the response self-describing when logged or inspected in browser DevTools.

5. Authentication: Session-Based

The reporting API uses session-based authentication. The user logs in with email and password, the server validates the credentials, creates a server-side session, and sends back a cookie. Every subsequent request includes the cookie automatically — no token management needed on the client.

Login Flow

  1. The dashboard sends a POST /api/login request with email and password in the request body.
  2. The server looks up the user by email in the users table.
  3. The server compares the submitted password against the stored hash using bcrypt.compare() (Node.js) or password_verify() (PHP).
  4. If the credentials are valid, the server creates an HTTP session and stores the user's ID and role in the session data.
  5. The server responds with Set-Cookie: session_id=abc123; HttpOnly; Secure; SameSite=Strict.
  6. All subsequent requests from the browser automatically include this cookie. The server reads the session data to identify the user.
Browser API Server Database │ │ │ │── POST /api/login ───────────────────────>│ │ │ { email, password } │ │ │ │── SELECT * FROM users │ │ │ WHERE email = ? ──────────────>│ │ │<── { id, password_hash, role } ──│ │ │ │ │ │ bcrypt.compare(password, hash) │ │ │ ✓ Match! │ │ │ │ │ │ Create session: │ │ │ session[abc123] = { │ │ │ userId: 1, │ │ │ role: 'admin' │ │ │ } │ │ │ │ │<── 200 OK ───────────────────────────────│ │ │ Set-Cookie: session_id=abc123; │ │ │ HttpOnly; Secure; SameSite=Strict │ │ │ │ │ │── GET /api/pageviews ───────────────────>│ │ │ Cookie: session_id=abc123 │ │ │ │ Look up session abc123 │ │ │ Found: userId=1, role=admin │ │ │ ✓ Authorized │ │ │ │ │ │── SELECT ... FROM pageviews ────>│ │ │<── [rows] ──────────────────────│ │<── 200 OK { success: true, data: ... } ──│ │ │ │ │

Cookie Flags

Flag Purpose
HttpOnly Prevents JavaScript from reading the cookie via document.cookie. Mitigates XSS attacks that try to steal session IDs.
Secure Cookie is only sent over HTTPS. Prevents session hijacking on insecure networks.
SameSite=Strict Cookie is not sent on cross-site requests. Prevents CSRF attacks where a malicious site tricks the browser into making authenticated requests to your API.
Never store plaintext passwords. Use bcrypt (Node.js) or password_hash() with PASSWORD_BCRYPT (PHP). These functions produce a one-way hash that cannot be reversed. The compare/verify functions handle salt extraction and timing-safe comparison automatically.

6. User Roles

Not every dashboard user should have the same access. The users table includes a role column with three possible values, each granting a different level of access.

Role View Analytics Manage Users System Settings
viewer Yes No No
admin Yes Yes No
owner Yes Yes Yes

Role Checking in Middleware

Role enforcement happens in middleware that runs before every route handler. The middleware reads the session, extracts the user's role, and compares it against the required role for the requested endpoint. If the role is insufficient, the middleware returns a 403 Forbidden response before the route handler ever executes.

// Middleware: require authentication
function requireAuth(req, res, next) {
    if (!req.session || !req.session.userId) {
        return res.status(401).json({
            success: false,
            error: 'Authentication required'
        });
    }
    next();
}

// Middleware: require a minimum role level
function requireRole(minRole) {
    const hierarchy = { viewer: 1, admin: 2, owner: 3 };

    return (req, res, next) => {
        const userRole = req.session.role;
        if (hierarchy[userRole] < hierarchy[minRole]) {
            return res.status(403).json({
                success: false,
                error: 'Insufficient permissions'
            });
        }
        next();
    };
}

// Usage in routes:
app.get('/api/pageviews', requireAuth, getPageviews);
app.post('/api/users',    requireAuth, requireRole('admin'), createUser);
app.delete('/api/users/:id', requireAuth, requireRole('admin'), deleteUser);
The role hierarchy is intentionally simple. Three roles cover the needs of a class project. Production analytics tools often add more granular permissions (per-dashboard access, per-site filtering, read-only API keys), but the core pattern is the same: check the role in middleware before the route handler runs.

7. Rate Limiting

Rate limiting prevents a single client from overwhelming the API with too many requests. Without it, a bug in the dashboard (an infinite loop calling fetch) or a malicious script can saturate the server and degrade service for everyone.

Simple In-Memory Rate Limiter

The simplest approach: maintain a Map keyed by client IP address, tracking the number of requests made in the current minute. When the count exceeds the limit, return 429 Too Many Requests.

// Simple in-memory rate limiter
const rateLimitMap = new Map();
const WINDOW_MS  = 60 * 1000;  // 1 minute
const MAX_REQUESTS = 60;        // 60 requests per minute per IP

function rateLimit(req, res, next) {
    const ip = req.ip;
    const now = Date.now();

    if (!rateLimitMap.has(ip)) {
        rateLimitMap.set(ip, { count: 1, windowStart: now });
        return next();
    }

    const entry = rateLimitMap.get(ip);

    // Reset window if expired
    if (now - entry.windowStart > WINDOW_MS) {
        entry.count = 1;
        entry.windowStart = now;
        return next();
    }

    entry.count++;

    if (entry.count > MAX_REQUESTS) {
        return res.status(429).json({
            success: false,
            error: 'Too many requests. Try again in a minute.'
        });
    }

    next();
}

// Apply to all API routes
app.use('/api', rateLimit);

Response Headers

Well-behaved rate limiters include headers that tell the client how many requests remain and when the window resets:

HTTP/1.1 200 OK
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 42
X-RateLimit-Reset: 1740000060
In production, use Redis or a library. The in-memory approach works for a single-server class project, but it does not survive server restarts, does not share state across multiple server instances, and leaks memory if you do not clean up expired entries. Libraries like express-rate-limit (Node.js) or throttle middleware (PHP) handle these edge cases. For multi-server deployments, store rate-limit counters in Redis so all instances share the same counts.

8. SPA vs Server-Rendered Dashboard

The API-first approach dictates that the dashboard is a single-page application (SPA). The server serves one HTML file (plus CSS and JavaScript), and all data comes from API calls. The server never renders HTML for dashboard pages.

How It Works

  1. The user navigates to https://example.com/dashboard.
  2. The server returns a single index.html file with a <script> tag.
  3. The JavaScript boots up, checks for an active session (calls GET /api/dashboard), and renders the UI.
  4. If the user is not logged in (401 response), the JS shows a login form.
  5. All navigation within the dashboard (switching between pageviews, performance, errors tabs) happens in JavaScript — no page reloads.
  6. Each tab switch triggers a fetch() call to the appropriate API endpoint and re-renders that section.
Browser Server │ │ │── GET /dashboard ──────────────────>│ │<── index.html + app.js ────────────│ (only static files) │ │ │ JS boots, checks auth: │ │── GET /api/dashboard ──────────────>│ │<── 200 { pageviews, sessions, ... } │ (JSON only) │ │ │ User clicks "Performance" tab: │ │── GET /api/performance ────────────>│ │<── 200 { lcp, cls, inp, ... } ─────│ (JSON only) │ │ │ User clicks "Errors" tab: │ │── GET /api/errors ─────────────────>│ │<── 200 { errors, trends, ... } ────│ (JSON only) │ │ │ No page reloads. No server- │ │ rendered HTML. API returns │ │ JSON only, never HTML. │

Why This Pattern

This is the pattern used by virtually every modern analytics tool. Google Analytics, Plausible, PostHog, Mixpanel — all of them serve a static SPA that consumes a JSON API. The dashboard you build in the next phase follows the same approach.

API-first does not mean API-only. The server still serves the static HTML, CSS, and JS files that make up the dashboard application. It also handles the /api/login and /api/logout endpoints. The key constraint is that the API never returns HTML — it only returns JSON. All rendering happens in the browser.

Tutorial Modules

01. Node.js API

Build the reporting API with Express.js: route handlers for each analytics endpoint, session-based authentication with bcrypt, role middleware, and rate limiting.

Start Module →

02. PHP API

Build the same reporting API in PHP: PDO database queries, native session handling, password_hash/password_verify authentication, and JSON response formatting.

Start Module →