# M11 — HTTP Endpoint Performance & Laravel Request Profiling ```text STATUS: COMPLETE (read-only production HTTP audit + local low-risk fixes) PRODUCTION: no cache flush, no restarts, no nginx/PHP/MySQL/FPM/Horizon changes WHEN: 2026-08-23 20:58–21:05 CEST ``` Guest curls from `server3` to `https://skinbase.org` (HTTP/2, `Accept-Encoding: br`). Two sequential requests per path. No concurrency. No cache clears. M10 snapshot indexes: **unchanged**. Duplicate-index DROP remains unrun; production does not have that index. --- ## 1. HTTP baseline table Times are TTFB seconds. Size is **compressed** body (brotli) unless noted. | Path | Status | 1st TTFB | 2nd TTFB | Size (br) | Cache-Control | Encoding | | ---- | ------ | -------- | -------- | --------- | ------------- | -------- | | `/` | 200 | 0.449 | 0.413 | ~22 KB | `public, max-age=60, s-maxage=300, swr=600` | br | | `/` uncompressed HTML | 200 | 0.444 | — | 286 KB | same | none (no AE) | | `/explore` | 200 | 0.504 | 0.490 | ~16 KB | `no-cache, private` | br | | `/discover/trending` | 200 | 0.449 | 0.434 | ~8.8 KB | `no-cache, private` | br | | `/uploads/latest` | 200 | 0.573 | 0.594 | ~11.5 KB | `no-cache, private` | br | | `/academy` | 200 | **1.283** | 0.838 | ~24 KB | `no-cache, private` | br | | `/academy/prompts` | 200 | **1.316** | **1.171** | ~29 KB | `no-cache, private` | br | | `/academy/prompts/popular` | 200 | 0.835 | — | — | private | br | | `/forum` | 200 | 0.512 | 0.525 | ~15 KB | `no-cache, private` | br | | `/collections` | **404** | 0.682 | 0.440 | ~8.7 KB | private | br | | `/collections/featured` | 200 | **1.037** | — | — | private | br | | `/rss/latest-uploads.xml` | 200 | 0.349 | 0.350 | ~13 KB | `no-cache, private` | br | | `/sitemap.xml` | 200 | 0.125 | 0.064 | 377 B | `public, max-age=21600` | br | | `/search?q=skin` | 200 | 0.467 | 0.520 | ~15 KB | private | br | | `/statistics` | **302 → /login** | 0.309 | 0.254 | 709 B | private | — | | `/leaderboard` | 200 | 0.439 | 0.435 | ~14 KB | private | br | | `/staff` | 200 | 0.382 | 0.413 | ~8 KB | private | br | | `/@killua` (profile) | 200 | **0.975** | **0.968** | 249 KB **uncompressed** | private | — | | `/favicon.ico` | 200 | 0.063 | — | — | static | — | No `Server-Timing` header **before** this milestone. Added locally (`AddServerTiming`). --- ## 2. Warm vs second request | Path | Δ | Interpretation | | ---- | - | -------------- | | `/` | −36 ms | Guest homepage `Cache::flexible` working | | `/sitemap.xml` | −61 ms | Public cache / file cache | | `/academy` | −445 ms | Application cache (Academy home payload) + SSR warm | | `/academy/prompts` | −145 ms | Still >1 s — listing + S3 `exists` + SSR | | Most others | ~0 | Cache already warm; remaining cost is PHP + SSR | Did **not** flush Redis to manufacture cold tests. --- ## 3. Access-log latency analysis `/etc/nginx/nginx.conf` uses **default** `access_log` (combined). **No custom `log_format`.** Missing: `request_time`, `upstream_response_time`, `upstream_connect_time`. Log file `/var/log/nginx/skinbase.org-access.log` is `www-data:adm` — app user **cannot read** it. **No p95/p99 from access logs.** Do not change nginx during M11. --- ## 4–5. Laravel logs / 5xx `storage/logs/laravel.log` ~3.5 GB. **CURRENT (hours around audit):** download warnings for missing originals (not 5xx). No Redis `LOADING`. No Predis memory exhaustion. **RECENT (same day):** CLI errors (`nova:metrics-prune` not defined, tinker parse, Enhance worker unavailable). Not HTTP user-facing. **HISTORICAL:** Redis LOADING / presence SMEMBERS / Predis OOM — M7/M8, not recurring in this window. HTTP 5xx count from nginx: **unknown** (log unread + no timing format). --- ## 6. Middleware cost Web append: `AddServerTiming` (new), `SecurityHeaders`, `RedirectLegacyProfileSubdomain`, `TrackOnlineVisitor`, `UpdateLastVisit`, `HandleInertiaRequests`, onboarding/email-upgrade. | Middleware | Per request | Verdict | | ---------- | ----------- | ------- | | TrackOnlineVisitor | Redis GET + SETEX + SADD after response; bots included; RSS/sitemaps **now skipped** | LOW after skip | | UpdateLastVisit | DB update throttled 300s, auth only | NO CHANGE | | HandleInertiaRequests | auth user + **was** full `studioOptionsForUser` on every auth Inertia page | **HIGH for auth** — gated to `/studio` | | Session | skipped for some paths via ConditionalStartSession | NO CHANGE | --- ## 7. Inertia shared props `app/Http/Middleware/HandleInertiaRequests.php::share` | Prop | Was | Now | | ---- | --- | --- | | `auth.user` | profile lazy-load | `load('profile')` once | | `flash` | closures | same | | `cdn` / `features` | config | same | | `studio_groups` | **eager Group::with(owner.profile, members) + whereHas for every logged-in Inertia page** | **only when path starts with `studio`** | `studio_groups` is consumed only by `resources/js/Layouts/StudioLayout.jsx`. --- ## 8. SSR audit - Enabled: `config/inertia.php` `ssr.enabled=true`, `http://127.0.0.1:13714` - Process: `node …/bootstrap/ssr/ssr.js` (www-data) - Used for Inertia HTML first visits (Academy, forum, leaderboard, collections, profile) - Home `/` is **Blade**, not SSR - No SSR duration in logs before Server-Timing - Do **not** disable SSR globally - Academy/collections ~1 s likely **SSR + PHP**; second academy hit faster (cache) --- ## 9. Controller mapping (slowest) | Path | Controller | | ---- | ---------- | | `/` | `App\Http\Controllers\Web\HomeController::index` → `HomepageService::all` / `allForUser` | | `/explore` | `ExploreController::index` (Meilisearch) | | `/discover/trending` | `DiscoverController::trending` | | `/academy` | `Academy\AcademyHomeController::index` | | `/academy/prompts` | `Academy\AcademyPromptController::index` | | `/leaderboard` | `LeaderboardPageController` → cached `LeaderboardService::getLeaderboard` | | `/collections/featured` | `CollectionDiscoveryController::featured` (6 discovery queries + SSR) | | `/search` | `SearchController::index` → Meilisearch + groups + news LIKE | | `/@user` | `User\ProfileController::showByUsername` | | `/download/artwork/{id}` | `ArtworkDownloadController` | | RSS | `RssFeedController::latestUploads` | --- ## 10–11. Query counts / M10 overlap Local Pest: leaderboard as auth user **0** `groups` / `group_members` queries after the shared-prop gate. Production EXPLAIN not repeated (M10). HTTP-facing: - Leaderboard HTTP uses **Cache::remember**, not the 9M snapshot GROUP BY - Heat job is scheduler, not HTTP - Browse `/explore` uses Meilisearch then hydrate — TTFB ~500 ms --- ## 12–13. Redis / presence Per tracked GET: `GET` record, `SETEX` 300s, `SADD` index. No `SMEMBERS` on the request path (M8 SSCAN prune remains). RSS/sitemaps/robots **were tracked**; now skipped. Bots still tracked on HTML (by design). --- ## 14. Payload Homepage uncompressed ~286 KB HTML (Inertia-like JSON in Blade). Brotli ~22 KB. Profile `/@killua` 249 KB uncompressed HTML — large Inertia SSR document. MEDIUM, no rewrite this milestone. Academy compressed 24–29 KB — fine; TTFB is CPU/S3/SSR not transfer. --- ## 15–21. Surface findings **Home:** guest flexible cache 5s/30s fresh + 1800s stale. TTFB ~410–450 ms. Personalized `allForUser` uncached (not measured; private). **Browse:** `/explore` ~500 ms; `no-cache, private` even for guests (CDN cannot cache). MEDIUM documentation only — personalization/maturity may require private. **Artwork detail:** not sampled with a canonical slug this pass (explore HTML is CDN image URLs). Code path `ArtworkController::show` / web artwork. **Search:** 467 ms including Meilisearch + optional news `LIKE` on content. Malformed query guard already in `SearchController`. **Statistics:** auth-gated 302. Rankings `/leaderboard` 439 ms from cache. **Profile:** ~970 ms both hits — SSR + heavy profile assembly. Documented, no rewrite. **Academy:** slowest public family. Causes: Inertia SSR + `Storage::exists` on S3 for prompt preview variants + `courseLessons()->count()` when `lessons_count_cache` is 0 (`?:` bug). Fixes: Redis-remember exists 1h; use cache column without extra count. **Forum:** 512 ms. Existing query-bound tests. Queues stay off. **RSS:** 350 ms, two queries (`Artwork::published()->with(user)->limit`). Redis not required for generation. Presence writes removed from RSS. **Download:** production currently logs missing originals **after** incrementing counters — **fixed** (404 first). --- ## 22. External / storage Academy list `Storage::disk(s3)->exists` per variant — **was synchronous S3 on HTTP**. Now cached 1h. Download `File::isFile` local originals. Enhance worker errors are CLI/job, not public pages. --- ## 23. Cache / stampede Homepage already `flexible` (stale-while-revalidate). Leaderboard `Cache::remember` — stampede possible but 439 ms and scheduled refresh exist. **No new caches** except Academy asset-exists. --- ## 24. Bots / deep pagination Access logs unreadable. Historical malformed search URLs remain historical. **No UA blocking.** --- ## 25. HTTP cache / compression / static - gzip + brotli **on** in nginx (confirmed `content-encoding: br` when AE sent) - Static `favicon.ico` 63 ms, not Laravel - `/build/*` access_log off in vhost - Guest `/` already public CDN-friendly; most Inertia HTML `private, no-cache` (auth/session/SSR) --- ## 26. TTFB classification | Endpoint | Dominant cost | | -------- | ------------- | | `/` | PHP bootstrap + cached payload + HTML serialize (~400 ms floor) | | `/explore`, `/discover/*` | PHP + Meilisearch + Blade/Inertia | | `/academy*` | **SSR + PHP + (was) S3 exists** | | `/collections/featured` | **SSR + multiple discovery SQL** | | `/@user` | **SSR + profile assembly** | | `/leaderboard` | PHP + Redis cache hit | | `/sitemap.xml` | static/cached XML | | `/rss/*` | PHP + small SQL | --- ## 27. Ranked findings | Severity | Item | | -------- | ---- | | HIGH | Auth Inertia `studio_groups` full group graph every page — **fixed** (studio path only) | | HIGH | Download counters/events before missing-file 404 — **fixed** | | HIGH | Academy S3 `exists` on listing/home cards — **fixed** (1h cache) | | MEDIUM | Academy / collections / profile ~1 s (SSR). Do not disable SSR | | MEDIUM | Guest explore/discover `Cache-Control: private` — CDN cannot cache | | MEDIUM | `/collections` 404; real routes `/collections/featured` etc. | | LOW | Presence on RSS — **fixed skip** | | LOW | `lessons_count_cache ?: count()` extra SQL — **fixed** | | NO CHANGE | FPM 14, Horizon, Redis size, snapshot indexes, forum queues, MySQL knobs | --- ## 28. Files changed - `app/Http/Middleware/HandleInertiaRequests.php` - `app/Http/Middleware/TrackOnlineVisitor.php` - `app/Http/Middleware/AddServerTiming.php` (new) - `bootstrap/app.php` - `app/Http/Controllers/ArtworkDownloadController.php` - `app/Services/Academy/AcademyAccessService.php` - `tests/Feature/Http/StudioGroupsSharedPropTest.php` - `tests/Feature/Http/ArtworkDownloadMissingFileTest.php` - `tests/Feature/Http/RssPresenceSkipTest.php` - `docs/optimization-m11-http-endpoint-performance.md` --- ## 29. Tests ```text pest StudioGroupsSharedPropTest PASS pest ArtworkDownloadMissingFileTest PASS pest RssPresenceSkipTest PASS ``` --- ## 30. Production deployment plan 1. Deploy application code only. 2. Do **not** run M10 DROP INDEX. 3. Do **not** restart Redis/MySQL/FPM/Horizon except normal PHP-FPM reload for PHP. 4. SSR process already running; no disable. --- ## 31. Post-deploy verification From `server3`, sequential: ```bash curl -sS -D - -o /dev/null --max-time 20 -H 'Accept-Encoding: br' https://skinbase.org/academy | grep -iE 'HTTP/|server-timing|cache-control' curl -sS -o /dev/null -w 'academy ttfb:%{time_starttransfer}\n' -H 'Accept-Encoding: br' https://skinbase.org/academy curl -sS -o /dev/null -w 'prompts ttfb:%{time_starttransfer}\n' -H 'Accept-Encoding: br' https://skinbase.org/academy/prompts curl -sS -o /dev/null -w 'home ttfb:%{time_starttransfer}\n' -H 'Accept-Encoding: br' https://skinbase.org/ ``` Expect `Server-Timing: app;desc="Laravel";dur=…`. Academy TTFB should move toward the warm ~0.8 s band more often (asset-exists cache). Guest home should stay ~400–450 ms. --- ## 32. Rollback Revert the listed PHP files. Presence skip and Server-Timing are additive. Download 404-before-increment is the correct semantics (do not roll back unless a download of a missing file must still increment stats — it should not). --- ## Measurement note (acceptance) Public guest pages already under ~500 ms except Academy (~1.3 s), collections featured (~1.0 s), profile (~0.97 s). | Path | BEFORE (prod guest) | AFTER (expected) | | ---- | ------------------- | ---------------- | | `/` | 0.45 / 0.41 s | same | | `/academy` | 1.28 / 0.84 s | fewer S3 round-trips; SSR remains | | `/academy/prompts` | 1.32 / 1.17 s | fewer S3 round-trips | | Auth non-studio Inertia | unmeasured (group graph every request) | **0 extra group SQL** (tested) | | Missing download | counters + 404 | **404 only** (tested) |