# Story 2.2: Error Logging & Troubleshooting Tools

## Story

**As a** system administrator,
**I want** to view application error logs and filter by severity from the admin UI,
**So that** I can diagnose failures without needing direct server access to log files.

## Status

done

## Acceptance Criteria

**AC1:** Given an authenticated admin navigates to `GET /admin/logs`, when the page loads, then a paginated table of log entries is displayed, ordered by most recent first, showing 50 entries per page. Each row shows: timestamp (ISO 8601 → display as `YYYY-MM-DD HH:MM:SS UTC`), level badge (`ERROR` / `WARNING` / `INFO`), message, and a truncated context summary (first ~80 chars of a JSON-stringified context). A filter control is visible offering: All Levels, ERROR only, WARNING only, INFO only.

**AC2:** Given an admin submits the filter, when the page reloads with `?level=error` (or `warning`, `info`, or absent for All), then only entries matching the selected level are displayed; the active filter is visually indicated in the filter control; the URL reflects the filter state so it can be bookmarked. Pagination preserves the filter (`?level=error&page=2`).

**AC3:** Given an admin clicks a log row, when the row expands inline (no page reload), then the full parsed context is displayed (request data, stack trace if present, relevant IDs such as import job ID or product SKU). No raw credentials or session tokens appear in the expanded view — values for keys matching `password`, `secret`, `token`, `api_key` (case-insensitive) are redacted at log-write time (see AC6).

**AC4:** Given `logs/app.log` contains more than 1,000 entries, when the admin views `/admin/logs`, then only the most recent 1,000 entries are available for browsing via pagination, and a note at the top indicates the actual on-disk total and the 1,000-entry display cap (e.g., "Showing the most recent 1,000 of 4,237 entries on disk.").

**AC5:** Given an admin clicks "Clear Log", when `POST /admin/logs/clear` is submitted with a valid CSRF token, then the active log file (`logs/app.log`) is **truncated** (not deleted — the file remains, contents emptied via `file_put_contents($path, '', LOCK_EX)`). A new INFO entry is written immediately after truncation: "Log cleared by admin [username] at [ISO timestamp]". The admin is redirected (PRG) to `/admin/logs` with flash message: "Log cleared." A CSRF mismatch returns 403 and re-renders the logs view with a human-readable error.

**AC6:** Given `src/Utils/Logger.php` is reviewed, when any service or controller calls `Logger::info|warning|error|debug()`, then the entry is written as a single JSON line to `logs/app.log` with fields: `timestamp` (ISO 8601 / `DateTimeInterface::ATOM`), `level`, `message`, `context` (associative array). `logs/error.log` receives only ERROR-level entries (separate write). **Context values are redacted before write** for any key matching the patterns `/password/i`, `/secret/i`, `/token/i`, `/api[_-]?key/i` — the value is replaced with the string `[REDACTED]`. Redaction applies recursively to nested arrays. The `error.log` file uses the same redacted entry as `app.log`.

**AC7:** Given a non-admin authenticated user attempts `GET /admin/logs` or `POST /admin/logs/clear`, when the auth check runs, then the user is redirected to `/?error=forbidden`. Given an unauthenticated user attempts those routes, they are redirected to `/login?redirect=...`.

**AC8:** Given the admin area now has two pages (Settings, Logs), when the user is on either, then a sub-navigation row above the page title shows both with the active page highlighted (`Settings | Logs`). The sidebar `Admin` nav item links to `/admin/settings` (unchanged); the sub-nav handles in-admin routing.

## Tasks / Subtasks

- [x] **Task 1: Add redaction to Logger** (AC: 3, 6)
  - [x] 1.1: In `src/Utils/Logger.php`, add `private const REDACTED_KEY_PATTERNS = ['/password/i', '/secret/i', '/token/i', '/api[_-]?key/i']` and a `private static function redactContext(array $context): array` that recurses into nested arrays, replacing matched-key values with the literal string `[REDACTED]`.
  - [x] 1.2: Apply redaction in `write()` and `error()` before calling `formatEntry()` — so both `app.log` and `error.log` see redacted output.
  - [x] 1.3: Keep the public API unchanged (callers don't need to change anything).

- [x] **Task 2: Create `LogReader` service** (AC: 1, 2, 4)
  - [x] 2.1: Create `src/Services/LogReader.php` with:
    - `public const DISPLAY_CAP = 1000;`
    - `public const PER_PAGE = 50;`
    - `public function read(?string $levelFilter, int $page): array` returning `['entries' => array[], 'total_on_disk' => int, 'total_after_filter' => int, 'page' => int, 'total_pages' => int, 'per_page' => int]`.
  - [x] 2.2: Read `PROJECT_ROOT . '/logs/app.log'` via `file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES)`. If the file does not exist, return zero entries (no error).
  - [x] 2.3: Take the last `DISPLAY_CAP` lines; reverse to newest-first; JSON-decode each (skip lines that fail to parse, but increment a counter — log to error log? no, the reader is read-only). Apply level filter if given. Paginate.
  - [x] 2.4: Validate `$levelFilter` against allowlist `['ERROR', 'WARNING', 'INFO', 'DEBUG']` (case-insensitive normalize to upper).

- [x] **Task 3: Extend `AdminController`** (AC: 1, 2, 4, 5, 7)
  - [x] 3.1: Add `LogReader` dependency to the constructor (third arg). Wire in `public/index.php`.
  - [x] 3.2: Add `public function viewLogs(): void` — parses `?level=` and `?page=` from `$_GET`, validates page is a positive int (default 1), validates level via allowlist (else treat as "All"), calls `LogReader::read()`, sets view locals (`$entries`, `$activeLevel`, `$page`, `$totalPages`, `$totalOnDisk`, `$totalAfterFilter`, `$flashSuccess`, `$flashError`), requires `src/Views/admin/logs.php`. Always sets `Cache-Control: no-store` (logs may contain sensitive trace data).
  - [x] 3.3: Add `public function clearLogs(): void` — CSRF validate (on failure: 403, set `$_SESSION['flash_error'] = 'Invalid form submission. Please try again.'`, redirect to `/admin/logs`); else truncate `logs/app.log` via `file_put_contents($path, '', LOCK_EX)`; then `Logger::info('Log cleared', ['user' => $_SESSION['user_name'] ?? null])` (the new line is the only line in the file post-truncation); set `$_SESSION['flash_success'] = 'Log cleared.'`; redirect to `/admin/logs`.

- [x] **Task 4: Create `logs.php` view + `_admin_tabs.php` partial** (AC: 1, 2, 3, 8)
  - [x] 4.1: Create `src/Views/admin/_admin_tabs.php` partial. Renders a `<ul class="nav nav-tabs mb-3">` with two tabs: Settings → `/admin/settings`, Logs → `/admin/logs`. Active class is set based on `$activeTab` (`'settings'` or `'logs'`).
  - [x] 4.2: Update `src/Views/admin/settings.php` to set `$activeTab = 'settings'` and `include` the tabs partial before the `<h1>`.
  - [x] 4.3: Create `src/Views/admin/logs.php` with the standard layout. Sets `$activePage = 'admin'`, `$activeTab = 'logs'`. Includes tabs partial. Renders:
    - Flash success/error banners (read once, then unset).
    - Total-count note (AC4): "Showing the most recent N of M entries on disk." Only show "of M" line when on-disk total > DISPLAY_CAP; else "Showing N entries.".
    - Filter form (`GET /admin/logs`) with a `<select name="level">` containing All / ERROR / WARNING / INFO, current selection preserved.
    - "Clear Log" `<form method="POST" action="/admin/logs/clear">` with CSRF token and a `confirm()` JS guard.
    - Table: timestamp, level (with `<span class="badge-status badge-status-...">` matching the badge palette), message, truncated context (first ~80 chars). Each row carries `data-context` JSON for expansion (also rendered as a hidden `<tr>` for no-JS users? — skip; the spec accepts the JS-only inline expansion).
    - Pagination controls preserve the level filter in their links.
  - [x] 4.4: Map levels to badges: `ERROR` → `badge-status-rejected` (red), `WARNING` → `badge-status-needs-review` (amber), `INFO` → `badge-status-auto-approved` (muted gray), `DEBUG` → `badge-status-auto-approved`.

- [x] **Task 5: Inline expand JS** (AC: 3)
  - [x] 5.1: Create `public/assets/js/admin-logs.js` — vanilla JS. On click of any `<tr class="log-row">`, toggle a detail row beneath it that shows `<pre>` of the pretty-printed JSON context (from `data-context` attribute). Use `JSON.parse` + `JSON.stringify(.., null, 2)`. Properly HTML-escape via textContent (not innerHTML). Toggle adds/removes the detail row from the DOM.

- [x] **Task 6: Wire routes** (AC: 1, 5, 7)
  - [x] 6.1: Update `public/index.php` — instantiate `LogReader`, pass to `AdminController` constructor.
  - [x] 6.2: Add `GET /admin/logs` → `AuthMiddleware::requireRole('admin')` + `$admin->viewLogs()`.
  - [x] 6.3: Add `POST /admin/logs/clear` → `AuthMiddleware::requireRole('admin')` + `$admin->clearLogs()`.

- [x] **Task 7: Tests** (AC: 1, 2, 4, 5, 6)
  - [x] 7.1: Extend `tests/Unit/LoggerTest.php` (already exists) with redaction tests:
    - Keys matching `password`/`secret`/`token`/`api_key` → value becomes `[REDACTED]`.
    - Nested arrays redacted recursively.
    - Non-matching keys pass through unchanged.
    - Mixed case keys (`Password`, `API_KEY`) redacted.
  - [x] 7.2: Create `tests/Unit/LogReaderTest.php`. Use a tmp file path via `Logger::setLogPaths()` to redirect output during the test; write known entries; verify pagination, filtering, and the 1000-cap behavior. Clean up the tmp file in `tearDown()`.
  - [x] 7.3: Run full suite — expect 38 prior + new tests, all green.

- [x] **Task 8: Manual validation against all ACs**
  - [x] 8.1: Log in as admin; navigate to `/admin/logs`; verify table renders with the latest entries (those generated by login itself).
  - [x] 8.2: Trigger ERROR / WARNING / INFO via the existing pages; refresh; verify filter dropdown narrows results.
  - [x] 8.3: Click a row; verify inline expansion shows pretty-printed JSON without any credential values.
  - [x] 8.4: Click "Clear Log"; confirm; verify redirect to `/admin/logs` with "Log cleared." banner and only the new "Log cleared" entry visible.
  - [x] 8.5: As a non-admin user, attempt `/admin/logs`; verify redirect to `/?error=forbidden`.

## Dev Notes

### Logger redaction — the most important detail

AC6 says: *"no entry ever contains values from system_settings for credential keys or PHP session tokens"*. The mitigation lives in `Logger`, not in callers — callers should not be trusted to pre-redact. Apply redaction in `Logger::write()` and `Logger::error()` (both code paths) so every entry written from anywhere goes through the same filter. Reasoning:

- `Story 2.1` already writes `Logger::info('System settings updated', ['user_id' => ..., 'user_name' => ...])` — safe, but a future careless caller could pass `['settings' => $_POST]` and leak Akeneo credentials. The Logger filter is the guardrail.
- The filter is **key-based** (case-insensitive regex on the array key), not value-based. This avoids false positives (a log message containing the word "password" in plain text remains visible).
- Session token redaction: PHP session IDs live in `$_COOKIE[session_name()]`. The Logger never inspects cookies directly; the requirement is that callers don't pass session IDs into context. The redaction patterns catch `*_token` keys (e.g., `csrf_token`, `access_token`) but don't auto-scan cookies. Note this in the completion notes.

### Log file growth and the 1000-entry cap

The cap is a **display cap**, not a rotation policy. The file keeps growing forever until cleared. Log rotation belongs in ops (logrotate) or a separate story.

Reading approach: `file()` loads the whole file into memory. For an internal admin tool with logrotate active, the file will rarely exceed a few MB. If it ever does, switching to a reverse-streaming reader (`SplFileObject` + seek-to-end) is a follow-up. Note this in deferred items.

### Log entry parse failures

If a line in `app.log` isn't valid JSON (corrupted write, partial line from a crash), skip it silently. Do not log the failure (would cause infinite loop). Increment a counter only if you want to surface it in the UI — for this story, just skip.

### Why `?level=` in query string, not POST

GET-with-query-string makes the filter bookmarkable and back-button friendly (AC2). The select element's form must use `method="GET"` and the level dropdown should auto-submit on change (small JS or a "Filter" button — pick the button for simplicity, no JS needed).

### Why not move log clearing into a "settings"-style POST→Redirect→GET via flash?

We are. `clearLogs()` follows the same PRG pattern established in Story 2.1 (`$_SESSION['flash_success']`, redirect to `/admin/logs`). Consistency.

### Critical: AJAX-vs-HTML auth

Story 2.2 has **no AJAX endpoints**. All log routes are HTML (GET) or HTML-form POST (clear). `AuthMiddleware::requireRole('admin')` is fine as-is — no inline JSON auth check needed.

### Existing files being modified (read these first)

- **`src/Utils/Logger.php`** — currently has `info/warning/error/debug` static methods and JSON-line `formatEntry()`. Add the redaction layer between context arrival and `formatEntry`. Do not change the public API.
- **`src/Views/admin/settings.php`** — gains a `$activeTab = 'settings'` and includes the tabs partial. Otherwise unchanged.
- **`src/Controllers/AdminController.php`** — constructor signature changes (3rd arg `LogReader`). The new methods follow the same pattern as `showSettings`/`saveSettings`.
- **`public/index.php`** — adds 2 routes + LogReader instantiation.

### Previous Story Intelligence (2.1 — Admin Configuration Dashboard, just completed)

- **Flash pattern:** `$_SESSION['flash_success']` set, redirect, read-once-then-unset in the rendering controller method. Reuse — do not invent a new pattern.
- **Cache-Control on admin pages:** Story 2.1 added `Cache-Control: no-store` in `renderSettings()`. Apply the same to `viewLogs()` — log content can include trace data, IDs, IPs. Worth keeping out of caches.
- **AdminController dependencies are manual DI:** `new AdminController($settings, $akeneo)` from `public/index.php`. Continue this pattern (no DI container).
- **Tabs partial (new):** Introduce `_admin_tabs.php` so future admin pages (Health, Backups, etc.) can drop in.
- **Carry-forward deferred items from 2.1 review (W16–W23):** plaintext secret storage, no rate limiting, inline JSON auth, CSRF rotation, no `created_at`, no key whitelist in SystemSettingsService, no AbortController on JS, tight curl timeout. **None apply to this story.**

### File List (anticipated)

**New files:**
- `src/Services/LogReader.php`
- `src/Views/admin/logs.php`
- `src/Views/admin/_admin_tabs.php`
- `public/assets/js/admin-logs.js`
- `tests/Unit/LogReaderTest.php`

**Modified files:**
- `src/Utils/Logger.php` (redaction)
- `src/Controllers/AdminController.php` (constructor + 2 new methods)
- `src/Views/admin/settings.php` (tabs include)
- `public/index.php` (routes + LogReader wiring)
- `tests/Unit/LoggerTest.php` (redaction tests)

### Testing Standards

- PHPUnit at `vendor/bin/phpunit`. `LoggerTest` already exists; extend it. `LogReaderTest` uses `Logger::setLogPaths()` to point at a tmp file under `sys_get_temp_dir()`.
- No DB needed for Logger / LogReader tests — file-based only.

### Out of scope for this story

- Log rotation policy (operationally handled or future story).
- Search by message text (filter-by-level only per AC).
- Date-range filter.
- Export to CSV.
- Real-time log tail / streaming.
- Persisting clear-log audits to a separate audit table (the Logger entry "Log cleared by..." is the audit trail).

## Dev Agent Record

### Agent Model Used

claude-sonnet-4-6

### Debug Log References

### Completion Notes List

All 8 acceptance criteria satisfied and verified via live HTTP smoke testing against the built-in PHP server.

- **AC1:** `GET /admin/logs` returns 200 with table headers (Timestamp / Level / Message / Context), 50/page pagination, level filter dropdown with All Levels / ERROR / WARNING / INFO options. Newest-first ordering via `array_reverse`.
- **AC2:** `?level=error` (and `warning`, `info`) filters correctly — only matching entries displayed, dropdown `selected` attribute set on the active option, URL preserved as bookmarkable. Pagination links carry the filter via `linkWithQuery()` helper in the view.
- **AC3:** Click any row → vanilla-JS toggle inserts a detail row below with `<pre>` of `JSON.stringify(parsed, null, 2)`. Uses `textContent` (not `innerHTML`) so embedded HTML in context values can't break out. Sensitive keys redacted at write-time (AC6) so they never reach the data-context attribute in the first place.
- **AC4:** When `total_on_disk > 1000`, banner reads "Showing the most recent 1,000 of M entries on disk." Verified pagination math via `LogReaderTest::testDisplayCapTakesMostRecent` and `testPagination`.
- **AC5:** `POST /admin/logs/clear` truncates the file via `file_put_contents($path, '', LOCK_EX)` (file remains, contents empty), immediately writes `Logger::info('Log cleared', ['user' => $username])`, redirects to `/admin/logs` with `flash_success = 'Log cleared.'`. Verified live: log file post-clear contained exactly one line — the "Log cleared" entry. CSRF mismatch → 403 + `flash_error` + redirect.
- **AC6:** Logger gained `REDACTED_KEY_PATTERNS = ['/password/i', '/secret/i', '/token/i', '/api[_-]?key/i', '/authorization/i']` and recursive `redactContext()` applied in BOTH `write()` and `error()` paths. Verified via 6 new unit tests covering: single keys, multiple patterns, case-insensitivity, recursion into nested arrays, error-log path, and non-sensitive keys passing through unchanged. The `Authorization` pattern was added after the recursion test caught it missing (good signal for test quality).
- **AC7:** Both `/admin/logs` routes use `AuthMiddleware::requireRole('admin')` — inherits the same enforcement Story 1.2 + Story 2.1 already verified live.
- **AC8:** New `_admin_tabs.php` partial included by both `settings.php` and `logs.php`. Bootstrap nav-tabs with `active` class driven by `$activeTab` local. Sidebar Admin link remains pointing to `/admin/settings` per Story 2.1.

Tests: 53 total, 108 assertions, all passing (38 prior + 15 new — `LoggerTest` +6 redaction tests, `LogReaderTest` 9 tests).

Deferred (none new in this story — Story 2.1's W16–W23 still apply where relevant; log rotation belongs to ops/logrotate or a future story).

### File List

```
src/Utils/Logger.php                              (modified — redaction in write() and error())
src/Services/LogReader.php                        (new)
src/Controllers/AdminController.php               (modified — LogReader dep + viewLogs() + clearLogs())
src/Views/admin/_admin_tabs.php                   (new — Settings/Logs tab partial)
src/Views/admin/logs.php                          (new)
src/Views/admin/settings.php                      (modified — include tabs partial)
public/assets/js/admin-logs.js                    (new)
public/index.php                                  (modified — LogReader wiring + 2 routes)
tests/Unit/LoggerTest.php                         (modified — 6 redaction tests added)
tests/Unit/LogReaderTest.php                      (new — 9 tests)
```

### Review Findings

- [x] [Review][Patch] `LogReader::read()` loads entire log into memory via `file()` — OOM on multi-MB logs; add filesize cap with guidance entry [src/Services/LogReader.php] — fixed via 50 MB `MAX_LOAD_BYTES` constant; over cap returns a single WARNING-level guidance entry with size info instead of OOMing; locked in via `testOversizedLogReturnsGuidanceEntryInsteadOfLoading`
- [x] [Review][Patch] `clearLogs()` ignores `@file_put_contents` failure; admin sees "Log cleared." even on disk-full/permission errors [src/Controllers/AdminController.php] — fixed; checks return value, on failure logs error + sets `flash_error = 'Could not clear the log file. Check filesystem permissions.'`
- [x] [Review][Patch] `Logger::formatEntry()` json_encode lacks `JSON_INVALID_UTF8_SUBSTITUTE | JSON_PARTIAL_OUTPUT_ON_ERROR` — invalid UTF-8 in context silently drops the entry [src/Utils/Logger.php] — fixed; flags added plus last-resort fallback that preserves the entry with `_encode_error` context
- [x] [Review][Patch] `mb_substr` truncation in logs view uses implicit encoding [src/Views/admin/logs.php] — fixed; explicit `'UTF-8'` passed to both `mb_strlen` and `mb_substr`
- [x] [Review][Patch] Log row click handler has no keyboard support [public/assets/js/admin-logs.js] — fixed; rows now get `tabindex="0"`, `role="button"`, `aria-expanded` (toggled), and Enter/Space `keydown` handlers
- [x] [Review][Patch] Log row click delegation should guard against future interactive children [public/assets/js/admin-logs.js] — fixed; click handler uses `e.target.closest('a, button, input, select, textarea, label, [data-no-toggle]')` guard
- [x] [Review][Defer] Logger redaction is key-only — values containing secrets (URLs with `?token=...`, Bearer headers in strings) pass through — deferred, architectural; AC6 explicitly key-based; revisit if value-leak incidents occur
- [x] [Review][Defer] Full streaming `LogReader` rewrite for unbounded log sizes — deferred, mitigated by filesize cap; revisit if cap triggers in real use
- [x] [Review][Defer] `Logger::setLogPaths()` is a public mutator with no path validation — deferred, exists for test affordance; only reachable by already-compromised application code
- [x] [Review][Defer] No log rotation strategy — deferred, ops/deployment concern (use logrotate)
- [x] [Review][Defer] `htmlspecialchars` lacks explicit `'UTF-8'` charset across codebase — deferred, broader codebase-consistency cleanup
- [x] [Review][Defer] `LogReaderTest` uses `sys_get_temp_dir()` with fixed filename — deferred, low-priority test polish (no parallel PHPUnit today)

## Change Log

| Date | Change |
|------|--------|
| 2026-05-20 | Story created from epics.md; status set to ready-for-dev |
| 2026-05-20 | All 8 tasks complete; 53/53 tests passing; live HTTP smoke verified all 8 ACs; status set to review |
| 2026-05-20 | Code review complete; 6 patch findings, 6 deferred; status set to in-progress |
| 2026-05-20 | All 6 patches applied (LogReader filesize cap, clearLogs failure surfacing, JSON UTF-8 flags, mb_substr UTF-8, JS a11y, JS click guard); 54/54 tests passing; status set to done |
