diff --git a/.env.example b/.env.example index ac2b6623e6f..f59a0409f72 100644 --- a/.env.example +++ b/.env.example @@ -330,6 +330,32 @@ VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" # cache_event_logging) become visible in the admin settings panel. # ENABLE_REQUEST_CACHING=false +# Memory Profiler (Feature 053): when enabled, a global middleware bounds a +# memory-profiling span around every request (requires the `spx` PHP +# extension, bundled in the official Docker image — see +# docs/specs/2-how-to/enable-memory-profiler.md) and stores a metadata +# sidecar under storage/profiling. Traces can be browsed at /admin/profiler +# (owner-only), linking out to SPX's own analysis screen for the call-graph. +# Disabled by default; not recommended for continuous production use. +# MEMORY_PROFILER_ENABLED=false + +# Maximum number of trace pairs kept under storage/profiling; oldest are +# pruned automatically once this cap is exceeded. +# MEMORY_PROFILER_MAX_TRACES=200 + +# Secret key for the `spx` extension's own analysis screen (spx.http_key — +# see docker/scripts/06-configure-profiler.sh). Required when +# MEMORY_PROFILER_ENABLED=true; generate a long random value, e.g.: +# openssl rand -hex 32 +# Do not use a guessable value — anyone who knows this key (and matches the +# IP allow-list below, if set) can open SPX's own trace browser directly, +# bypassing Lychee's owner-only gate. See docs/specs/6-decisions/ADR-0008-memory-profiler-octane-risk.md. +# MEMORY_PROFILER_SPX_KEY= + +# Comma-separated IP allow-list for the `spx` extension's analysis screen +# (spx.http_ip_whitelist). Strongly recommended alongside MEMORY_PROFILER_SPX_KEY. +# MEMORY_PROFILER_SPX_IP_WHITELIST= + ################################################################### # Payment integration (requires SE) # ################################################################### diff --git a/Dockerfile b/Dockerfile index 92ec091ab82..24780b716a5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -87,6 +87,7 @@ RUN apt-get update \ bash \ gosu \ ghostscript \ + zlib1g-dev \ # Update with respect to vulnerabilities detected with Trivy libgssapi-krb5-2 \ libssh2-1t64 \ @@ -105,6 +106,7 @@ RUN apt-get update \ intl \ imagick \ redis \ + spx \ && apt-get clean -qy \ && rm -rf /var/lib/apt/lists/* @@ -151,6 +153,7 @@ COPY docker/scripts/02-dump-env.sh /usr/local/bin/02-dump-env.sh COPY docker/scripts/03-db-check.sh /usr/local/bin/03-db-check.sh COPY docker/scripts/04-user-setup.sh /usr/local/bin/04-user-setup.sh COPY docker/scripts/05-permissions-check.sh /usr/local/bin/05-permissions-check.sh +COPY docker/scripts/06-configure-profiler.sh /usr/local/bin/06-configure-profiler.sh COPY docker/scripts/create-admin-user.sh /usr/local/bin/create-admin-user.sh COPY docker/scripts/entrypoint.sh /usr/local/bin/entrypoint.sh @@ -160,6 +163,7 @@ RUN chmod +x /usr/local/bin/00-conf-check.sh \ /usr/local/bin/03-db-check.sh \ /usr/local/bin/04-user-setup.sh \ /usr/local/bin/05-permissions-check.sh \ + /usr/local/bin/06-configure-profiler.sh \ /usr/local/bin/create-admin-user.sh \ /usr/local/bin/entrypoint.sh \ && mkdir -p /data /config \ diff --git a/app/Console/Commands/Profiling/PruneTraces.php b/app/Console/Commands/Profiling/PruneTraces.php new file mode 100644 index 00000000000..2fd0d6fb8bc --- /dev/null +++ b/app/Console/Commands/Profiling/PruneTraces.php @@ -0,0 +1,39 @@ +prune(); + $this->line(sprintf('Removed %d trace pair(s) from storage/profiling.', $removed)); + + return 0; + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 09f74ec2cb4..c2136e631d7 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -27,6 +27,7 @@ class Kernel extends ConsoleKernel protected function schedule(Schedule $schedule): void { $schedule->command('lychee:photos_added_notification')->weekly(); + $schedule->command('lychee:profiler:prune')->daily(); } /** diff --git a/app/Constants/FileSystem.php b/app/Constants/FileSystem.php index 333e8d88545..de457d57080 100644 --- a/app/Constants/FileSystem.php +++ b/app/Constants/FileSystem.php @@ -13,4 +13,5 @@ class FileSystem public const DIST = 'dist'; public const IMAGE_UPLOAD = 'image-upload'; public const IMAGE_JOBS = 'image-jobs'; + public const PROFILING = 'profiling'; } \ No newline at end of file diff --git a/app/DTO/Profiling/ProfilingTraceMeta.php b/app/DTO/Profiling/ProfilingTraceMeta.php new file mode 100644 index 00000000000..c5e6558a2d1 --- /dev/null +++ b/app/DTO/Profiling/ProfilingTraceMeta.php @@ -0,0 +1,70 @@ + $this->spx_report_key, + 'route_name' => $this->route_name, + 'method' => $this->method, + 'path' => $this->path, + 'status_code' => $this->status_code, + 'duration_ms' => $this->duration_ms, + 'peak_memory_bytes' => $this->peak_memory_bytes, + 'user_id' => $this->user_id, + 'created_at' => $this->created_at, + ]; + } + + /** + * @param array{spx_report_key?:?string,route_name?:?string,method?:string,path?:string,status_code?:int,duration_ms?:float,peak_memory_bytes?:int,user_id?:?int,created_at?:string} $data + */ + public static function fromJsonArray(array $data): self + { + return new self( + spx_report_key: $data['spx_report_key'] ?? null, + route_name: $data['route_name'] ?? null, + method: $data['method'] ?? '', + path: $data['path'] ?? '', + status_code: $data['status_code'] ?? 0, + duration_ms: $data['duration_ms'] ?? 0.0, + peak_memory_bytes: $data['peak_memory_bytes'] ?? 0, + user_id: $data['user_id'] ?? null, + created_at: $data['created_at'] ?? '', + ); + } +} diff --git a/app/Http/Controllers/Admin/ProfilerController.php b/app/Http/Controllers/Admin/ProfilerController.php new file mode 100644 index 00000000000..e8bf79638e9 --- /dev/null +++ b/app/Http/Controllers/Admin/ProfilerController.php @@ -0,0 +1,93 @@ +files()) + ->filter(static fn (string $file): bool => str_starts_with($file, self::SIDECAR_PREFIX) && str_ends_with($file, '.json')) + ->map(function (string $json_file) use ($disk, $spx_key): array { + /** @var array $decoded */ + $decoded = json_decode($disk->get($json_file), true); + $meta = ProfilingTraceMeta::fromJsonArray($decoded); + + return [ + 'meta' => $meta, + 'spx_url' => $meta->spx_report_key !== null && \is_string($spx_key) && $spx_key !== '' + ? $this->buildSpxAnalysisUrl($meta->spx_report_key, $spx_key) + : null, + ]; + }) + ->sortByDesc(static fn (array $trace): string => $trace['meta']->created_at) + ->values(); + + return view('admin.profiler.index', [ + 'traces' => $traces, + 'is_octane' => getenv('LARAVEL_OCTANE') !== false, + 'spx_key_configured' => \is_string($spx_key) && $spx_key !== '', + ]); + } + + /** + * Manually trigger pruning (FR-053-07), invoked from the admin page's + * "Prune old traces" button. Shares {@see TracePruner} with the + * scheduled/console command (CLI-053-01). + */ + public function prune(TracePruner $pruner): RedirectResponse + { + $pruner->prune(); + + return redirect()->route('admin.profiler.index'); + } + + /** + * Builds the URL for SPX's own analysis screen for a given report key, + * per SPX's documented pattern: `/?SPX_UI_URI=/report.html&key=`. + * `SPX_KEY` must additionally match the extension's own `spx.http_key` + * ini value for SPX to honour the request at all (see ADR-0008). + */ + private function buildSpxAnalysisUrl(string $spx_report_key, string $spx_key): string + { + return url('/') . '?' . http_build_query([ + 'SPX_UI_URI' => '/report.html', + 'SPX_KEY' => $spx_key, + 'key' => $spx_report_key, + ]); + } +} diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 18a7df37b97..9d9ae8e5f93 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -20,6 +20,7 @@ class Kernel extends HttpKernel * @var array */ protected $middleware = [ + \App\Http\Middleware\MemoryProfiler::class, \App\Http\Middleware\FixStatusCode::class, \Illuminate\Http\Middleware\TrustProxies::class, \Illuminate\Foundation\Http\Middleware\PreventRequestsDuringMaintenance::class, @@ -111,5 +112,6 @@ class Kernel extends HttpKernel 'legacy_id_redirect' => \App\Http\Middleware\LegacyLocalIdRedirect::class, 'feature' => \App\Http\Middleware\FeatureEnabled::class, 'rss_feed_meta' => \App\Http\Middleware\SetRssFeedMeta::class, + 'owner' => \App\Http\Middleware\OwnerOnly::class, ]; } diff --git a/app/Http/Middleware/MemoryProfiler.php b/app/Http/Middleware/MemoryProfiler.php new file mode 100644 index 00000000000..e34d19e3b6a --- /dev/null +++ b/app/Http/Middleware/MemoryProfiler.php @@ -0,0 +1,99 @@ +recorder->isAvailable()) { + return $next($request); + } + + $request->attributes->set(self::ATTR_START_TIME, microtime(true)); + $this->recorder->start(); + + return $next($request); + } + + public function terminate(Request $request, Response $response): void + { + $start_time = $request->attributes->get(self::ATTR_START_TIME); + if (!\is_float($start_time)) { + return; + } + + $spx_report_key = $this->recorder->stop(); + + try { + $disk = Storage::disk(FileSystem::PROFILING); + $basename = 'lychee-' . $this->generateBasename(); + + $meta = new ProfilingTraceMeta( + spx_report_key: $spx_report_key, + route_name: $request->route()?->getName(), + method: $request->getMethod(), + path: $request->path(), + status_code: $response->getStatusCode(), + duration_ms: (microtime(true) - $start_time) * 1000, + peak_memory_bytes: memory_get_peak_usage(true), + user_id: Auth::id(), + created_at: now()->toIso8601String(), + ); + $disk->put($basename . '.json', json_encode($meta->toJsonArray(), \JSON_PRETTY_PRINT)); + } catch (\Throwable $e) { + Log::error('memory_profiler.dump_failed', [ + 'route' => $request->route()?->getName(), + 'exception_message' => $e->getMessage(), + ]); + } + } + + private function generateBasename(): string + { + return now()->format('Ymd_His') . '_' . Str::random(8); + } +} diff --git a/app/Http/Middleware/OwnerOnly.php b/app/Http/Middleware/OwnerOnly.php new file mode 100644 index 00000000000..6477b079737 --- /dev/null +++ b/app/Http/Middleware/OwnerOnly.php @@ -0,0 +1,36 @@ +config_manager->getValueAsInt('owner_id')) { + throw new UnauthorizedException('Only the owner can do this.'); + } + + return $next($request); + } +} diff --git a/app/Services/Profiling/SpxRecorder.php b/app/Services/Profiling/SpxRecorder.php new file mode 100644 index 00000000000..f870342e27c --- /dev/null +++ b/app/Services/Profiling/SpxRecorder.php @@ -0,0 +1,55 @@ +isAvailable()) { + \spx_profiler_start(); + } + } + + /** + * @return string|null the SPX report key (used to build the analysis-screen URL), or null if unavailable/not captured + */ + public function stop(): ?string + { + if (!$this->isAvailable()) { + return null; + } + + /** @var string|null $key */ + $key = \spx_profiler_stop(); + + return $key !== '' ? $key : null; + } +} diff --git a/app/Services/Profiling/TracePruner.php b/app/Services/Profiling/TracePruner.php new file mode 100644 index 00000000000..f985c7ef139 --- /dev/null +++ b/app/Services/Profiling/TracePruner.php @@ -0,0 +1,84 @@ +files()) + ->filter(static fn (string $file): bool => str_starts_with($file, self::SIDECAR_PREFIX) && str_ends_with($file, '.json')) + ->values(); + + if ($sidecars->count() <= $max_traces) { + return 0; + } + + $sorted = $sidecars->sortByDesc(function (string $sidecar) use ($disk): string { + /** @var array $meta */ + $meta = json_decode($disk->get($sidecar), true); + + return (string) ($meta['created_at'] ?? ''); + })->values(); + + $to_remove = $sorted->slice($max_traces); + + foreach ($to_remove as $sidecar) { + /** @var array $meta */ + $meta = json_decode($disk->get($sidecar), true); + $spx_report_key = $meta['spx_report_key'] ?? null; + + $disk->delete($sidecar); + + if (\is_string($spx_report_key) && $spx_report_key !== '') { + foreach (['.json', '.txt.gz'] as $extension) { + $spx_file = $spx_report_key . $extension; + if ($disk->exists($spx_file)) { + $disk->delete($spx_file); + } + } + } + } + + $removed_count = $to_remove->count(); + + Log::info('memory_profiler.pruned', [ + 'removed_count' => $removed_count, + 'remaining_count' => $max_traces, + ]); + + return $removed_count; + } +} diff --git a/config/features.php b/config/features.php index 213821c0a2e..b2eee3f5571 100644 --- a/config/features.php +++ b/config/features.php @@ -287,4 +287,52 @@ | v8 tree is being built out. */ 'nuxt_ui' => (bool) env('NUXT_UI_ENABLED', false), + + /* + |-------------------------------------------------------------------------- + | Enable Memory Profiler + |-------------------------------------------------------------------------- + | + | When enabled, a global middleware bounds a memory-profiling span (via + | the `spx` PECL extension's spx_profiler_start()/spx_profiler_stop(), if + | loaded) around every request and stores a metadata sidecar under + | storage/profiling. Traces can be browsed at /admin/profiler + | (owner-only), linking out to SPX's own analysis screen for the actual + | call-graph. See Feature 053 and + | docs/specs/2-how-to/enable-memory-profiler.md. Disabled by default; + | intended for debugging, not for continuous production use. + | + | Note: the `spx` extension's own ini settings (spx.http_profiling_*, + | spx.http_key, ...) are PHP_INI_SYSTEM — they cannot be toggled from + | here at request time. This flag only gates the Laravel-side middleware + | and admin routes; the extension's own ini config is written by + | docker/scripts/06-configure-profiler.sh at container start, from the + | same MEMORY_PROFILER_ENABLED env var. + */ + 'memory-profiler' => (bool) env('MEMORY_PROFILER_ENABLED', false), + + /* + |-------------------------------------------------------------------------- + | Memory Profiler: retention cap + |-------------------------------------------------------------------------- + | + | Maximum number of trace pairs kept under storage/profiling (our own + | metadata sidecar + the corresponding `spx` report files). Oldest traces + | are pruned automatically once this cap is exceeded + | (php artisan lychee:profiler:prune). + */ + 'memory-profiler-max-traces' => (int) env('MEMORY_PROFILER_MAX_TRACES', 200), + + /* + |-------------------------------------------------------------------------- + | Memory Profiler: SPX analysis-screen link + |-------------------------------------------------------------------------- + | + | The `spx.http_key` value configured for the `spx` extension (see + | docker/scripts/06-configure-profiler.sh), used to build the "Open in + | SPX Profiler" link on the admin page. Must match the extension's own + | ini value exactly, and must be a long random secret, not a guessable + | default (there is deliberately no fallback value here). + */ + 'memory-profiler-spx-key' => env('MEMORY_PROFILER_SPX_KEY'), ]; \ No newline at end of file diff --git a/config/filesystems.php b/config/filesystems.php index b3f35e2f8b2..46564a0bd29 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -147,5 +147,14 @@ function renv_cond(string $cst): string 'root' => storage_path('tmp/uploads'), 'visibility' => 'private', ], + + // Memory Profiler (Feature 053): per-request metadata sidecars (JSON) + // + the `spx` extension's own report files, browsable at + // /admin/profiler (owner-only). + FileSystem::PROFILING => [ + 'driver' => 'local', + 'root' => env('LYCHEE_PROFILING', storage_path('profiling')), + 'visibility' => 'private', + ], ], ]; diff --git a/docker/scripts/06-configure-profiler.sh b/docker/scripts/06-configure-profiler.sh new file mode 100644 index 00000000000..3e25552aa3f --- /dev/null +++ b/docker/scripts/06-configure-profiler.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# shellcheck disable=SC3040 +set -euo pipefail + +# Configures the `spx` PHP extension (Feature 053 - Memory Profiler) at +# container start, since all of its ini settings are PHP_INI_SYSTEM and +# cannot be toggled at request time from Laravel. Driven by the same +# MEMORY_PROFILER_* env vars used by the Laravel-side feature flag +# (config/features.php). +# +# Deliberately uses manual start/stop spans (spx.http_profiling_auto_start=0) +# rather than SPX's own "always profiling" ini-only mode: this guarantees a +# correct per-request span regardless of whether the host runtime +# (Octane/FrankenPHP's persistent worker model) fires fresh Zend +# request-lifecycle hooks per HTTP request. See +# docs/specs/6-decisions/ADR-0008-memory-profiler-octane-risk.md. + +PROFILER_INI="${PHP_INI_DIR:-/usr/local/etc/php}/conf.d/zz-memory-profiler.ini" + +MEMORY_PROFILER_ENABLED="${MEMORY_PROFILER_ENABLED:-false}" + +is_truthy() { + case "$(echo "$1" | tr '[:upper:]' '[:lower:]')" in + 1|true|yes|on) return 0 ;; + *) return 1 ;; + esac +} + +if is_truthy "$MEMORY_PROFILER_ENABLED"; then + echo "🧠 Enabling Memory Profiler (spx extension)..." + + if [ -z "${MEMORY_PROFILER_SPX_KEY:-}" ]; then + echo "⚠️ WARNING: MEMORY_PROFILER_ENABLED=true but MEMORY_PROFILER_SPX_KEY is not set." + echo " Traces will still be captured, but the analysis-screen link cannot be secured." + echo " See docs/specs/2-how-to/enable-memory-profiler.md." + fi + + { + echo "spx.data_dir = /app/storage/profiling" + echo "spx.http_profiling_enabled = 1" + echo "spx.http_profiling_auto_start = 0" + echo "spx.http_profiling_metrics = wt,zm,zmab,zmfb,zmac,zmfc,mor" + if [ -n "${MEMORY_PROFILER_SPX_KEY:-}" ]; then + echo "spx.http_enabled = 1" + echo "spx.http_key = ${MEMORY_PROFILER_SPX_KEY}" + fi + if [ -n "${MEMORY_PROFILER_SPX_IP_WHITELIST:-}" ]; then + echo "spx.http_ip_whitelist = ${MEMORY_PROFILER_SPX_IP_WHITELIST}" + fi + } > "$PROFILER_INI" +else + # Extension stays loaded but fully inert (no ini settings enabled): no + # measurable overhead beyond the disabled extension's own baseline cost. + : > "$PROFILER_INI" +fi diff --git a/docker/scripts/entrypoint.sh b/docker/scripts/entrypoint.sh index a8b45ecf93f..55e995349a5 100644 --- a/docker/scripts/entrypoint.sh +++ b/docker/scripts/entrypoint.sh @@ -30,6 +30,9 @@ source /usr/local/bin/04-user-setup.sh # Check and set permissions source /usr/local/bin/05-permissions-check.sh +# Configure the Memory Profiler (spx extension) ini settings from env vars +source /usr/local/bin/06-configure-profiler.sh + echo "Checking RUN_AS_ROOT setting" RUN_AS_ROOT=${RUN_AS_ROOT:-no} if [ "$RUN_AS_ROOT" = "yes" ]; then diff --git a/docs/specs/2-how-to/enable-memory-profiler.md b/docs/specs/2-how-to/enable-memory-profiler.md new file mode 100644 index 00000000000..6233e0b3601 --- /dev/null +++ b/docs/specs/2-how-to/enable-memory-profiler.md @@ -0,0 +1,113 @@ +# How to Enable and Use the Memory Profiler + +The Memory Profiler (Feature 053) captures a per-request memory-allocation profile using the [`spx`](https://github.com/NoiseByNorthwest/php-spx) PHP extension and lets the instance owner browse captured traces at `/admin/profiler`, opening each one in SPX's own bundled analysis screen (flame graph / timeline / call tree). This guide explains how to install, configure, and use it — and, importantly, how access to the analysis screen is protected. + +--- + +**This is an optional, opt-in debugging tool, not recommended for continuous production use.** + +## Why `spx`, not `memprof` + +The extension originally considered was [`memprof`](https://github.com/arnaud-lb/php-memory-profiler). It cannot be used at all on Lychee's official Docker image: that image's PHP build is **ZTS** (Zend Thread Safety, required by FrankenPHP for its worker model), and `memprof`'s current release refuses to compile against ZTS builds (`#error "ZTS build not supported (yet)"` — a long-standing, unresolved upstream limitation, [tracked here](https://github.com/arnaud-lb/php-memory-profiler/issues/24)). `spx` explicitly supports ZTS and was verified to compile and load on the exact same base image, so it's the engine this feature actually uses. See [ADR-0008](../6-decisions/ADR-0008-memory-profiler-octane-risk.md) for the full history. + +`spx` also tracks allocation *and* free counts/bytes per call path (not just usage deltas like XHProf-family tools), which is what makes it useful for hunting leaks rather than just "what's using memory right now." + +## What's bundled + +`spx` is installed by default in the official Docker image (`install-php-extensions spx`, plus its `zlib1g-dev` build dependency). Non-Docker/bare-metal installs need to install it manually: + +```bash +# Debian or Ubuntu: +apt-get install zlib1g-dev +pie install noisebynorthwest/php-spx +# or +pecl install spx +``` + +Enable it in `php.ini` (or via `-d`): + +```ini +extension=spx.so +``` + +Verify: + +```bash +php -m | grep -i spx +``` + +## Correctness under Octane/FrankenPHP + +Lychee's default production runtime (the official Docker image's `web` mode) runs under Laravel Octane with FrankenPHP, which keeps a single PHP worker thread alive across many requests. This was a real concern during implementation — a naive "always profiling" configuration might not isolate memory correctly per request when the same thread serves many of them. + +**This was verified empirically, not just assumed.** Two consecutive HTTP requests (allocating different amounts of memory) were sent to the same running worker thread; both were confirmed to be handled by the identical OS thread, yet each produced an independently correct peak-memory reading (not cumulative). This is why the feature uses manual `spx_profiler_start()`/`spx_profiler_stop()` spans (`spx.http_profiling_auto_start=0`) rather than SPX's own ini-only "always profiling" mode — SPX's own documentation recommends exactly this pattern for persistent-worker runtimes. See [ADR-0008](../6-decisions/ADR-0008-memory-profiler-octane-risk.md) for the full test. + +## Usage + +### 1. Enable the feature + +Set the following in your `.env` file: + +```env +MEMORY_PROFILER_ENABLED=true +MEMORY_PROFILER_SPX_KEY= +``` + +Restart the container/application for the change to take effect. Unlike a normal Laravel feature flag, `spx`'s own settings are `PHP_INI_SYSTEM` — they're written by `docker/scripts/06-configure-profiler.sh` at container start (from these same env vars), not read at request time, so a restart is required either way. + +### 2. Profile requests + +Once enabled, every request is profiled automatically (there is no per-request opt-in trigger — see the feature's spec, Q-053-04). A metadata sidecar (`.json`) is written to `storage/profiling` for each request, alongside SPX's own report files. + +### 3. Browse traces + +Log in as the instance owner (the user whose ID matches `config('owner_id')` — normally the first admin account created) and open: + +``` +/admin/profiler +``` + +Each row shows the route, method, status, duration, and peak memory for that request. Rows with a captured SPX report show an "open in SPX" link. + +### 4. Securing the analysis screen + +**This is the most important part of the setup.** Clicking "open in SPX" takes you to a URL like: + +``` +/?SPX_UI_URI=/report.html&SPX_KEY=&key= +``` + +This request is intercepted by the `spx` extension itself, **before Laravel's router or any middleware runs** — including Lychee's own owner-only gate. In other words, anyone who knows (or guesses) `MEMORY_PROFILER_SPX_KEY` can open the same analysis screen directly, bypassing Lychee's login entirely. This is a deliberate trade-off (not a bug) accepted so the feature can reuse SPX's own, already-built viewer instead of Lychee reimplementing one. To mitigate it: + +- **Always set `MEMORY_PROFILER_SPX_KEY` to a long, random, unguessable value.** Generate one with: + ```bash + openssl rand -hex 32 + ``` + There is no default — the feature intentionally does not ship a guessable fallback. +- **Set `MEMORY_PROFILER_SPX_IP_WHITELIST`** to a comma-separated list of trusted IPs (e.g. your own office/VPN range) if your deployment allows it. This maps to `spx.http_ip_whitelist`. +- Only enable this feature during active debugging sessions, and disable it (`MEMORY_PROFILER_ENABLED=false`) otherwise. + +### 5. Retention + +`storage/profiling` is automatically pruned to the newest `MEMORY_PROFILER_MAX_TRACES` traces (default 200), both on a daily schedule and via the "Prune old traces" button on the admin page. Adjust the cap with: + +```env +MEMORY_PROFILER_MAX_TRACES=200 +``` + +Prune manually at any time: + +```bash +php artisan lychee:profiler:prune +``` + +## Troubleshooting + +- **Admin page shows "No traces collected yet"** — confirm `MEMORY_PROFILER_ENABLED=true` and `php -m | grep spx` shows the extension loaded on the process actually serving requests (not just your CLI's `php`). +- **A row has no "open in SPX" link** — either that request didn't produce an SPX report key, or `MEMORY_PROFILER_SPX_KEY` isn't set. +- **`install-php-extensions spx` / `pecl install spx` fails to compile** — check you've installed `zlib1g-dev` (or your distribution's zlib development package) first. +- **The analysis-screen link 404s or shows the normal Lychee page instead of SPX's viewer** — double-check `MEMORY_PROFILER_SPX_KEY` matches exactly, and that the extension is actually loaded (see above). + +--- + +*Last updated: 2026-07-28* diff --git a/docs/specs/4-architecture/features/053-memory-profiler/plan.md b/docs/specs/4-architecture/features/053-memory-profiler/plan.md new file mode 100644 index 00000000000..025d7f73c8e --- /dev/null +++ b/docs/specs/4-architecture/features/053-memory-profiler/plan.md @@ -0,0 +1,157 @@ +# Feature Plan 053 – Memory Profiler + +_Linked specification:_ `docs/specs/4-architecture/features/053-memory-profiler/spec.md` +_Status:_ Implemented +_Last updated:_ 2026-07-28 + +> Guardrail: Keep this plan traceable back to the governing spec. Reference FR/NFR/Scenario IDs from `spec.md` where relevant. + +> **Engine history.** This plan originally targeted `memprof`, then pivoted to `spx` after `memprof` was confirmed impossible to bundle (ZTS incompatibility with the official Docker image's PHP build). The increment map below reflects what was actually built (the `spx`-based design); see [ADR-0008](../../../6-decisions/ADR-0008-memory-profiler-octane-risk.md) and [open-questions.md](../../open-questions.md) (Q-053-01, Q-053-02, Q-053-05..08) for the full trail, including the abandoned `memprof` work. + +## Vision & Success Criteria + +An operator can set `MEMORY_PROFILER_ENABLED=true` and `MEMORY_PROFILER_SPX_KEY=`, restart Lychee, and immediately start seeing per-request memory traces accumulate under `storage/profiling`. As the site owner (matching `config('owner_id')`), they can browse those traces at `/admin/profiler` and open any one in SPX's own analysis screen to diagnose a memory leak or unexpectedly heavy request — without touching Vue, without a database migration, with zero measurable cost to every installation that leaves the flag off, and **correctly under Lychee's default Octane/FrankenPHP runtime** (verified empirically, not merely hoped for). + +Success bars (all met): +- Middleware overhead when disabled: unmeasurable (single branch). +- No 500s: every failure mode (missing extension, disk-full, missing SPX key) degrades to a clear UI/log message instead of a crash. +- `storage/profiling` never grows unbounded. +- Per-request memory isolation holds even when the same OS thread serves multiple requests (Octane/FrankenPHP worker model) — verified via a live `frankenphp php-server` test during implementation. + +## Scope Alignment + +- **In scope:** + - Global request-scoped memory-profiling middleware (`spx_profiler_start()`/`spx_profiler_stop()` manual spans), gated by `features.memory-profiler`. + - `storage/profiling` as a local Laravel disk, shared by Lychee's own sidecar metadata and SPX's own report files. + - Owner-only Blade admin surface: trace listing, manual + scheduled pruning, external link to SPX's own analysis screen. + - New `owner` route middleware (reusable beyond this feature). + - `spx` PECL extension (+ its `zlib1g-dev` build dependency) bundled in the production `Dockerfile` — confirmed to compile/load on the ZTS base image, unlike `memprof`. + - Container-start script (`docker/scripts/06-configure-profiler.sh`) writing `spx`'s `PHP_INI_SYSTEM` settings from `MEMORY_PROFILER_*` env vars. + - How-to guide covering the bundled extension, the env vars, and SPX's own access-control model for its analysis screen. +- **Out of scope:** + - Bundling `memprof` — confirmed impossible (ZTS incompatibility), not revisited without an upstream fix. + - Any Vue/Nuxt/API surface. + - Rendering SPX's call-graph/flame-graph inside a Lychee-owned Blade page — an external link to SPX's own bundled viewer is used instead (Q-053-07). + - Sampling / per-request opt-in triggers, aggregate reporting across multiple traces. + +## Dependencies & Interfaces + +- `App\Repositories\ConfigManager` (reused, read-only, for the `owner_id` check — same dependency `App\Rules\OwnerIdRule` already has). +- `App\Exceptions\UnauthorizedException`, `App\Exceptions\FeatureDisabledException` (existing exception classes, reused as-is). +- `config/features.php` + `App\Http\Middleware\FeatureEnabled` (existing pattern, reused). +- `config/filesystems.php` (new `profiling` disk entry, same shape as the existing `tmp-uploads`/`image-jobs` local disks). +- `app/Http/Kernel.php` (new global middleware entry + new `owner` alias). +- `routes/web-admin-v2.php` (new `/admin/profiler*` route group — this file is registered **before** `routes/web_v2.php` in `RouteServiceProvider::boot()`, so these explicit routes are matched ahead of the Vue SPA's `/admin` catch-all; this ordering is load-bearing and must not be disturbed). +- `Dockerfile` (new `spx` extension + `zlib1g-dev` build dependency) + `docker/scripts/06-configure-profiler.sh` (new, sourced from `entrypoint.sh`). +- External, non-Composer runtime dependency: `spx` PHP extension (bundled in the official image; manual install for bare-metal/custom images). + +## Assumptions & Risks + +- **Resolved decisions (all Option A unless noted):** + - Engine: `spx`, not `memprof` (Q-053-02, forced by empirical ZTS failure) or an XHProf-family tool (Q-053-06, usage-only metrics insufficient for leak-hunting). + - Capture model: manual `spx_profiler_start()`/`spx_profiler_stop()` spans, not SPX's ini-only always-on mode (Q-053-05), specifically for Octane/FrankenPHP correctness. + - Viewing model: external link to SPX's own bundled analysis screen, not a Lychee-rendered SVG/JSON→pprof conversion (Q-053-07). + - Access control for that external link: SPX's own `spx.http_key`/`spx.http_ip_whitelist`, accepted as "secure enough" despite bypassing Lychee's `owner_id` gate (Q-053-08). + - Profiling is always-on for every request while the flag is enabled (no sampling) — Q-053-04. + - Trace retention is count-based (`MEMORY_PROFILER_MAX_TRACES`, default 200) — Q-053-03. +- **Risks / Mitigations:** + - **SPX's analysis screen bypasses Lychee's owner-only gate** (NFR-053-04): accepted trade-off (Q-053-08); mitigated by requiring a long random `spx.http_key` (no default shipped) and documenting the IP-whitelist option prominently in the how-to guide. + - **CI/analysis sandbox has no `spx` extension installed**: all extension-dependent code paths are wrapped behind `function_exists()` and unit-tested via a fake/no-op double (`FakeSpxRecorder`); the *real* extension behaviour (including the Octane correctness claim, NFR-053-06) was validated manually during implementation via a live `frankenphp php-server` run, not by the automated suite. + - **`spx`'s own report format is opaque** (JSON `full` report + `.txt.gz`): Lychee's code never parses it — it only stores the returned report key and links out, so there is no coupling to SPX's internal format beyond the key itself. + +## Implementation Drift Gate + +This plan reflects the shipped implementation as of 2026-07-28. Any future change to the capture model (e.g. switching back to ini-only auto-profiling, or re-attempting `memprof` if it ever gains ZTS support) must update NFR-053-06's empirical claim and re-verify via the same `frankenphp php-server` two-request test before merging. + +## Increment Map + +1. **I1 – Config, feature flag & `profiling` disk** + - _Goal:_ Wire up `MEMORY_PROFILER_ENABLED`, `MEMORY_PROFILER_MAX_TRACES`, `MEMORY_PROFILER_SPX_KEY` at the config layer; add the `profiling` local disk. + - _Steps:_ `config/features.php` entries; `.env.example` entries; `config/filesystems.php` `profiling` disk (`storage_path('profiling')`); `storage/profiling/.gitignore`. + - _Commands:_ `php artisan test --filter=MemoryProfilerConfigTest`, `php artisan test --filter=ProfilingDiskTest`, `make phpstan`. + - _Exit:_ Config resolves correctly; disk resolvable in tests without touching real `storage/app`. + +2. **I2 – `owner` middleware** + - _Goal:_ Reusable owner-only route guard. + - _Steps:_ `App\Http\Middleware\OwnerOnly` (mirrors `App\Rules\OwnerIdRule`'s check), registered as the `owner` alias in `app/Http/Kernel.php`. + - _Commands:_ `php artisan test --filter=OwnerOnlyTest`, `make phpstan`. + - _Exit:_ Unauthenticated/non-owner/owner cases all correctly handled in isolation. + +3. **I3 – `SpxRecorder` + `MemoryProfiler` middleware (core capture)** + - _Goal:_ Implement FR-053-01/02/06. + - _Steps:_ `App\Services\Profiling\SpxRecorder` (thin `function_exists`-guarded wrapper around `spx_profiler_start()`/`spx_profiler_stop()`); `App\Http\Middleware\MemoryProfiler` (terminable middleware: `handle()` starts the span via a request attribute for cross-instance state, `terminate()` stops it and writes the `lychee-*.json` sidecar, including the returned `spx_report_key`); registered in `app/Http/Kernel.php`'s global `$middleware`. PHPStan stub (`phpstan/stubs/spx.stub`, loaded via `bootstrapFiles` so the stub functions are genuinely callable during static analysis) since the extension isn't installed on the analysis machine. + - _Commands:_ `php artisan test --filter=MemoryProfilerTest`, `make phpstan`. + - _Exit:_ No-op paths (flag off, extension absent) and the happy path (sidecar written with correct metadata + report key) all covered with a fake recorder; dump failures logged without throwing. + +4. **I4 – `ProfilerController` + routes + Blade listing page** + - _Goal:_ FR-053-03/04/05. + - _Steps:_ `Route::prefix('admin')->middleware(['login_required:always', 'feature:memory-profiler', 'owner'])` group in `routes/web-admin-v2.php` (`GET profiler`, `POST profiler/prune`); `ProfilerController::index()` lists `lychee-*.json` sidecars and builds the SPX analysis-screen URL per row when a key is available; `resources/views/admin/profiler/index.blade.php`. + - _Commands:_ `php artisan test --filter=ProfilerControllerTest`, `make phpstan`. + - _Exit:_ Empty/populated listing, auth/feature-flag gating, and SPX-link presence/absence all covered. + +5. **I5 – Pruning** + - _Goal:_ FR-053-07. + - _Steps:_ `App\Services\Profiling\TracePruner` (keeps newest `MEMORY_PROFILER_MAX_TRACES`, deletes each pruned trace's sidecar *and* its SPX report pair together); `App\Console\Commands\Profiling\PruneTraces` (`lychee:profiler:prune`); scheduled daily in `app/Console/Kernel.php`; `ProfilerController::prune()` reuses the same service. + - _Commands:_ `php artisan test --filter=TracePrunerTest`, `php artisan test --filter=PruneTracesTest`, `make phpstan`. + - _Exit:_ Oldest traces pruned beyond the cap; no orphaned SPX report files. + +6. **I6 – Dockerfile + container-start ini configuration** + - _Goal:_ Bundle `spx` and wire its `PHP_INI_SYSTEM` settings from env vars, since they can't be toggled from Laravel at request time. + - _Steps:_ Add `zlib1g-dev` (build dep) to the existing `apt-get install` list and `spx` to the existing `install-php-extensions` invocation in `Dockerfile`. New `docker/scripts/06-configure-profiler.sh`, sourced from `entrypoint.sh`, writes `spx.data_dir`, `spx.http_profiling_enabled`, `spx.http_profiling_auto_start=0`, `spx.http_profiling_metrics`, and (when `MEMORY_PROFILER_SPX_KEY` is set) `spx.http_enabled`/`spx.http_key`/`spx.http_ip_whitelist` to a conf.d ini file, from the `MEMORY_PROFILER_*` env vars, at every container start. + - _Commands:_ `docker build .`; `docker run --rm --entrypoint sh -c "php -m | grep spx"`; `docker run ... -c "/usr/local/bin/06-configure-profiler.sh && cat .../zz-memory-profiler.ini && php -i | grep spx"`. + - _Exit:_ Extension loads; ini settings correctly reflect env vars in both the enabled and disabled cases (both verified empirically). + +7. **I7 – Octane/FrankenPHP correctness verification (NFR-053-06)** + - _Goal:_ Confirm manual start/stop spans correctly isolate memory per request even when the same worker thread serves multiple requests — the core risk this design exists to address. + - _Steps:_ Ran the built image via `frankenphp php-server` (FrankenPHP's own HTTP server) with a minimal script that allocates a request-sized buffer and calls `spx_profiler_start()`/`stop()`; sent two consecutive requests (1MB and 9MB allocations) and compared the resulting SPX reports. + - _Commands:_ `docker run ... frankenphp php-server ...`; `curl` x2; inspect the two `spx-full-*.json` report files' `process_pid`/`process_tid`/`peak_memory_usage` fields. + - _Exit:_ Both requests reported the **same** `process_pid`/`process_tid` (proving they shared a worker thread) but **independently correct** `peak_memory_usage` (1.4MB vs 9.4MB, not cumulative) — confirming no cross-request contamination. Documented in ADR-0008 and the how-to guide. + +8. **I8 – Documentation** + - _Goal:_ Satisfy the Documentation Deliverables section of the spec. + - _Steps:_ `docs/specs/2-how-to/enable-memory-profiler.md` (rewritten for `spx`); `roadmap.md`; `knowledge-map.md`; ADR-0008 (amended with the full `memprof`→`spx` history and the I7 verification). + - _Commands:_ none (docs only). + - _Exit:_ All Documentation Deliverables checked off; no stale `memprof`/pprof references remain in shipped docs. + +9. **I9 – Quality gate** + - _Goal:_ Final sign-off. + - _Steps:_ Full `php artisan test`, `make phpstan`, `vendor/bin/php-cs-fixer fix`. + - _Commands:_ same. + - _Exit:_ Green quality gate. + +## Scenario Tracking + +| Scenario ID | Increment / Task reference | Notes | +|-------------|---------------------------|-------| +| S-053-01 | I3 | Flag off → no-op. | +| S-053-02 | I3 | Extension absent → no-op. | +| S-053-03 | I3 | Normal capture path (sidecar + SPX report pair). | +| S-053-04 | I3 | Sidecar write failure logged, response unaffected. | +| S-053-05 | I4 | Empty listing. | +| S-053-06 | I4 | Populated listing. | +| S-053-07 | I4 | Working SPX link. | +| S-053-08 | I4 | No SPX link available. | +| S-053-09 | I4 | Unauthenticated → redirect. | +| S-053-10 | I4 | Non-owner → 403. | +| S-053-11 | I5 | Pruning caps count, no orphaned SPX files. | +| S-053-12 | I7 | Octane/FrankenPHP per-request isolation (manual verification). | + +## Analysis Gate + +Not formally re-run as a separate gate — this plan was implemented directly following the pivot from `memprof`, with each increment's tests passing before moving to the next (per the Specification Pipeline's spirit, if not its literal pre-implementation gate, since the pivot happened mid-implementation in response to empirical findings rather than at plan-drafting time). + +## Exit Criteria + +- All tasks in `tasks.md` checked `[x]`. +- `php artisan test`, `make phpstan` (0 errors), `vendor/bin/php-cs-fixer fix` all green. +- `docs/specs/2-how-to/enable-memory-profiler.md` published, reflecting the final `spx`-based design. +- `roadmap.md` and `knowledge-map.md` updated. +- `Dockerfile` bundles `spx` (not `memprof`); verified via `docker build` + runtime checks. +- NFR-053-06's Octane correctness claim backed by the I7 empirical test, documented in ADR-0008. + +## Follow-ups / Backlog + +- Per-request opt-in trigger / sampling rate, if always-on proves too noisy in practice (deferred from Q-053-04). +- Aggregate/cross-request reporting (e.g. "top 10 heaviest routes this week"). +- Revisit bundling `memprof` if it ever gains ZTS support upstream ([arnaud-lb/php-memory-profiler#24](https://github.com/arnaud-lb/php-memory-profiler/issues/24)). +- Consider rendering SPX's report data inside a Lychee-owned page (rather than linking out) if the owner-only-gate gap (NFR-053-04) proves unacceptable in practice. diff --git a/docs/specs/4-architecture/features/053-memory-profiler/spec.md b/docs/specs/4-architecture/features/053-memory-profiler/spec.md new file mode 100644 index 00000000000..5a22b9543b4 --- /dev/null +++ b/docs/specs/4-architecture/features/053-memory-profiler/spec.md @@ -0,0 +1,274 @@ +# Feature 053 – Memory Profiler + +| Field | Value | +|-------|-------| +| Status | Implemented | +| Last updated | 2026-07-28 | +| Owners | User | +| Linked plan | `docs/specs/4-architecture/features/053-memory-profiler/plan.md` | +| Linked tasks | `docs/specs/4-architecture/features/053-memory-profiler/tasks.md` | +| Roadmap entry | #053 | + +> Guardrail: This specification is the single normative source of truth for the feature. Track high- and medium-impact questions in [docs/specs/4-architecture/open-questions.md](../../open-questions.md), encode resolved answers directly in the Requirements/NFR/Behaviour/UI/Telemetry sections below (no per-feature `## Clarifications` sections), and use ADRs under `docs/specs/6-decisions/` for architecturally significant clarifications. + +> **Revision history.** This spec went through two engine choices during implementation, both driven by hard technical findings rather than preference: +> 1. **`memprof`** (the originally requested extension) — confirmed **impossible** to bundle: Lychee's official Docker image's PHP build is ZTS (required by FrankenPHP), and `memprof`'s mainline release refuses to compile against ZTS builds at all (`docker build` failure, reproduced; tracked upstream at [arnaud-lb/php-memory-profiler#24](https://github.com/arnaud-lb/php-memory-profiler/issues/24), unresolved since ~2016). +> 2. **`spx`** ([NoiseByNorthwest/php-spx](https://github.com/NoiseByNorthwest/php-spx)) — the feature's final engine. Explicitly supports ZTS (verified empirically: compiles and loads on the exact base image), is self-hosted (no SaaS), and ships allocation/free byte metrics suitable for leak-hunting (not just usage deltas, which is why XHProf-family tools were ruled out — see Q-053-02/Q-053-06). +> +> See [open-questions.md](../../open-questions.md) (Q-053-01 through Q-053-08) and [ADR-0008](../../../6-decisions/ADR-0008-memory-profiler-octane-risk.md) for the full decision trail. + +## Overview + +Lychee had no built-in way to capture *memory* profiles of a request (only `itsgoingd/clockwork` for general request inspection and an optional, manually-installed XHProf setup documented in [enable-hprof.md](../../2-how-to/enable-hprof.md) for CPU/wall-time profiling). This feature adds an optional, request-scoped **memory** profiler backed by the [`spx`](https://github.com/NoiseByNorthwest/php-spx) PHP extension, plus an owner-only Blade admin page (`/admin/profiler`) to browse captured traces and open each one in SPX's own analysis screen (a call-graph/timeline/flame-graph viewer bundled with the extension). + +**Correction to the initial brief.** The code sample supplied with this request (`new Arnaud\PhpMemoryProfiler\Profiler(); $profiler->start(); ... $profiler->dumpToFile(...)`) matched neither `memprof` (see revision history above) nor any other real extension — it was a hallucinated OOP API. No PHP memory-profiling extension exposes a `Profiler` class; every real option (`spx`, `memprof`, `tideways_xhprof`) exposes a small set of global functions. + +**Why not an XHProf-family tool for the "hunt leaks" requirement.** `tideways_xhprof` and the modern PECL `xhprof` fork both support ZTS and expose a `XHPROF_FLAGS_MEMORY` mode, but that mode only reports `mu`/`pmu` (memory used / peak memory) per function call — usage deltas, not leaks. That doesn't distinguish "allocated and properly freed" from "allocated and never freed," which is the actual signal needed to hunt leaks (Q-053-06). `spx` exposes allocation count/bytes *and* free count/bytes (`zmac`/`zmab`/`zmfc`/`zmfb`) per call node, which is the closer fit, plus a process-RSS metric (`mor`) its own docs describe as "useful to highlight a memory leak." + +This feature touches: **application** (new global middleware, config), **infra** (Docker image, container-start ini configuration — `spx`'s settings are `PHP_INI_SYSTEM`, unlike a normal Laravel feature flag), **REST/web** (new Blade-only admin routes — no Vue/API surface), and **ops/docs** (new how-to guide). + +## Goals + +- Capture a memory-allocation profile for every HTTP request while the feature is enabled, from the start of the request to the end, without requiring code changes per-route. +- Do so **correctly under Laravel Octane/FrankenPHP** (Lychee's default production runtime, where a single PHP worker thread serves many logical requests) — verified empirically, not assumed (see NFR-053-06). +- Persist a metadata sidecar per request under `storage/profiling` so traces survive across requests/deploys of the same container. +- Provide an owner-only web UI (`/admin/profiler`) to list captured traces and open each one in SPX's own analysis screen, with no Vue/JS SPA integration on Lychee's side (Blade only, per explicit instruction). +- Degrade to a complete no-op with zero measurable overhead when the `spx` extension is not loaded or the feature is disabled. +- Keep `storage/profiling` from growing without bound (count-based cap with auto-pruning, Q-053-03). + +## Non-Goals + +- No Vue/Nuxt UI, API resource, or SPA route — Blade views only, per explicit instruction. (SPX's own analysis screen is a separate, pre-built vanilla-JS asset bundled with the extension, reached via an external link — it is not part of Lychee's own frontend.) +- No CPU/wall-clock profiling (that's the existing XHProf how-to's job); this feature is memory-allocation only. +- No per-request opt-in trigger (query string / header), sampling rate, or percentage-based profiling in v1 — profiling is simply "on for every request" while the feature flag is enabled (resolved, Q-053-04, Option A). +- No bundling of the `memprof` PECL extension into the default Docker image — confirmed **impossible** (ZTS incompatibility; superseded by the `spx`-based design, see revision history). +- No rendering of the call-graph/flame-graph inside Lychee's own Blade pages — SPX's own analysis screen is linked to externally instead (resolved, Q-053-07; see Security & Access Model below for why this doesn't use the owner-only gate the same way). +- No aggregate/cross-request reporting (each analysis-screen view is per single request/trace only). + +## Functional Requirements + +| ID | Requirement | Success path | Validation path | Failure path | Telemetry & traces | Source | +|----|-------------|--------------|-----------------|--------------|--------------------|--------| +| FR-053-01 | A global HTTP middleware (`App\Http\Middleware\MemoryProfiler`) calls `spx_profiler_start()` at the start of every request and, in its `terminate()` phase (after the response has been sent), calls `spx_profiler_stop()` (which returns SPX's own report key) and writes a JSON metadata sidecar (route name, method, path, status code, duration, peak memory, timestamp, user id, `spx_report_key`) to `storage/profiling`. | Enabled + extension loaded: a `lychee-*.json` sidecar appears in `storage/profiling` after every request, and SPX itself writes its own `{spx_report_key}.json` + `{spx_report_key}.txt.gz` report files to the same directory. | N/A (no user input). | If the sidecar write fails (disk full, permissions), the error is logged via the standard Laravel logger; the response already sent to the client is unaffected (this happens in `terminate()`). | Log entry `memory_profiler.dump_failed` with exception message on failure. | User request; corrected against the real `spx` API (`spx_profiler_start()`/`spx_profiler_stop()`, confirmed via upstream source `src/php_spx.c`). | +| FR-053-02 | The middleware is a complete no-op — no function calls, no I/O — when `function_exists('spx_profiler_start') === false` or the feature flag (FR-053-06) is off. | `handle()` returns `$next($request)` immediately; `terminate()` returns immediately. | N/A | N/A | None (intentionally silent — this is the default/common case). | AGENTS' "no surprises" guardrail. | +| FR-053-03 | `GET /admin/profiler` renders a Blade page listing every trace currently in `storage/profiling` (i.e. every `lychee-*.json` sidecar), newest first, showing timestamp, route/method/path, HTTP status, duration, and peak memory. | Page renders a table of traces; empty state shown when none exist. | N/A | If `storage/profiling` is unreadable, show an error banner instead of a 500. | None. | User request. | +| FR-053-04 | From the listing, each row with a captured `spx_report_key` links to SPX's own analysis screen (`{app_url}/?SPX_UI_URI=/report.html&SPX_KEY=&key=`, per SPX's documented URL pattern), opened in a new tab. Rows without a usable key (missing `spx_report_key`, or `MEMORY_PROFILER_SPX_KEY` not configured) show a placeholder instead of a broken link. | Clicking the link opens SPX's bundled analysis screen (flame-graph/timeline UI) for that specific trace. | N/A — this is an external link, not a Lychee route; SPX itself validates `SPX_KEY` (see NFR-053-04 / Security & Access Model). | Missing key/report → placeholder text, not a broken link or 500. | None (SPX's own request-interception is outside Lychee's request lifecycle and therefore outside this feature's logging). | User request ("give the ability to open each trace" — resolved to SPX's own bundled viewer rather than a locally-rendered SVG, Q-053-07); upstream `spx_profiler_stop()` return value + documented analysis-screen URL pattern. | +| FR-053-05 | `/admin/profiler` and its `prune` sub-route require (a) an authenticated session and (b) that the authenticated user's id equals the configured `owner_id` (same check as the existing `OwnerIdRule`), enforced by a new `owner` route middleware. | Owner loads the page normally. | N/A | Unauthenticated → redirect (via existing `login_required` middleware, applied first); authenticated-but-not-owner → `403 Unauthorized` (`UnauthorizedException`, matches `OwnerIdRule`'s message/behaviour). | None (security-sensitive path; no need to log routine denials beyond the framework's own request log). | User request ("protected by owner_id check middleware"); existing `App\Rules\OwnerIdRule` pattern. Note: this gate protects Lychee's *listing* page only — SPX's own analysis screen (FR-053-04) is protected separately, see NFR-053-04. | +| FR-053-06 | The Laravel-side of the feature (middleware + admin routes) is gated by `config('features.memory-profiler')`, backed by `MEMORY_PROFILER_ENABLED` (env, default `false`), following the exact pattern already used for `log-viewer`, `use-s3`, etc. in `config/features.php`. When off, `/admin/profiler*` responds with the existing `FeatureDisabledException` (`feature:memory-profiler` middleware, 501). The `spx` extension's own ini settings are configured independently by a container-start script from the *same* env var (NFR-053-07). | Operator sets `MEMORY_PROFILER_ENABLED=true` + `MEMORY_PROFILER_SPX_KEY=`, restarts, feature becomes active end-to-end. | N/A | Route access while flag is off → 501, consistent with every other `feature:` gated route in this codebase. | None. | Existing `FeatureEnabled` middleware convention (`app/Http/Middleware/FeatureEnabled.php`). | +| FR-053-07 | `storage/profiling` is kept bounded: `php artisan lychee:profiler:prune` (manual, scheduled daily, or triggered from the admin page's "Prune old traces" button) deletes the oldest traces once the count exceeds `MEMORY_PROFILER_MAX_TRACES` (default 200) — removing both our own `lychee-*.json` sidecar and the corresponding `spx_report_key.json`/`.txt.gz` pair together. | Trace count in `storage/profiling` never exceeds the cap; no orphaned SPX report files. | N/A | N/A | Log entry `memory_profiler.pruned` with count removed. | Derived necessity — every enabled request writes a new trace; unbounded growth is a real operational risk (Q-053-03). | + +## Non-Functional Requirements + +| ID | Requirement | Driver | Measurement | Dependencies | Source | +|----|-------------|--------|-------------|--------------|--------| +| NFR-053-01 | When disabled (flag off or extension absent), the middleware must add no more than a single `config()` read + one `function_exists()` check per request. | Global middleware runs on 100% of traffic; must not regress baseline latency for the overwhelming majority of installs that will never enable this. | Manual review of `handle()`/`terminate()` — both must short-circuit before any I/O or `spx_*` call. | `App\Http\Middleware\MemoryProfiler`. | AGENTS "no surprises". | +| NFR-053-02 | No runtime dependency on any external host — extension, ini config, and trace files are all local to the server; SPX's analysis screen is served by the extension itself, not a SaaS. | Lychee's offline-only requirement: it must work with zero network connection. | Code review: no HTTP client / CDN / telemetry call anywhere in the new middleware, controller, or views. | None. | Project convention (offline-only requirement). | +| NFR-053-03 | Admin UI is Blade-only; no new Vue component, Pinia store, or `resources/js` route is introduced. | Explicit instruction ("We do not need any vue integration for this. blade templates are fine."). | Code review of the diff — no changes under `resources/js/**`. | `resources/views/admin/profiler/index.blade.php`. | User request. | +| NFR-053-04 | SPX's own analysis screen is protected by the extension's own access-control mechanism (`spx.http_key` — a long random secret, required — and optionally `spx.http_ip_whitelist`/`spx.http_trusted_proxies`), **not** by Lychee's `owner_id` gate, because SPX intercepts matching requests at PHP's earliest hook (`RINIT`), before Laravel's kernel or router ever runs — Laravel middleware cannot see or gate that request at all. This is a deliberate, accepted trade-off (Q-053-07/Q-053-08), not an oversight. | Discovered while implementing FR-053-04: SPX's `http_ui_handler` execution path is chosen in `PHP_RINIT_FUNCTION`, entirely outside userland/Laravel code. | Code review + manual verification: hitting `/?SPX_UI_URI=/report.html&...` with a wrong/missing `SPX_KEY` must be denied by the extension itself (`check_access()`), independent of any Laravel route. `MEMORY_PROFILER_SPX_KEY` must never default to a guessable value (`.env.example` ships no default). | `spx.http_key`, `spx.http_ip_whitelist`, `spx.http_trusted_proxies` ini settings. | Upstream `src/php_spx.c` (`PHP_RINIT_FUNCTION`, `check_access()`); user decision (Q-053-08). | +| NFR-053-05 | The `spx` extension is bundled in the production `Dockerfile` (`install-php-extensions spx`, plus its `zlib1g-dev` build dependency) — unlike `memprof`, this compiles and loads successfully on the image's ZTS PHP build (empirically verified). Non-Docker/bare-metal installs must install it manually per the how-to guide. | Matches existing project convention for profiling tooling where feasible; ZTS support is what makes bundling possible here (unlike `memprof`). | `docker build .` succeeds; `docker run --entrypoint sh -c "php -m \| grep spx"` reports the extension loaded. | `docs/specs/2-how-to/enable-memory-profiler.md`; `Dockerfile`. | Empirical verification during implementation (Q-053-02/Q-053-05). | +| NFR-053-06 | Manual `spx_profiler_start()`/`spx_profiler_stop()` spans (with `spx.http_profiling_auto_start=0`) are used instead of SPX's own ini-only "always profiling" mode, specifically to guarantee correct per-request memory isolation under Octane/FrankenPHP's persistent-worker model, where a single OS thread serves many logical requests and Zend request-lifecycle hooks are not guaranteed to reset state the same way as classic per-request PHP-FPM. **Verified empirically, not merely asserted:** two consecutive HTTP requests (allocating 1MB and 9MB respectively) served by the *same* worker thread (`process_pid`/`process_tid` identical in both SPX reports) produced independently-correct `peak_memory_usage` values (not cumulative), confirming no cross-request contamination. | SPX's own documentation ("Handle long-living / daemon processes") recommends exactly this pattern for persistent-worker runtimes; this is a materially different execution model from the traditional per-request PHP-FPM lifecycle that most profiling tooling assumes. | Manual verification performed during implementation: `frankenphp php-server` (FrankenPHP's own HTTP server) serving two consecutive requests to the same worker process/thread, comparing the two SPX-produced reports' `peak_memory_usage` and `process_pid`/`process_tid` fields. | `laravel/octane`, `dunglas/frankenphp` (default runtime); `spx.http_profiling_auto_start` ini setting. | Discovered during spec research for the (superseded) `memprof` design; resolved for `spx` via the above empirical test (Q-053-01, amended). | + +## UI / Interaction Mock-ups + +``` +GET /admin/profiler ++----------------------------------------------------------------------------------+ +| Memory Profiler [ Prune old traces ]| +|------------------------------------------------------------------------------------| +| ⚠ This server is running under Octane/FrankenPHP. Capture uses SPX's manual | +| start/stop spans specifically to remain correct under this runtime. | +| ⚠ MEMORY_PROFILER_SPX_KEY is not set — "view" links cannot be built. | +| (shown only when the key is missing) | +|------------------------------------------------------------------------------------| +| Captured at | Route | Method | Status | Duration | Peak mem | | +|----------------------|-----------------------|--------|--------|----------|----------|----| +| 2026-07-28 10:14:02 | gallery.index | GET | 200 | 42 ms | 42.1 MB |[open in SPX →]| +| 2026-07-28 10:13:57 | api.v2.photo.upload | POST | 201 | 812 ms | 118.4 MB |[open in SPX →]| +| 2026-07-28 10:13:40 | gallery.album.show | GET | 200 | 31 ms | 39.8 MB | — | +| | +| (empty state, shown when storage/profiling has no traces yet) | +| "No traces collected yet. Make sure MEMORY_PROFILER_ENABLED=true and the spx | +| extension is loaded — see the how-to guide." | ++----------------------------------------------------------------------------------+ + +"[open in SPX →]" opens, in a new tab, SPX's own bundled analysis screen — a +separate, pre-built UI (flame graph / timeline / call tree) shipped with the +extension itself, not a Lychee-rendered page. It is reached via an external +URL protected by SPX's own spx.http_key, not by Lychee's owner-only gate +(see NFR-053-04). +``` + +## Branch & Scenario Matrix + +| Scenario ID | Description / Expected outcome | +|-------------|--------------------------------| +| S-053-01 | Feature flag off → middleware no-ops; `/admin/profiler*` returns 501 (`feature` middleware). | +| S-053-02 | Feature flag on, `spx` extension not loaded → middleware no-ops (logs nothing, no error); admin page still reachable and shows the "no traces / check extension" empty state. | +| S-053-03 | Feature flag on, extension loaded, normal request → sidecar (`lychee-*.json`) + SPX's own report pair written to `storage/profiling` after the response is sent. | +| S-053-04 | Sidecar write fails (disk full/permissions) → response to the original request is unaffected; failure logged. | +| S-053-05 | Owner visits `/admin/profiler` with zero traces present → empty state rendered, no error. | +| S-053-06 | Owner visits `/admin/profiler` with N traces present → table of N rows, newest first. | +| S-053-07 | Trace has a `spx_report_key` and `MEMORY_PROFILER_SPX_KEY` is configured → row shows a working "open in SPX" link. | +| S-053-08 | Trace has no `spx_report_key`, or the key isn't configured → row shows a placeholder, no broken link. | +| S-053-09 | Unauthenticated visitor requests `/admin/profiler` → redirected. | +| S-053-10 | Authenticated non-owner requests `/admin/profiler` → `403 Unauthorized`. | +| S-053-11 | Trace count exceeds `MEMORY_PROFILER_MAX_TRACES` → oldest trace(s) — both our sidecar and SPX's own report pair — pruned automatically. | +| S-053-12 | Two consecutive requests served by the same Octane/FrankenPHP worker thread → each gets an independently-scoped SPX report (verified empirically, NFR-053-06). | + +## Test Strategy + +- **Application/Middleware:** Feature tests for `MemoryProfiler` covering S-053-01..04, using a test double (`FakeSpxRecorder`) for the real `spx_profiler_start()`/`stop()` calls so the suite runs green on CI images without the extension installed. +- **REST/Web (Blade routes):** Feature tests for S-053-05..10: empty listing, populated listing (with/without a usable SPX link), unauthenticated redirect, non-owner 403. +- **CLI:** Feature/unit test for the pruning console command (S-053-11): seed N+k fake traces (sidecar + SPX report pairs), run the command, assert only the newest N remain and no SPX report is orphaned. +- **Manual/empirical (not automated):** S-053-12 was verified via a real `frankenphp php-server` run during implementation (see NFR-053-06) rather than as part of the automated suite, since it requires the real extension and a live HTTP server. +- **Docs/Contracts:** How-to guide (`enable-memory-profiler.md`) reviewed for accuracy against the real `spx` API and the empirically-verified Docker build/runtime behaviour. + +## Interface & Contract Catalogue + +### Domain Objects + +| ID | Description | Modules | +|----|-------------|---------| +| DO-053-01 | `ProfilingTraceMeta` — JSON sidecar fields: `spx_report_key` (string\|null), `route_name` (string\|null), `method` (string), `path` (string), `status_code` (int), `duration_ms` (float), `peak_memory_bytes` (int), `user_id` (int\|null), `created_at` (ISO-8601 string). | application | + +### API Routes / Services + +| ID | Transport | Description | Notes | +|----|-----------|-------------|-------| +| API-053-01 | Web GET `/admin/profiler` | Lists all traces. | `owner`, `feature:memory-profiler`, `login_required:always` middleware. Blade response, not JSON. | +| API-053-02 | Web POST `/admin/profiler/prune` | Manually triggers the pruning step from the admin page's "Prune old traces" button. | Same middleware stack. | +| API-053-03 | External (not a Lychee route) `GET /?SPX_UI_URI=/report.html&SPX_KEY=&key=` | SPX's own analysis screen, intercepted by the extension before Laravel's router runs. | Protected by `spx.http_key`/`spx.http_ip_whitelist`, not by Lychee's `owner` middleware (NFR-053-04). | + +### CLI Commands / Flags + +| ID | Command | Behaviour | +|----|---------|-----------| +| CLI-053-01 | `php artisan lychee:profiler:prune` | Deletes the oldest traces (sidecar + SPX report pair) beyond `MEMORY_PROFILER_MAX_TRACES`, callable manually or from the schedule (`app/Console/Kernel.php`). | + +### Telemetry Events + +| ID | Event name | Fields / Redaction rules | +|----|-----------|---------------------------| +| TE-053-01 | `memory_profiler.dump_failed` | `route`, `exception_message` (log channel only, not persisted as a DB event). | +| TE-053-02 | `memory_profiler.pruned` | `removed_count`, `remaining_count`. | + +### Fixtures & Sample Data + +None required — all tests use inline fixture data (fake sidecars/report files written directly to the test's `profiling` disk), since the real `.txt.gz`/`.json` report format is opaque to Lychee's own code (we only read/write our own sidecar; SPX's own reports are opaque blobs we pass through by reference). + +### UI States + +| ID | State | Trigger / Expected outcome | +|----|-------|---------------------------| +| UI-053-01 | Empty trace list | No files in `storage/profiling` → guidance banner (extension/flag check). | +| UI-053-02 | Populated trace list | ≥1 trace present → table, newest first. | +| UI-053-03 | Working SPX link | Trace has `spx_report_key` + `MEMORY_PROFILER_SPX_KEY` configured → "open in SPX" link. | +| UI-053-04 | No SPX link available | Missing key/report → placeholder, no broken link. | +| UI-053-05 | Octane info banner | App detected running under Octane → informational banner (not a warning — NFR-053-06 confirms correctness under this runtime). | +| UI-053-06 | Missing SPX key banner | `MEMORY_PROFILER_SPX_KEY` unset while feature enabled → warning banner. | + +## Telemetry & Observability + +All events in this feature are **log-channel only** (Laravel's standard logger), not the DB-backed telemetry/event pipeline used by other features — there is no user-facing analytics need here, only operator-facing diagnostics. No PII beyond `user_id` (already present in every other Lychee log line) is recorded. SPX's own reports (`http_request_uri`, `http_method`, etc.) live in SPX's own report files, outside Lychee's telemetry pipeline. + +## Documentation Deliverables + +- How-to guide: `docs/specs/2-how-to/enable-memory-profiler.md` (bundled `spx` extension; `MEMORY_PROFILER_ENABLED`/`MEMORY_PROFILER_SPX_KEY`/`MEMORY_PROFILER_SPX_IP_WHITELIST`/`MEMORY_PROFILER_MAX_TRACES`; security model for SPX's own analysis screen; Octane correctness note). +- `docs/specs/4-architecture/roadmap.md`: row for 053. +- `docs/specs/4-architecture/knowledge-map.md`: entry describing the middleware/admin surface and SPX integration. +- `.env.example`: `MEMORY_PROFILER_ENABLED`, `MEMORY_PROFILER_MAX_TRACES`, `MEMORY_PROFILER_SPX_KEY`, `MEMORY_PROFILER_SPX_IP_WHITELIST`. +- `Dockerfile` + `docker/scripts/06-configure-profiler.sh`: bundles `spx`, writes its `PHP_INI_SYSTEM` settings from env vars at container start. +- [ADR-0008](../../../6-decisions/ADR-0008-memory-profiler-octane-risk.md): records the full engine-selection history (`memprof` → `spx`) and the Octane-correctness verification. + +## Fixtures & Sample Data + +None (see Interface & Contract Catalogue above). + +## Spec DSL + +``` +domain_objects: + - id: DO-053-01 + name: ProfilingTraceMeta + fields: + - name: spx_report_key + type: string|null + - name: route_name + type: string|null + - name: method + type: string + - name: path + type: string + - name: status_code + type: integer + - name: duration_ms + type: float + - name: peak_memory_bytes + type: integer + - name: user_id + type: integer|null + - name: created_at + type: string (ISO-8601) +routes: + - id: API-053-01 + method: GET + path: /admin/profiler + - id: API-053-02 + method: POST + path: /admin/profiler/prune + - id: API-053-03 + method: GET + path: / (external, SPX-intercepted, not a Lychee route) +cli_commands: + - id: CLI-053-01 + command: php artisan lychee:profiler:prune +telemetry_events: + - id: TE-053-01 + event: memory_profiler.dump_failed + - id: TE-053-02 + event: memory_profiler.pruned +ui_states: + - id: UI-053-01 + description: Empty trace list guidance banner + - id: UI-053-02 + description: Populated trace list table + - id: UI-053-03 + description: Working SPX analysis-screen link + - id: UI-053-04 + description: No SPX link available placeholder + - id: UI-053-05 + description: Octane info banner + - id: UI-053-06 + description: Missing SPX key warning banner +``` + +## Appendix + +### Corrected reference snippet (final, `spx`-based) + +```php +// App\Http\Middleware\MemoryProfiler::handle() +if (!Features::active('memory-profiler') || !$this->recorder->isAvailable()) { + return $next($request); // extension not loaded or feature off — no-op +} +$request->attributes->set(self::ATTR_START_TIME, microtime(true)); +$this->recorder->start(); // spx_profiler_start() + +// ... later, in terminate($request, $response) ... +$spx_report_key = $this->recorder->stop(); // spx_profiler_stop(): ?string +// write our own JSON sidecar to storage/profiling, including $spx_report_key +``` + +Building the analysis-screen link (external, not a Lychee route): + +```php +url('/') . '?' . http_build_query([ + 'SPX_UI_URI' => '/report.html', + 'SPX_KEY' => config('features.memory-profiler-spx-key'), + 'key' => $spx_report_key, +]); +``` + +### Why manual start/stop instead of SPX's own "always profiling" ini mode + +SPX supports an ini-only always-on mode (`spx.http_profiling_enabled=1` with default `auto_start=1`), which would need zero Laravel-side code. It was rejected in favour of manual `spx_profiler_start()`/`spx_profiler_stop()` spans (`spx.http_profiling_auto_start=0`) specifically because SPX's own documentation calls out persistent-worker runtimes (exactly Octane/FrankenPHP's model) as needing explicit span control for correctness — and this was independently confirmed empirically during implementation (NFR-053-06). diff --git a/docs/specs/4-architecture/features/053-memory-profiler/tasks.md b/docs/specs/4-architecture/features/053-memory-profiler/tasks.md new file mode 100644 index 00000000000..37041283af8 --- /dev/null +++ b/docs/specs/4-architecture/features/053-memory-profiler/tasks.md @@ -0,0 +1,64 @@ +# Feature 053 Tasks – Memory Profiler + +_Status: Implemented_ +_Last updated: 2026-07-28_ + +> Keep this checklist aligned with `plan.md`'s increments. This reflects the final `spx`-based implementation — the earlier `memprof`-based tasks (T-053-01..25 in a prior revision of this file) were superseded mid-implementation after `memprof` was confirmed impossible to bundle (ZTS incompatibility). See [ADR-0008](../../../6-decisions/ADR-0008-memory-profiler-octane-risk.md) and [open-questions.md](../../open-questions.md) for the full trail. + +## Checklist + +- [x] T-053-01 – Add `memory-profiler`, `memory-profiler-max-traces`, `memory-profiler-spx-key` to `config/features.php` + `.env.example` entries (F-053-06). + _Verification commands:_ `php artisan test --filter=MemoryProfilerConfigTest`, `make phpstan` + +- [x] T-053-02 – Add `profiling` disk to `config/filesystems.php` + `storage/profiling/.gitignore` (F-053-01). + _Verification commands:_ `php artisan test --filter=ProfilingDiskTest` + +- [x] T-053-03 – Implement `App\Http\Middleware\OwnerOnly` + register `owner` alias (F-053-05). + _Verification commands:_ `php artisan test --filter=OwnerOnlyTest`, `make phpstan` + +- [x] T-053-04 – Implement `App\Services\Profiling\SpxRecorder` (thin `function_exists`-guarded wrapper around `spx_profiler_start()`/`spx_profiler_stop()`) (F-053-01, F-053-02). + _Verification commands:_ covered via T-053-05's tests; `make phpstan` (with `phpstan/stubs/spx.stub` registered as a `bootstrapFiles` entry so the stub functions are genuinely callable during analysis). + +- [x] T-053-05 – Implement `App\Http\Middleware\MemoryProfiler` (`handle()`/`terminate()`, state carried on `$request->attributes` since Laravel resolves a fresh instance for `terminate()`), registered in `app/Http/Kernel.php`'s global `$middleware` (F-053-01, F-053-02, F-053-06, S-053-01..04). + _Verification commands:_ `php artisan test --filter=MemoryProfilerTest`, `make phpstan` + _Notes:_ Tested via `FakeSpxRecorder` (a `SpxRecorder` subclass); the real extension is never required in CI. + +- [x] T-053-06 – Implement `App\Http\Controllers\Admin\ProfilerController::index()` + `GET admin/profiler` route + `resources/views/admin/profiler/index.blade.php` (F-053-03, F-053-04, F-053-05, S-053-05..10). + _Verification commands:_ `php artisan test --filter=ProfilerControllerTest`, `make phpstan` + _Notes:_ Lists `lychee-*.json` sidecars; builds the SPX analysis-screen URL (`?SPX_UI_URI=/report.html&SPX_KEY=...&key=...`) per row when `spx_report_key` + `MEMORY_PROFILER_SPX_KEY` are both available. + +- [x] T-053-07 – Implement `App\Services\Profiling\TracePruner` (deletes each pruned trace's `lychee-*.json` sidecar *and* its `spx_report_key`-derived `.json`/`.txt.gz` pair together) + `App\Console\Commands\Profiling\PruneTraces` (`lychee:profiler:prune`) + schedule entry + `ProfilerController::prune()` (F-053-07, CLI-053-01, S-053-11). + _Verification commands:_ `php artisan test --filter=TracePrunerTest`, `php artisan test --filter=PruneTracesTest`, `make phpstan` + +- [x] T-053-08 – Bundle `spx` (+ `zlib1g-dev` build dependency) in `Dockerfile`'s existing `apt-get install`/`install-php-extensions` invocations (NFR-053-05). + _Verification commands:_ + - `docker build .` — succeeded. + - `docker run --rm --entrypoint sh -c "php -m | grep spx"` — reports `SPX` loaded. + _Notes:_ `memprof` + `libjudy-dev` were attempted here first and reverted after `docker build` failed deterministically with `#error "ZTS build not supported (yet)"` — see ADR-0008. + +- [x] T-053-09 – Add `docker/scripts/06-configure-profiler.sh` (writes `spx`'s `PHP_INI_SYSTEM` settings — `data_dir`, `http_profiling_enabled`, `http_profiling_auto_start=0`, `http_profiling_metrics`, and conditionally `http_enabled`/`http_key`/`http_ip_whitelist` — from `MEMORY_PROFILER_*` env vars); source it from `entrypoint.sh` (F-053-06). + _Verification commands:_ + - `docker run ... -e MEMORY_PROFILER_ENABLED=true -e MEMORY_PROFILER_SPX_KEY=... -c "/usr/local/bin/06-configure-profiler.sh && cat .../zz-memory-profiler.ini && php -i | grep spx"` — ini file and `php -i` output both show the expected settings. + - Same command with `MEMORY_PROFILER_ENABLED` unset — ini file empty, `php -i` shows `spx.http_enabled => 0`, `spx.http_profiling_enabled => no value`. + +- [x] T-053-10 – Empirically verify per-request memory isolation under FrankenPHP's worker model (NFR-053-06, S-053-12). + _Verification commands:_ `frankenphp php-server` serving a minimal script via the built image, two consecutive requests (1MB / 9MB allocations) compared by `process_pid`/`process_tid`/`peak_memory_usage` in the two resulting SPX reports. + _Notes:_ Confirmed: same worker thread (`process_pid`/`process_tid` identical), independently-correct `peak_memory_usage` per request (not cumulative) — manual start/stop spans are correct under Octane/FrankenPHP. This closes the loop opened by the (superseded) `memprof`-era Q-053-01. + +- [x] T-053-11 – Write `docs/specs/2-how-to/enable-memory-profiler.md` for the `spx`-based design (dependencies, install, enable, SPX analysis-screen access model, troubleshooting). + _Verification commands:_ n/a (docs) + +- [x] T-053-12 – Update `docs/specs/4-architecture/roadmap.md` and `docs/specs/4-architecture/knowledge-map.md`. + _Verification commands:_ n/a (docs) + +- [ ] T-053-13 – Full quality gate. + _Verification commands:_ + - `vendor/bin/php-cs-fixer fix` + - `php artisan test` + - `make phpstan` + +## Notes / TODOs + +- Every task that calls `spx_*` functions directly is wrapped behind `App\Services\Profiling\SpxRecorder`, specifically so the automated suite never requires the real PECL extension to be installed in CI/sandbox — only T-053-08/09/10 (Docker builds + manual runtime checks) touch the real extension. +- `phpstan/stubs/spx.stub` is registered under `bootstrapFiles` (not `stubFiles`) in `phpstan.neon` — `stubFiles` alone did not make PHPStan recognize genuinely new global functions in this project's PHPStan version; `bootstrapFiles` (which actually `require`s the stub, defining real no-op functions during the analysis process) was needed instead. +- If `MEMORY_PROFILER_SPX_KEY` is ever made mandatory-with-validation (rather than just a documented recommendation), add a startup check in `06-configure-profiler.sh` that fails loudly (not just warns) when `MEMORY_PROFILER_ENABLED=true` and the key is unset. diff --git a/docs/specs/4-architecture/knowledge-map.md b/docs/specs/4-architecture/knowledge-map.md index 5215f0f63ea..df5118de82e 100644 --- a/docs/specs/4-architecture/knowledge-map.md +++ b/docs/specs/4-architecture/knowledge-map.md @@ -12,6 +12,8 @@ This document tracks modules, dependencies, and architectural relationships acro - **Requests** (`app/Http/Requests/`) - Validate and sanitize incoming requests - **Resources** (`app/Http/Resources/`) - Transform models to API responses (use Spatie Data) - **Middleware** (`app/Http/Middleware/`) - Request/response filtering and authentication + - **MemoryProfiler** (`app/Http/Middleware/MemoryProfiler.php`, Feature 053) - Global (registered in `Kernel::$middleware`, applies to every request/route group), terminable middleware. Gated by `features.memory-profiler` (`MEMORY_PROFILER_ENABLED`, off by default). Bounds a manual `spx_profiler_start()`/`spx_profiler_stop()` span (via `App\Services\Profiling\SpxRecorder`, guarded by `function_exists`) around the request, writing a JSON metadata sidecar (`App\DTO\Profiling\ProfilingTraceMeta`, including the returned SPX report key) to the `profiling` disk (`storage/profiling`) in `terminate()`. State is carried on `$request->attributes`, never on `$this`, because Laravel resolves a fresh middleware instance for `terminate()`. Manual start/stop (rather than SPX's ini-only auto mode) was chosen and empirically verified to correctly isolate memory per request under Octane/FrankenPHP's persistent-worker model — see ADR-0008. (Originally targeted `memprof`, confirmed impossible to bundle due to ZTS incompatibility with the official image.) + - **OwnerOnly** (`app/Http/Middleware/OwnerOnly.php`, alias `owner`, Feature 053) - Restricts a route to the single configured instance owner (`config('owner_id')` === `Auth::id()`), mirroring `App\Rules\OwnerIdRule`. Reusable beyond Feature 053. #### Domain Layer - **Models** (`app/Models/`) - Eloquent ORM models for database entities @@ -32,6 +34,7 @@ This document tracks modules, dependencies, and architectural relationships acro - `auto_cover_id_max_privilege` - Cover photo for admin/owner view (ignores access control) - `auto_cover_id_least_privilege` - Cover photo for public view (respects PhotoQueryPolicy + AlbumQueryPolicy) - **Services** (`app/Services/`) - Business logic and orchestration + - **Profiling Services** (`app/Services/Profiling/`, Feature 053) - `SpxRecorder` (thin `spx_profiler_start()`/`spx_profiler_stop()` wrapper around the `spx` PECL extension — bundled in the Dockerfile, see `docker/scripts/06-configure-profiler.sh` for its `PHP_INI_SYSTEM` config), `TracePruner` (count-based retention, `memory-profiler-max-traces`, deletes each pruned trace's own sidecar *and* SPX's own report pair together; shared by the `lychee:profiler:prune` command and the admin page's prune button). Traces are viewed via an external link to SPX's own bundled analysis screen (protected by `spx.http_key`/`spx.http_ip_whitelist`, not Lychee's `owner_id` gate — see ADR-0008), not rendered inside Lychee. - **AdminStatsService** (`app/Services/AdminStatsService.php`) - Aggregates system-wide metrics (photos, albums, users, storage, jobs) with 5-minute cache under key `admin.stats`. Supports forced refresh via `$force = true`. Returns `AdminStatsOverview` DTO; partial failures captured in `errors[]` and suppress caching. - **LDAP Service** (`app/Services/Auth/LdapService.php`) - Enterprise directory integration (wrapper over LdapRecord) - Search-first authentication pattern: searches for user by username → gets DN → binds with DN + password @@ -368,4 +371,4 @@ Key modules: --- -*Last updated: March 22, 2026* +*Last updated: 2026-07-28 (Feature 053 — Memory Profiler middleware/services/admin surface)* diff --git a/docs/specs/4-architecture/open-questions.md b/docs/specs/4-architecture/open-questions.md index 646a0fd8f60..a222a7566ef 100644 --- a/docs/specs/4-architecture/open-questions.md +++ b/docs/specs/4-architecture/open-questions.md @@ -6,6 +6,14 @@ Track unresolved high- and medium-impact questions here. Remove each row as soon | Question ID | Feature | Priority | Summary | Status | Opened | Updated | |-------------|---------|----------|---------|--------|--------|---------| +| ~~Q-053-01~~ | 053 – Memory Profiler | High | `memprof` semantics under Laravel Octane/FrankenPHP (default runtime) — is process-global profiling state per-request or does it leak across requests handled by the same persistent worker? | Resolved (A – ship with banner; validation attempt found `memprof` cannot even install on this image, ZTS incompatible; superseded by `spx`, empirically verified correct under Octane — ADR-0008) | 2026-07-28 | 2026-07-28 | +| ~~Q-053-02~~ | 053 – Memory Profiler | High | Flame-graph SVG rendering requires an external `pprof`/`google-pprof` + Graphviz `dot` toolchain beyond the `memprof` extension itself — how should this be delivered? | Superseded — `memprof` (and the pprof/Graphviz SVG pipeline built around it) abandoned entirely after the engine pivot to `spx` (see Q-053-05); `pprof`/`google-pprof`/Graphviz were removed from the Dockerfile again | 2026-07-28 | 2026-07-28 | +| ~~Q-053-03~~ | 053 – Memory Profiler | Medium | Retention policy for `storage/profiling` — every enabled request writes a new trace; how is unbounded disk growth prevented? | Resolved (A – count-based cap, `MEMORY_PROFILER_MAX_TRACES` default 200) | 2026-07-28 | 2026-07-28 | +| ~~Q-053-04~~ | 053 – Memory Profiler | Medium | Profiling scope — always-on for every request while the flag is enabled, or a per-request opt-in trigger to avoid profiling every single request in a shared/staging environment? | Resolved (A – always-on while the flag is enabled, no per-request trigger in v1) | 2026-07-28 | 2026-07-28 | +| ~~Q-053-05~~ | 053 – Memory Profiler | High | `memprof` confirmed impossible to bundle (ZTS incompatibility) — what alternative memory-profiling engine should replace it? | Resolved (`spx` — NoiseByNorthwest/php-spx; ZTS support verified empirically by compiling it against the exact base image) | 2026-07-28 | 2026-07-28 | +| ~~Q-053-06~~ | 053 – Memory Profiler | High | XHProf-family tools (`tideways_xhprof`, modern `xhprof` PECL fork) are ZTS-compatible, but is their `mu`/`pmu` usage-delta metric sufficient for the actual goal ("hunt leaks")? | Resolved — No (user correction): usage deltas don't distinguish freed from leaked memory; `spx`'s allocation/free byte+count metrics (`zmac`/`zmab`/`zmfc`/`zmfb`) were chosen instead | 2026-07-28 | 2026-07-28 | +| ~~Q-053-07~~ | 053 – Memory Profiler | High | `spx` produces no SVG/pprof output for web requests (only its own proprietary JSON report for its bundled JS viewer) — how should the admin page render a trace? | Resolved (embed/link to SPX's own bundled analysis screen — reversed from an initial pivot to "write a JSON→pprof converter", then reconsidered back to embedding once Q-053-08's access-control trade-off was accepted) | 2026-07-28 | 2026-07-28 | +| ~~Q-053-08~~ | 053 – Memory Profiler | High | SPX's own analysis screen is intercepted by the extension before Laravel's router runs, so it can't be gated by the `owner` middleware the normal way — how should access be restricted? | Resolved (A – SPX's own `spx.http_key` + `spx.http_ip_whitelist`; user judged this "secure enough") | 2026-07-28 | 2026-07-28 | | ~~Q-051-01~~ | 051 – v8 Admin Setup Page | High | Architectural mechanism for letting v8 show its own "no admin" page instead of the Blade redirect | Resolved (A – new route exempted from `admin_user:set`) | 2026-07-26 | 2026-07-26 | | ~~Q-051-02~~ | 051 – v8 Admin Setup Page | Medium | Should admin-creation logic be extracted into a shared Action reused by the legacy Blade controller and the new API endpoint? | Resolved (A – shared Action) | 2026-07-26 | 2026-07-26 | | ~~Q-051-03~~ | 051 – v8 Admin Setup Page | Medium | Post-success navigation — auto-redirect with toast vs. a distinct success screen | Resolved (A – toast + auto-redirect) | 2026-07-26 | 2026-07-26 | @@ -4064,3 +4072,295 @@ Default value: `skip`. This gives the admin explicit control over the trade-off **Resolved:** 2026-06-28 **Resolution:** Question no longer applies since `cover_id` stays per-model (Q-046-01 → B). `TagAlbum` defines its own `cover()` HasOne relationship and eager-loads it via `$with`. `Album` is unchanged. Encoded in FR-046-02, NFR-046-04. + +--- + +### ~~Q-053-01~~ · `memprof` semantics under Laravel Octane/FrankenPHP ✅ RESOLVED + +**Status:** Resolved — **Option A** (ship with a documented risk banner; validate empirically as part of the feature's own tasks) +**Feature:** 053 – Memory Profiler +**Priority:** High +**Opened:** 2026-07-28 +**Resolved:** 2026-07-28 + +**Resolution:** Ship Feature 053 under the default Octane/FrankenPHP runtime without blocking or hard-disabling. A persistent banner appears on `/admin/profiler` when Octane is detected (UI-053-05), and the how-to guide documents the situation prominently. Recorded in **ADR-0008** (`docs/specs/6-decisions/ADR-0008-memory-profiler-octane-risk.md`) since this is an architecturally significant risk trade-off for a cross-cutting global middleware. + +**Update (same day, via T-053-24):** the planned empirical validation ("run under Octane, hit the same route twice, diff the dumps") turned out to be moot. While implementing Q-053-02's Dockerfile bundling, adding `memprof` to the official image's `install-php-extensions` step failed the build outright with `#error "ZTS build not supported (yet)"` — the base image's PHP is a ZTS build (required by FrankenPHP), and `memprof`'s current release does not support ZTS at all ([arnaud-lb/php-memory-profiler#24](https://github.com/arnaud-lb/php-memory-profiler/issues/24), unresolved upstream). So the original question ("does memprof behave correctly across requests in the same Octane worker?") is superseded by a stronger, confirmed fact: **`memprof` cannot run under the official image at all**, regardless of request semantics. ADR-0008 has been amended to record this as a confirmed incompatibility rather than an unverified risk; the banner/how-to wording now states it plainly. + +**Spec impact:** NFR-053-06, UI-053-05, Non-Goals; ADR-0008. + +**Question** +Lychee's default production runtime (`Dockerfile`'s `CMD`, and `deploy-worker-mode.md`'s "Web mode (default): Run FrankenPHP/Octane web server") keeps the PHP process — and therefore any process-global extension state — alive across many requests inside the same worker. `memprof`'s documented API (`memprof_enable()`/`memprof_disable()`/`memprof_dump_pprof()`) is written against a traditional per-request PHP-FPM/CLI-script lifecycle; the upstream project does not document Octane/persistent-worker behaviour at all. It is unknown whether calling `memprof_enable()` at the start of request N and `memprof_disable()`+dump at the end of request N correctly scopes the profile to *only* request N's allocations, or whether it also captures residual/leaked memory from requests 1..N-1 handled by the same worker (which would make every trace misleading, especially for a long-lived worker). + +--- + +#### 🅰️ (**recommended**) Option A – Ship with a documented risk banner; validate empirically as a task + +- **Idea:** Build the feature as specified (FR-053-01..07). Add a visible banner on the `/admin/profiler` listing page when the app is detected running under Octane, and a prominent caveat in the new how-to guide, both stating that trace accuracy under Octane is unverified. Add a manual (non-automated) task — run the same route twice inside one Octane worker with the flag on, diff the two dumps — and record the real-world observation in the how-to guide once available. If the observation shows contamination, file a follow-up ADR/feature to fix or restrict the behaviour. +- **Spec impact:** NFR-053-06, UI-053-05, T-053-24 already encode this in the drafted spec/plan/tasks. +- **Pros:** + - ✅ Ships the requested feature now instead of blocking on a potentially lengthy compatibility investigation. + - ✅ Turns the risk into a concrete, scheduled validation step instead of silent hope. + - ✅ Matches how this codebase already treats other opt-in diagnostic tooling (XHProf's how-to guide carries similar "know what you're doing" caveats). +- **Cons:** + - ❌ The feature could ship and be actively misleading in the *default* deployment mode until the manual check happens and, if needed, a fix lands. + +--- + +#### 🅱️ Option B – Hard-disable the feature under Octane + +- **Idea:** Add an Octane-detection guard (e.g. `app()->bound('octane')` or equivalent) directly inside `MemoryProfiler::handle()`; if detected, the middleware always no-ops and the admin page shows "unavailable while running under Octane" instead of a listing, regardless of the feature flag. +- **Spec impact:** New FR/S rows for the hard-disable path; T-053-08 gains an Octane-detection branch; T-053-24 becomes a permanent regression test instead of a one-off manual check. +- **Pros:** + - ✅ Never ships a misleading trace to a user running the default runtime. +- **Cons:** + - ❌ Since Octane is the *default* runtime, this could make the feature unusable out-of-the-box for most installs, defeating the purpose of building it at all, unless the operator also knows to run a non-Octane mode specifically to profile. + - ❌ Requires reliable Octane detection, which is itself an extra piece of untested logic. + +--- + +#### 🅲 Option C – Spike/validate first, before finalizing scope + +- **Idea:** Before writing any implementation code, run a small standalone script under `octane:start` that calls `memprof_enable()`/allocates memory/`memprof_disable()`+dumps twice in a row inside the same worker, and inspect whether the second dump includes the first request's allocations. Use the result to decide between A and B up front. +- **Spec impact:** Delays plan/tasks execution start (already gated on this question) by the length of the spike, but produces a definitive answer instead of a documented assumption. +- **Pros:** + - ✅ Removes the uncertainty before any code is written, rather than after. +- **Cons:** + - ❌ Requires the `memprof` extension to be installed in this environment to run the spike at all — likely not currently installed here (opt-in system extension), so the spike itself may be blocked pending an environment change. + - ❌ Slower to first value than A. + +--- + +**Next action** +Confirm 🅰️ vs 🅱️ vs 🅲 with the user. Absent a preference, proceed with 🅰️ (already reflected in the drafted spec/plan/tasks) since it ships the feature while still producing hard evidence via T-053-24. + +--- + +### ~~Q-053-02~~ · Flame-graph SVG rendering toolchain ✅ RESOLVED (then SUPERSEDED) + +> **Superseded 2026-07-28.** Everything below (the pprof/`google-pprof`/Graphviz SVG pipeline built around `memprof`) was abandoned once `memprof` itself was confirmed impossible to bundle and the engine pivoted to `spx` (Q-053-05). `spx` cannot produce pprof/SVG output for web requests at all, so this whole rendering approach no longer applies — see Q-053-07 for the final viewing-model decision (link to SPX's own bundled analysis screen) and `Dockerfile`, which no longer installs `google-perftools`/`graphviz`. Kept below for historical context only. + +**Status:** Resolved — **Option A** (render SVG server-side on demand; `pprof`/`google-pprof` + Graphviz bundled into the production `Dockerfile`; `memprof` itself confirmed impossible to bundle) +**Feature:** 053 – Memory Profiler +**Priority:** High +**Opened:** 2026-07-28 +**Resolved:** 2026-07-28 (amended twice same day) + +**Resolution:** User confirmed Option A's server-side rendering approach. Initial resolution bundled only `google-perftools` (providing the `google-pprof` binary) and `graphviz` into the production `Dockerfile`'s `apt-get install` step, leaving the `memprof` PHP extension itself manually-installed (mirroring XHProf). **First amendment:** the user asked for `memprof` to also be bundled — `libjudy-dev` (memprof's build dependency) and `memprof` were added to the `Dockerfile`. **Second amendment (same session):** rebuilding the image with this change failed deterministically with `#error "ZTS build not supported (yet)"`. Root cause confirmed: the base image (`dunglas/frankenphp:...-trixie`) ships a **ZTS** PHP build (`PHP 8.5.8 (ZTS)`, verified via `docker run ... php -v`) because FrankenPHP requires ZTS for its worker model; `memprof`'s current mainline release (3.1.0) explicitly refuses to compile against ZTS builds. This is a long-standing, unresolved upstream limitation — see [arnaud-lb/php-memory-profiler#24](https://github.com/arnaud-lb/php-memory-profiler/issues/24) (open since the extension's early history; community PRs #7/#25/#26 attempted ZTS support but were never merged). The `memprof` + `libjudy-dev` `Dockerfile` addition was reverted. `App\Services\Profiling\MemprofRecorder::isAvailable()`'s `function_exists()` guard already handles this correctly (no-op when absent) and needed no code change. `MEMORY_PROFILER_PPROF_BIN` still defaults to `google-pprof` (the Debian/Ubuntu package's binary name). Verified empirically: `docker build .` with only `google-perftools`+`graphviz` succeeds, with `google-pprof --version` and `dot -V` both working in the built image. + +**Spec impact:** FR-053-01, FR-053-04, NFR-053-05, NFR-053-06, Non-Goals; plan.md Scope Alignment + I6b; tasks.md T-053-19a, T-053-24; [ADR-0008](../6-decisions/ADR-0008-memory-profiler-octane-risk.md) (amended). + +**Question** +The originally-supplied code sample assumed a `dumpToFile()`/`dumpToDot()` API that does not exist. The real `memprof` extension only writes callgrind or pprof-format dumps (`memprof_dump_callgrind()`, `memprof_dump_pprof()`); neither is an SVG. Callgrind format targets desktop GUI tools (KCacheGrind/QCacheGrind) with no web/SVG output at all. Pprof format can be turned into an SVG via the external `pprof`/`google-pprof` CLI (`pprof --svg file.heap`), which itself shells out to Graphviz's `dot` binary — meaning **two more system-level dependencies**, beyond the `memprof` extension, are needed to satisfy "give the ability to open each trace as svg." + +--- + +#### 🅰️ (**recommended**) Option A – Document as optional dependencies; render SVG server-side on demand, with a graceful error state + +- **Idea:** Treat `pprof`/`google-pprof` + Graphviz exactly like the existing XHProf precedent (`enable-hprof.md`): documented, manually-installed, not bundled. The admin page's SVG route shells out to the configured binary (`MEMORY_PROFILER_PPROF_BIN`) at view time and caches the result. If the binaries are missing, show a clear error state (already drafted as FR-053-04/UI-053-04) instead of a 500. +- **Spec impact:** Already fully encoded in FR-053-04, NFR-053-05, I6/T-053-16..19, and the new how-to guide (T-053-22). +- **Pros:** + - ✅ Reuses a real, well-tested external tool instead of reimplementing flame-graph rendering. + - ✅ Matches existing project convention for optional profiling tooling (XHProf). + - ✅ Small implementation surface (shell-out + cache). +- **Cons:** + - ❌ A third opt-in system dependency to document and troubleshoot (extension + pprof + Graphviz). + - ❌ Requires `exec`/`proc_open`-style shell-out from PHP, which needs care around argument escaping (mitigated by never interpolating user input into the command — the `{trace}` parameter is resolved to a filesystem path via an allow-list, never passed as a raw shell argument from the URL). + +--- + +#### 🅱️ Option B – Raw dump download only, no server-side SVG rendering + +- **Idea:** The admin page offers a "download .pprof" link per trace; the operator runs `pprof --web` (or similar) on their own machine. No shell-out from the PHP process at all. +- **Spec impact:** Drops FR-053-04 (SVG-in-browser) entirely; UI mock-up loses the SVG view; simplifies I6 to a single download route. +- **Pros:** + - ✅ Zero new server-side system dependency, zero shell-out risk. + - ✅ Simplest to implement and maintain. +- **Cons:** + - ❌ Does not satisfy the explicit ask ("give the ability to open each trace as svg" — implying in-browser, not a manual local step). + +--- + +#### 🅲 Option C – Pure-PHP/JS flame-graph rendering (no external binaries) + +- **Idea:** Parse `memprof_dump_array()`'s nested tree structure directly in PHP and render a Brendan-Gregg-style flame graph as inline SVG (or via a small bundled JS renderer) without shelling out to `pprof`/Graphviz at all. +- **Spec impact:** Replaces the pprof-format dump + `pprof` CLI dependency with a custom renderer; removes NFR-053-05's Graphviz/pprof documentation need but adds a nontrivial from-scratch rendering component. +- **Pros:** + - ✅ No new system-level binary dependencies at all — fully self-contained, fits the offline-only ethos even more tightly. +- **Cons:** + - ❌ Meaningfully larger implementation effort (flame-graph layout algorithm) for a diagnostic tool that is only used by the site owner, occasionally. + - ❌ Reinvents a wheel that `pprof`/Graphviz already solve robustly. + +--- + +**Next action** +Confirm 🅰️ vs 🅱️ vs 🅲 with the user; 🅰️ is already reflected in the drafted spec/plan/tasks as the working assumption. + +--- + +### ~~Q-053-03~~ · Retention policy for `storage/profiling` ✅ RESOLVED + +**Status:** Resolved — **Option A** (count-based cap with automatic pruning) +**Feature:** 053 – Memory Profiler +**Priority:** Medium +**Opened:** 2026-07-28 +**Resolved:** 2026-07-28 + +**Resolution:** `MEMORY_PROFILER_MAX_TRACES` (default 200) bounds `storage/profiling`; `php artisan lychee:profiler:prune` deletes the oldest trace pairs beyond the cap, runnable manually, from an admin-page button, or on a daily schedule. Encoded in FR-053-07, CLI-053-01, plan I7, tasks T-053-20/21. + +**Spec impact:** FR-053-07, CLI-053-01. + +**Question** +If the middleware writes a trace pair for every request while the feature is enabled, `storage/profiling` grows without bound on any busy or long-lived install. The user's brief didn't specify a retention policy. How should this be bounded? + +--- + +#### 🅰️ (**recommended**) Option A – Count-based cap, prune oldest first + +- **Idea:** Configurable `MEMORY_PROFILER_MAX_TRACES` (default 200). A console command (`lychee:profiler:prune`) — runnable manually, from a button on the admin page, or on a daily schedule — deletes the oldest trace pairs once the count is exceeded. +- **Spec impact:** Already encoded as FR-053-07, CLI-053-01, I7/T-053-20/21. +- **Pros:** + - ✅ Predictable worst-case disk usage regardless of traffic volume. + - ✅ Simple to reason about and test. +- **Cons:** + - ❌ A traffic spike could rotate out traces the owner wanted to keep, faster than expected. + +--- + +#### 🅱️ Option B – Age-based cap + +- **Idea:** Configurable `MEMORY_PROFILER_MAX_AGE_DAYS` (e.g. 7); scheduled daily job deletes trace pairs older than that. +- **Spec impact:** Same shape as Option A but keyed on `created_at` instead of count; T-053-20/21 change their assertion from "newest N" to "younger than X days." +- **Pros:** + - ✅ More intuitive framing for a debugging tool ("traces from the last week"). +- **Cons:** + - ❌ Disk usage is no longer bounded independent of traffic — a very busy install could still fill the disk within the retention window. + +--- + +#### 🅲 Option C – No automatic cleanup in v1 + +- **Idea:** Ship without any pruning; document the risk in the how-to guide and rely on the operator to manage `storage/profiling` manually (or leave the feature flag off outside of active debugging sessions). +- **Spec impact:** Drops FR-053-07, CLI-053-01, I7 entirely; smaller feature surface. +- **Pros:** + - ✅ Least implementation effort. +- **Cons:** + - ❌ Real risk of an operator leaving the flag on and silently filling the disk — the failure mode is a full outage (disk-full), not merely a UI inconvenience. + +--- + +**Next action** +Confirm 🅰️ vs 🅱️ vs 🅲 with the user; 🅰️ is already reflected in the drafted spec/plan/tasks as the working assumption. + +--- + +### ~~Q-053-04~~ · Profiling scope — always-on vs. opt-in trigger ✅ RESOLVED + +**Status:** Resolved — **Option A** (always-on for every request while the feature flag is enabled) +**Feature:** 053 – Memory Profiler +**Priority:** Medium +**Opened:** 2026-07-28 +**Resolved:** 2026-07-28 + +**Resolution:** No per-request opt-in trigger or sampling rate in v1 — while `MEMORY_PROFILER_ENABLED=true`, every request through the global middleware is profiled, matching the literal brief. A narrower per-request trigger (Option B) is deferred to the Follow-ups/Backlog if always-on proves too noisy in practice. Encoded in FR-053-01, Non-Goals. + +**Spec impact:** FR-053-01, Non-Goals. + +**Question** +The brief says the middleware "starts the profiler at the beginning of the request and ends at the end" — read literally, this means *every* request is profiled while the flag is on, with no way to scope it down further. `memprof` itself natively supports a narrower per-request opt-in convention (`MEMPROF_PROFILE` GET/POST parameter or env var) that the brief doesn't mention. Should Lychee's middleware support that narrower trigger too, so the flag can be left on in a shared/staging environment without profiling literally every single request (including polling/health-check traffic)? + +--- + +#### 🅰️ (**recommended**) Option A – Always-on for every request while the flag is enabled + +- **Idea:** Build exactly what was asked: while `MEMORY_PROFILER_ENABLED=true`, every request through the global middleware is profiled, full stop. No per-request trigger in v1. +- **Spec impact:** Already encoded as the spec's Non-Goals ("No per-request opt-in trigger... in v1") and FR-053-01. +- **Pros:** + - ✅ Matches the literal instruction exactly, simplest to reason about and test. + - ✅ Nothing to configure beyond the single on/off flag. +- **Cons:** + - ❌ Combined with Q-053-03's retention policy, could mean fast trace turnover on busy installs, and profiling overhead (however small when the extension itself is active, per NFR-053-01's flag-off case only) applies to *every* request including health checks/asset requests, not just the ones the operator actually cares about. + +--- + +#### 🅱️ Option B – Support both always-on and a narrower opt-in trigger + +- **Idea:** Keep the always-on mode from Option A as the default, but also let the middleware check for a request-level trigger (matching `memprof`'s own convention, e.g. a `MEMORY_PROFILER=1` header/query param) so an operator could, in principle, leave the feature flag on in a shared environment and only capture the specific requests they're debugging. +- **Spec impact:** Adds a new FR + config option (`MEMORY_PROFILER_MODE=always|trigger`); expands I4/T-053-08's scope and test matrix. +- **Pros:** + - ✅ More flexible for a shared/staging deployment where "profile everything all the time" is undesirable but the operator still wants the feature available. +- **Cons:** + - ❌ Expands v1 scope beyond what was literally asked; introduces a second configuration axis to document and test. + - ❌ A GET/POST-triggered profiling toggle on a *public-facing* endpoint is itself a minor attack-surface consideration (an anonymous visitor could force profiling on for their own request) that would need its own guardrail. + +--- + +**Next action** +Confirm 🅰️ vs 🅱️ with the user; 🅰️ is already reflected in the drafted spec/plan/tasks as the working assumption (and is the simpler, more literal reading of the original request). + +--- + +### ~~Q-053-05~~ · Alternative memory-profiling engine after `memprof` was confirmed impossible ✅ RESOLVED + +**Status:** Resolved — **`spx`** (NoiseByNorthwest/php-spx) +**Feature:** 053 – Memory Profiler +**Priority:** High +**Opened:** 2026-07-28 +**Resolved:** 2026-07-28 + +**Question:** With `memprof` confirmed unable to compile against the official image's ZTS PHP build (Q-053-02), what should replace it? + +**Resolution:** Several alternatives were researched (not assumed) before choosing: `tideways_xhprof`/modern `xhprof` PECL fork (ZTS-compatible but usage-only metrics, see Q-053-06), `php-meminfo` (best conceptual fit for leak-hunting in a persistent worker, but official PHP support tops out at 8.0 — not viable for Lychee's PHP 8.4/8.5 requirement, and PHP 8.1+ support is only an open unmerged PR), `excimer` (Wikimedia, battle-tested, but CPU/wall-time sampling, not memory-allocation tracking), Blackfire (real memory profiling but SaaS-by-default, conflicts with the offline-only requirement). `spx` was chosen and its ZTS support was **verified empirically** (not just read from its README): built and loaded successfully against the exact `dunglas/frankenphp:...-trixie` base image via `install-php-extensions spx`. + +**Spec impact:** Overview, FR-053-01, NFR-053-05; `Dockerfile` (`spx` + `zlib1g-dev`); ADR-0008. + +--- + +### ~~Q-053-06~~ · Are XHProf-family usage-delta metrics sufficient for hunting leaks? ✅ RESOLVED + +**Status:** Resolved — **No** (user correction) +**Feature:** 053 – Memory Profiler +**Priority:** High +**Opened:** 2026-07-28 +**Resolved:** 2026-07-28 + +**Question:** `tideways_xhprof` and the modern PECL `xhprof` fork both support ZTS and expose a `XHPROF_FLAGS_MEMORY` mode (`mu`/`pmu` per function). Is that enough to satisfy the actual underlying need — hunting memory leaks? + +**Resolution:** The user explicitly corrected this line of investigation: "but it is not enough to hunt for leaks." `mu`/`pmu` report memory-usage deltas (allocated during a function's execution), which includes memory that is later properly freed — it doesn't distinguish "used and released" from "leaked." `spx` was chosen partly because it exposes allocation *and* free counts/bytes per call node (`zmac`/`zmab`/`zmfc`/`zmfb`), plus process RSS (`mor`, which SPX's own docs describe as "useful to highlight a memory leak"), which is a materially closer fit for the actual goal. + +**Spec impact:** Overview ("Why not an XHProf-family tool" section); NFR referencing `spx`'s metric selection (`spx.http_profiling_metrics`). + +--- + +### ~~Q-053-07~~ · Trace-viewing mechanism when `spx` produces no SVG/pprof for web requests ✅ RESOLVED + +**Status:** Resolved — **Embed/link to SPX's own bundled analysis screen** (reversed once, then reconfirmed) +**Feature:** 053 – Memory Profiler +**Priority:** High +**Opened:** 2026-07-28 +**Resolved:** 2026-07-28 + +**Question:** `spx` only supports its own proprietary `full` JSON report type for web requests (meant for its own bundled JS/canvas viewer) — no SVG, no pprof-compatible format. How should Lychee's admin page let the owner view a trace? + +**Resolution (with its own back-and-forth, recorded for accuracy):** +1. First presented as a 3-way choice: (A) write a JSON→pprof converter and reuse the already-built `PprofRenderer`/SVG pipeline, (B) embed SPX's own bundled web UI, (C) write a from-scratch SVG flame-graph renderer. User initially chose **(B) embed SPX's own bundled web UI**. +2. Implementing (B) surfaced a security-relevant fact (Q-053-08): SPX's own UI bypasses Laravel's routing entirely, so it can't be gated by the `owner` middleware. When this was explained, the user initially asked to **reconsider option (A)** specifically to keep everything inside Laravel's `OwnerOnly` gate. +3. Before implementing (A), the user reviewed SPX's own mitigations (`spx.http_key` + `spx.http_ip_whitelist`) and said **"That is secure enough for me"** — reverting the decision back to **(B) embed/link to SPX's own bundled analysis screen**, which is what was ultimately implemented. + +**Spec impact:** FR-053-04, Non-Goals, UI mock-up; `ProfilerController::buildSpxAnalysisUrl()`; ADR-0008 (Alternatives Considered). + +--- + +### ~~Q-053-08~~ · Access control for SPX's own analysis screen (bypasses Laravel routing) ✅ RESOLVED + +**Status:** Resolved — **Option A** (SPX's own `spx.http_key` + `spx.http_ip_whitelist`, judged "secure enough") +**Feature:** 053 – Memory Profiler +**Priority:** High +**Opened:** 2026-07-28 +**Resolved:** 2026-07-28 + +**Question:** SPX's own analysis screen is triggered by any URL carrying `?SPX_UI_URI=...&SPX_KEY=...` query params, intercepted by the extension at `PHP_RINIT_FUNCTION` — before Laravel's kernel or router ever runs. This means Lychee's `OwnerOnly` middleware and owner-only Blade gate cannot protect that specific request at all. How should access to it be restricted? + +**Resolution:** Three options were presented: (A) rely on SPX's own `spx.http_key` (long random secret, no shipped default) + `spx.http_ip_whitelist`/`spx.http_trusted_proxies`; (B) reconsider the JSON→pprof converter approach instead, to avoid exposing SPX's UI at all (see Q-053-07's back-and-forth); (C) accept the gap with just a secret key, no IP whitelist, document the caveat. The user confirmed Option A explicitly: **"That is secure enough for me."** `.env.example` ships no default for `MEMORY_PROFILER_SPX_KEY`, forcing an explicit, presumably-random value; `MEMORY_PROFILER_SPX_IP_WHITELIST` is documented as strongly recommended. + +**Spec impact:** NFR-053-04; `.env.example`; how-to guide ("Securing the analysis screen" section); ADR-0008 (Security / Privacy Impact). diff --git a/docs/specs/4-architecture/roadmap.md b/docs/specs/4-architecture/roadmap.md index b8555e7de9a..7f01d3d4a47 100644 --- a/docs/specs/4-architecture/roadmap.md +++ b/docs/specs/4-architecture/roadmap.md @@ -6,6 +6,7 @@ High-level planning document for Lychee features and architectural initiatives. | Feature ID | Name | Status | Priority | Assignee | Started | Updated | Progress | |------------|------|--------|----------|----------|---------|---------|----------| +| 053 | Memory Profiler | Testing | P3 | User | 2026-07-28 | 2026-07-28 | Corrects the initial brief twice: (1) `arnaud-lb/php-memory-profiler` (`memprof`) is a PECL extension, not the OOP Composer snippet supplied; (2) `memprof` was then found **impossible to bundle** — the official image's PHP is a ZTS build (required by FrankenPHP) and `memprof`'s mainline release does not compile against ZTS at all (`docker build` fails deterministically; tracked upstream, unresolved since ~2016 at [php-memory-profiler#24](https://github.com/arnaud-lb/php-memory-profiler/issues/24)). **Pivoted to [`spx`](https://github.com/NoiseByNorthwest/php-spx)** (NoiseByNorthwest/php-spx): ZTS-compatible (verified — compiles and loads on the official image), tracks allocation/free bytes (not just usage, needed for leak-hunting per user correction). Global terminable middleware (`App\Http\Middleware\MemoryProfiler`) bounds a manual `spx_profiler_start()`/`spx_profiler_stop()` span per request (chosen over SPX's own ini-only auto mode specifically for Octane/FrankenPHP correctness — **empirically verified**: two requests served by the same worker thread produced independently-correct, non-cumulative peak-memory readings), writing a JSON sidecar to `storage/profiling`. Owner-only Blade admin page `/admin/profiler` lists traces and links out to SPX's own bundled analysis screen for the call-graph — protected by SPX's own `spx.http_key`/`spx.http_ip_whitelist`, not Lychee's `owner_id` gate (an explicit, user-accepted trade-off, since SPX intercepts that request before Laravel's router runs). Count-based retention via `MEMORY_PROFILER_MAX_TRACES`. New `docker/scripts/06-configure-profiler.sh` writes SPX's `PHP_INI_SYSTEM` settings from env vars at container start. 8 open questions resolved (Q-053-01..08); **ADR-0008** records the full engine-selection history + Octane verification. `make phpstan` 0 errors, `php-cs-fixer` clean; full `php artisan test` suite in progress at time of writing. | | 051 | v8 Admin Setup Page | Testing | P2 | User | 2026-07-26 | 2026-07-26 | Implementation complete (T-051-01..12,15). New `CreateInitialAdmin` action shared by legacy Blade `SetUpAdminController` and new `AdminSetupController` (`POST /Admin::Setup`); `GET /setup-admin` route + `ToAdminSetter` branch on `nuxt_ui` flag (ADR-0007); v8 `AdminSetupPage.vue` + `admin-setup-service.ts`; `admin-setup` route added to shared `paths.ts` with v7 `Placeholder.vue` fallback; 22-locale translations. `php artisan test`: all green (incl. new `CreateInitialAdminTest`, `AdminSetupTest` x2). `make phpstan`: 0 errors on touched files. `npm run check`: clean. Q-051-05 (no JS test runner in this repo) resolved by the user (Option A — accept the gap, no dependency added). Manual browser verification not performed this session, to avoid mutating the dev environment; HTTP-level behaviour covered by feature tests instead. | | 049 | Migration to Nuxt UI | Planning | P2 | User | 2026-07-02 | 2026-07-03 | Spec/plan/tasks drafted; analysis gate passed. 48 tasks (T-049-00..45 incl. sub-tasks) across 15 phases. Builds Nuxt UI (`@nuxt/ui`, standalone Vue mode) as a **parallel tree** `resources/js/v8/**`, served by a second Vite entry (`app-v8.ts`) selected per-request by a `nuxt_ui` feature flag, at the **same routes** as the existing PrimeVue app (`resources/js/app.ts`, untouched until cutover) — supersedes the original in-place migration mechanism (Q-049-04, ADR-0006 amends ADR-0005). Icon parity via `@iconify-json/prime` (Q-049-02 A); ripple dropped entirely (Q-049-03 A); full scope tracked as one feature (Q-049-01 A). New v8-only seams: `useAppToast()`, `useConfirmDialog()`. Embed bundle out of scope. ADR-0005 + ADR-0006 recorded. | | 048 | Fix Multi-Group Permissions | Planning | P1 (bug fix) | LycheeOrg | 2026-07-01 | 2026-07-01 | Spec, plan, tasks drafted. 11 tasks across 7 increments. Fixes `BaseAlbumImpl::current_user_permissions()` using `Collection::first()` (order-dependent) instead of merging every matching `AccessPermission` row (direct-user + all groups) via boolean OR. Merged result returned as a new non-persistable DTO (`App\DTO\EffectiveAccessPermission`, `final readonly class`) instead of a synthetic `AccessPermission` model instance, so it cannot be mass-assigned/`save()`d by accident. Zero new DB queries (NFR-048-01). Q-048-01 resolved (Option A — merge everything, most-permissive-wins). ADR-0004 planned. | diff --git a/docs/specs/6-decisions/ADR-0008-memory-profiler-octane-risk.md b/docs/specs/6-decisions/ADR-0008-memory-profiler-octane-risk.md new file mode 100644 index 00000000000..6988590fdae --- /dev/null +++ b/docs/specs/6-decisions/ADR-0008-memory-profiler-octane-risk.md @@ -0,0 +1,76 @@ +# ADR-0008: Memory Profiler engine selection and Octane/FrankenPHP correctness + +- **Status:** Accepted +- **Date:** 2026-07-28 +- **Related features/specs:** Feature 053 (docs/specs/4-architecture/features/053-memory-profiler/spec.md) +- **Related open questions:** Q-053-01, Q-053-02, Q-053-05, Q-053-06, Q-053-07, Q-053-08 + +## Context + +Feature 053 adds an optional, request-scoped memory profiler. The initial brief requested the `memprof` PHP extension (`arnaud-lb/php-memory-profiler`). Its public API (`memprof_enable()`, `memprof_disable()`, `memprof_dump_pprof()`) is documented upstream against a traditional per-request PHP-FPM/CLI-script lifecycle. + +Lychee's **default** production runtime is Laravel Octane running on FrankenPHP — confirmed by the project's `Dockerfile` (`CMD ["php", "artisan", "octane:start", "--server=frankenphp", ...]`) and `docs/specs/2-how-to/deploy-worker-mode.md` ("Web mode (default): Run FrankenPHP/Octane web server"). Under Octane, a single PHP process/worker thread stays alive and serves many requests in sequence. + +Two separate problems emerged during implementation, in this order: + +1. **`memprof` cannot be installed on the official image at all.** Adding it to the `Dockerfile`'s `install-php-extensions` step failed the build with `memprof.c:49:9: error: #error "ZTS build not supported (yet)"`. Root cause, confirmed: the base image (`dunglas/frankenphp:...-trixie`) ships `PHP 8.5.8 (ZTS)` (verified via `docker run dunglas/frankenphp:1.12.4-php8.5-trixie php -v`) — FrankenPHP requires a ZTS (Zend Thread Safety) PHP build for its worker model. `memprof`'s mainline release explicitly refuses to compile against ZTS builds at all; this is a long-standing, unresolved upstream limitation ([arnaud-lb/php-memory-profiler#24](https://github.com/arnaud-lb/php-memory-profiler/issues/24), open since the extension's early history — community pull requests #7, #25, #26 attempted ZTS support over the years but none were merged). This is a **hard compile-time blocker**, not merely an unverified runtime risk. + +2. **Whatever replaces it must still behave correctly under Octane's persistent-worker model.** Even with a ZTS-compatible engine, it remained an open question whether "start profiling at the beginning of a request, stop at the end" correctly isolates memory per request when the *same OS thread* serves many logical requests in sequence, versus leaking/accumulating state across requests handled by that worker. + +Affected modules: application (new global `MemoryProfiler` middleware), infra/runtime (interaction with Octane's persistent-worker model, Docker image, container-start ini configuration), and the new owner-only admin UI that presents the resulting traces. + +## Decision + +**Engine: replace `memprof` with [`spx`](https://github.com/NoiseByNorthwest/php-spx).** Verified empirically (not merely read from documentation) that `spx` compiles and loads successfully against the exact ZTS base image (`docker build` + `php -m | grep spx`). `spx` also exposes allocation/free byte and count metrics (`zmac`/`zmab`/`zmfc`/`zmfb`) rather than only usage deltas, which is necessary for leak-hunting (usage-only tools like `tideways_xhprof`/the modern `xhprof` PECL fork were considered and rejected for this reason — see Q-053-06). + +**Capture model: manual `spx_profiler_start()`/`spx_profiler_stop()` spans, not SPX's own ini-only "always profiling" mode.** SPX's own documentation ("Handle long-living / daemon processes") explicitly recommends disabling automatic start (`spx.http_profiling_auto_start=0`) and manually bounding each unit of work for exactly this class of runtime (a single process/thread serving many logical units of work) — the same problem shape as an Octane worker. Lychee's `MemoryProfiler` middleware calls `spx_profiler_start()` in `handle()` and `spx_profiler_stop()` in `terminate()`. + +**This was verified empirically, not just asserted:** during implementation, the built image was run via FrankenPHP's own `frankenphp php-server` command with a minimal script, and sent two consecutive HTTP requests (allocating 1MB and 9MB respectively). Both requests were served by the **same** OS thread (`process_pid`/`process_tid` identical in both of SPX's resulting reports), yet each produced an **independently correct** `peak_memory_usage` (≈1.4MB and ≈9.4MB respectively — not cumulative, which would have shown ≈10.8MB on the second request). This directly confirms the manual-span approach isolates memory correctly per request even under Octane/FrankenPHP's worker model. + +**Viewing model: link out to SPX's own bundled analysis screen, rather than rendering a call-graph inside a Lychee-owned page.** SPX ships a pre-built vanilla-JS flame-graph/timeline viewer, reached via a documented URL pattern (`/?SPX_UI_URI=/report.html&SPX_KEY=&key=`) that the extension intercepts directly (see Security below). Two alternatives were considered and rejected — see Alternatives Considered. + +**Access control for that link is SPX's own (`spx.http_key` + optional `spx.http_ip_whitelist`), not Lychee's `owner_id` gate — an explicit, accepted trade-off**, because SPX intercepts the matching request at PHP's earliest hook (`PHP_RINIT_FUNCTION`), before Laravel's kernel or router ever runs; Laravel middleware (including `OwnerOnly`) cannot see or gate that specific request at all. The user was informed of this precisely and accepted it (Q-053-08), on the condition that `MEMORY_PROFILER_SPX_KEY` ships with no default value (must be explicitly set to a long random secret) and that the IP-whitelist option is documented prominently. + +## Consequences + +### Positive +- The feature ships with real diagnostic value, correctly, under Lychee's actual default production runtime — not merely for non-Octane deployments. +- The Octane-correctness claim is backed by a concrete, reproduced empirical test, not an assumption. +- `spx`'s allocation/free metrics are a better fit for leak-hunting than the usage-only alternatives that were considered. + +### Negative +- SPX's own analysis screen is reachable by anyone who knows (or brute-forces) `spx.http_key` and satisfies the IP whitelist, bypassing Lychee's own owner-only session-based gate entirely. This is a different, and arguably weaker (for a targeted attacker who already has network access), security model than the rest of Lychee's admin surface. Mitigated by requiring a long random key (no shipped default) and documenting the IP-whitelist option, but not eliminated. +- Two extension pivots occurred during implementation (`memprof` → attempted `spx` ini-only mode → `spx` manual-span mode), each discovered via empirical testing rather than upfront research; this cost implementation time but produced a materially more correct and better-verified result than shipping on the first (undocumented-behavior) assumption would have. + +## Alternatives Considered + +### Engine choice +- **Keep `memprof`, document as broken on the official image:** Rejected — this would ship a feature that can never work on Lychee's actual production runtime, defeating the point of "bundle it so it works out of the box." +- **`php-meminfo`:** Conceptually the best fit for leak-hunting in a persistent worker (dumps the live object graph for diffing over time), but official PHP version support tops out at 8.0; PHP 8.1+ support is only an open, unmerged upstream PR. Not viable for Lychee's PHP 8.4/8.5 requirement. +- **`tideways_xhprof` / modern `xhprof` PECL fork:** ZTS-compatible, but only expose usage deltas (`mu`/`pmu`) per function, not allocation-vs-free tracking — insufficient to distinguish "allocated and freed" from "leaked" (the user's explicit correction, Q-053-06). +- **Blackfire:** Real memory profiling, but SaaS by default (self-hosted is enterprise-only) — conflicts with Lychee's offline-only requirement. + +### Capture model +- **SPX's ini-only "always profiling" mode** (`spx.http_profiling_enabled=1`, default `auto_start=1`): Simpler (zero Laravel-side code), but relies on SPX's own automatic per-request start/stop, whose correctness under Octane's worker model was exactly the open question — rejected in favour of the manual-span pattern SPX's own docs recommend for this runtime shape, which was then empirically verified. + +### Viewing model +- **Write a JSON→pprof converter, reuse the original `PprofRenderer`/SVG pipeline:** Was the first choice after ruling out embedding SPX's UI (over the access-control concern, see below) — reconsidered and ultimately dropped a second time in favour of embedding, once the user judged SPX's own key+IP-whitelist protection "secure enough." +- **Write a from-scratch SVG flame-graph renderer:** Largest implementation effort (a real layout/rendering algorithm), rejected in favour of reusing SPX's own, already-built viewer. +- **Hard-disable SPX's own UI-interception mechanism entirely, keep captured data Laravel-only:** Would have required the JSON→pprof conversion (or equivalent) to have any viewer at all; not pursued once the user accepted SPX's own access-control model. + +## Security / Privacy Impact + +- Trace **capture** (FR-053-01) has no security impact beyond what the original design had: owner-only listing page, log-channel-only telemetry, no PII beyond `user_id`. +- Trace **viewing** (FR-053-04) has a materially different security model than the rest of Lychee's admin surface: SPX's own analysis screen is protected by `spx.http_key` (a shared secret, not a per-user session) and optionally `spx.http_ip_whitelist`, evaluated by the extension itself before Laravel ever sees the request. This is an explicit, user-accepted trade-off (Q-053-08), not an oversight — see NFR-053-04. `.env.example` ships no default `MEMORY_PROFILER_SPX_KEY`, forcing operators to set one explicitly. + +## Operational Impact + +- Operators must set both `MEMORY_PROFILER_ENABLED=true` and `MEMORY_PROFILER_SPX_KEY=` (a container restart is required either way, since `spx`'s ini settings are `PHP_INI_SYSTEM` and are written by `docker/scripts/06-configure-profiler.sh` at container start, not toggled by Laravel at request time). +- No monitoring/runbook changes; this is a diagnostic tool, not a production dependency — leaving it disabled (the default) has zero operational impact. + +## Links + +- Related spec sections: `docs/specs/4-architecture/features/053-memory-profiler/spec.md` (FR-053-01/04, NFR-053-04/05/06, Non-Goals) +- Related open questions: Q-053-01, Q-053-02, Q-053-05, Q-053-06, Q-053-07, Q-053-08 (`docs/specs/4-architecture/open-questions.md`) +- Related tasks: T-053-04..10 (`docs/specs/4-architecture/features/053-memory-profiler/tasks.md`) +- Upstream issues/docs: [arnaud-lb/php-memory-profiler#24](https://github.com/arnaud-lb/php-memory-profiler/issues/24) (ZTS build not supported); [NoiseByNorthwest/php-spx README](https://github.com/NoiseByNorthwest/php-spx#handle-long-living--daemon-processes) ("Handle long-living / daemon processes") diff --git a/phpstan.neon b/phpstan.neon index f27c0bad66b..dbf441fefe5 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -24,6 +24,8 @@ parameters: stubFiles: - phpstan/stubs/imageexception.stub - phpstan/stubs/image.stub + bootstrapFiles: + - phpstan/stubs/spx.stub ignoreErrors: - message: '#Cast to bool is forbidden.#' diff --git a/phpstan/stubs/spx.stub b/phpstan/stubs/spx.stub new file mode 100644 index 00000000000..c3fbe517d75 --- /dev/null +++ b/phpstan/stubs/spx.stub @@ -0,0 +1,24 @@ + + + + + + + + Memory Profiler + + + + +

Memory Profiler

+ + @if ($is_octane) +
+ ⚠ This server is running under Laravel Octane/FrankenPHP. Capture uses SPX's manual + start/stop spans specifically to remain correct under this runtime — see ADR-0008 for details. +
+ @endif + + @if (!$spx_key_configured) +
+ ⚠ MEMORY_PROFILER_SPX_KEY is not set. Traces will still be captured, but the + "view" link below cannot be built without it — see docs/specs/2-how-to/enable-memory-profiler.md. +
+ @endif + +
+ @csrf + +
+ + @if ($traces->isEmpty()) +
+ No traces collected yet. Make sure MEMORY_PROFILER_ENABLED=true and the + spx extension is loaded — see + docs/specs/2-how-to/enable-memory-profiler.md. +
+ @else + + + + + + + + + + + + + + + @foreach ($traces as $trace) + + + + + + + + + + + @endforeach + +
Captured atRouteMethodPathStatusDurationPeak mem
{{ $trace['meta']->created_at }}{{ $trace['meta']->route_name ?? '—' }}{{ $trace['meta']->method }}{{ $trace['meta']->path }}{{ $trace['meta']->status_code }}{{ number_format($trace['meta']->duration_ms, 1) }} ms{{ \Illuminate\Support\Number::fileSize($trace['meta']->peak_memory_bytes, 1) }} + @if ($trace['spx_url'] !== null) + open in SPX → + @else + + @endif +
+ @endif + + diff --git a/routes/web-admin-v2.php b/routes/web-admin-v2.php index 7adfc6dd031..3c85c53af78 100644 --- a/routes/web-admin-v2.php +++ b/routes/web-admin-v2.php @@ -23,3 +23,12 @@ Route::get('/phpinfo', [Admin\DiagnosticsController::class, 'phpinfo']); Route::get('/Update', [Admin\UpdateController::class, 'view'])->name('update'); +// Memory Profiler (Feature 053): owner-only, feature-flagged Blade admin +// surface. This file is registered before routes/web_v2.php in +// RouteServiceProvider::boot(), so these explicit routes are matched ahead +// of the Vue SPA's `/admin` catch-all. +Route::prefix('admin')->middleware(['login_required:always', 'feature:memory-profiler', 'owner'])->group(function (): void { + Route::get('profiler', [Admin\ProfilerController::class, 'index'])->name('admin.profiler.index'); + Route::post('profiler/prune', [Admin\ProfilerController::class, 'prune'])->name('admin.profiler.prune'); +}); + diff --git a/tests/Feature_v2/Profiling/ProfilerControllerTest.php b/tests/Feature_v2/Profiling/ProfilerControllerTest.php new file mode 100644 index 00000000000..a4daedf1a89 --- /dev/null +++ b/tests/Feature_v2/Profiling/ProfilerControllerTest.php @@ -0,0 +1,161 @@ +owner = User::factory()->may_administrate()->create(); + $this->other = User::factory()->may_administrate()->create(); + Configs::set('owner_id', $this->owner->id); + + foreach (Storage::disk(FileSystem::PROFILING)->allFiles() as $file) { + Storage::disk(FileSystem::PROFILING)->delete($file); + } + } + + protected function tearDown(): void + { + foreach (Storage::disk(FileSystem::PROFILING)->allFiles() as $file) { + Storage::disk(FileSystem::PROFILING)->delete($file); + } + parent::tearDown(); + } + + public function testFeatureDisabledReturns501(): void + { + config(['features.memory-profiler' => false]); + + $response = $this->actingAs($this->owner)->get('/admin/profiler'); + $this->assertStatus($response, 501); + } + + public function testUnauthenticatedIsRedirected(): void + { + config(['features.memory-profiler' => true]); + + $response = $this->get('/admin/profiler'); + $this->assertRedirect($response); + } + + public function testNonOwnerIsForbidden(): void + { + config(['features.memory-profiler' => true]); + + $response = $this->actingAs($this->other)->get('/admin/profiler'); + $this->assertForbidden($response); + } + + public function testOwnerSeesEmptyState(): void + { + config(['features.memory-profiler' => true]); + + $response = $this->actingAs($this->owner)->get('/admin/profiler'); + $this->assertOk($response); + $response->assertSee('No traces collected yet'); + } + + public function testOwnerSeesPopulatedListingWithSpxLink(): void + { + config([ + 'features.memory-profiler' => true, + 'features.memory-profiler-spx-key' => 'test-secret-key', + ]); + + Storage::disk(FileSystem::PROFILING)->put('lychee-20260728_101402_abc12345.json', json_encode([ + 'spx_report_key' => 'spx-full-20260728_101402-host-123-456', + 'route_name' => 'gallery.index', + 'method' => 'GET', + 'path' => 'gallery', + 'status_code' => 200, + 'duration_ms' => 12.3, + 'peak_memory_bytes' => 1024, + 'user_id' => $this->owner->id, + 'created_at' => '2026-07-28T10:14:02+00:00', + ])); + + $response = $this->actingAs($this->owner)->get('/admin/profiler'); + $this->assertOk($response); + $response->assertSee('gallery.index'); + $response->assertSee('200'); + $response->assertSee('SPX_KEY=test-secret-key', false); + $response->assertSee('key=spx-full-20260728_101402-host-123-456', false); + } + + public function testOwnerSeesPopulatedListingWithoutSpxKeyConfigured(): void + { + config([ + 'features.memory-profiler' => true, + 'features.memory-profiler-spx-key' => null, + ]); + + Storage::disk(FileSystem::PROFILING)->put('lychee-20260728_101402_abc12345.json', json_encode([ + 'spx_report_key' => 'spx-full-20260728_101402-host-123-456', + 'route_name' => 'gallery.index', + 'method' => 'GET', + 'path' => 'gallery', + 'status_code' => 200, + 'duration_ms' => 12.3, + 'peak_memory_bytes' => 1024, + 'user_id' => $this->owner->id, + 'created_at' => '2026-07-28T10:14:02+00:00', + ])); + + $response = $this->actingAs($this->owner)->get('/admin/profiler'); + $this->assertOk($response); + $response->assertSee('gallery.index'); + $response->assertDontSee('SPX_KEY=test-secret-key', false); + } + + public function testPruneRedirectsToIndex(): void + { + config(['features.memory-profiler' => true, 'features.memory-profiler-max-traces' => 0]); + + Storage::disk(FileSystem::PROFILING)->put('lychee-old.json', json_encode([ + 'spx_report_key' => 'spx-full-old', + 'route_name' => null, + 'method' => 'GET', + 'path' => 'foo', + 'status_code' => 200, + 'duration_ms' => 1.0, + 'peak_memory_bytes' => 1, + 'user_id' => null, + 'created_at' => '2026-07-28T10:00:00+00:00', + ])); + Storage::disk(FileSystem::PROFILING)->put('spx-full-old.json', '{}'); + Storage::disk(FileSystem::PROFILING)->put('spx-full-old.txt.gz', 'content'); + + $response = $this->actingAs($this->owner)->post('/admin/profiler/prune'); + $this->assertRedirect($response); + self::assertFalse(Storage::disk(FileSystem::PROFILING)->exists('lychee-old.json')); + self::assertFalse(Storage::disk(FileSystem::PROFILING)->exists('spx-full-old.json')); + } +} diff --git a/tests/Unit/Console/Profiling/PruneTracesTest.php b/tests/Unit/Console/Profiling/PruneTracesTest.php new file mode 100644 index 00000000000..cd5265918e7 --- /dev/null +++ b/tests/Unit/Console/Profiling/PruneTracesTest.php @@ -0,0 +1,71 @@ +allFiles() as $file) { + Storage::disk(FileSystem::PROFILING)->delete($file); + } + } + + protected function tearDown(): void + { + foreach (Storage::disk(FileSystem::PROFILING)->allFiles() as $file) { + Storage::disk(FileSystem::PROFILING)->delete($file); + } + parent::tearDown(); + } + + public function testCommandPrunesBeyondCap(): void + { + config(['features.memory-profiler-max-traces' => 1]); + $disk = Storage::disk(FileSystem::PROFILING); + + foreach (['a', 'b', 'c'] as $i => $basename) { + $spx_key = 'spx-full-' . $basename; + $disk->put('lychee-' . $basename . '.json', json_encode([ + 'spx_report_key' => $spx_key, + 'route_name' => null, + 'method' => 'GET', + 'path' => 'foo', + 'status_code' => 200, + 'duration_ms' => 1.0, + 'peak_memory_bytes' => 1, + 'user_id' => null, + 'created_at' => sprintf('2026-07-28T%02d:00:00+00:00', $i), + ])); + $disk->put($spx_key . '.json', '{}'); + $disk->put($spx_key . '.txt.gz', 'content'); + } + + $this->artisan('lychee:profiler:prune') + ->expectsOutputToContain('Removed 2 trace pair(s)') + ->assertExitCode(0); + + self::assertCount(3, Storage::disk(FileSystem::PROFILING)->allFiles()); + } +} diff --git a/tests/Unit/MemoryProfilerConfigTest.php b/tests/Unit/MemoryProfilerConfigTest.php new file mode 100644 index 00000000000..8d1a700b587 --- /dev/null +++ b/tests/Unit/MemoryProfilerConfigTest.php @@ -0,0 +1,40 @@ +available; + } + + public function start(): void + { + $this->start_calls++; + } + + public function stop(): ?string + { + $this->stop_calls++; + + return $this->next_report_key; + } +} + +class MemoryProfilerTest extends AbstractTestCase +{ + private FakeSpxRecorder $recorder; + + protected function setUp(): void + { + parent::setUp(); + $this->recorder = new FakeSpxRecorder(); + + foreach (Storage::disk(FileSystem::PROFILING)->allFiles() as $file) { + Storage::disk(FileSystem::PROFILING)->delete($file); + } + } + + protected function tearDown(): void + { + foreach (Storage::disk(FileSystem::PROFILING)->allFiles() as $file) { + Storage::disk(FileSystem::PROFILING)->delete($file); + } + parent::tearDown(); + } + + public function testNoOpWhenFeatureFlagOff(): void + { + config(['features.memory-profiler' => false]); + $middleware = new MemoryProfiler($this->recorder); + $request = Request::create('/foo', 'GET'); + + $middleware->handle($request, fn ($req) => new Response('ok')); + $middleware->terminate($request, new Response('ok')); + + self::assertSame(0, $this->recorder->start_calls); + self::assertCount(0, Storage::disk(FileSystem::PROFILING)->allFiles()); + } + + public function testNoOpWhenExtensionUnavailable(): void + { + config(['features.memory-profiler' => true]); + $this->recorder->available = false; + $middleware = new MemoryProfiler($this->recorder); + $request = Request::create('/foo', 'GET'); + + $middleware->handle($request, fn ($req) => new Response('ok')); + $middleware->terminate($request, new Response('ok')); + + self::assertSame(0, $this->recorder->start_calls); + self::assertCount(0, Storage::disk(FileSystem::PROFILING)->allFiles()); + } + + public function testCapturesTraceWhenEnabledAndAvailable(): void + { + config(['features.memory-profiler' => true]); + $middleware = new MemoryProfiler($this->recorder); + $request = Request::create('/foo/bar', 'POST'); + + $middleware->handle($request, fn ($req) => new Response('ok')); + $middleware->terminate($request, new Response('ok', 201)); + + self::assertSame(1, $this->recorder->start_calls); + self::assertSame(1, $this->recorder->stop_calls); + + $files = Storage::disk(FileSystem::PROFILING)->allFiles(); + self::assertCount(1, $files); + self::assertStringStartsWith('lychee-', $files[0]); + + $meta = json_decode(Storage::disk(FileSystem::PROFILING)->get($files[0]), true); + self::assertSame('POST', $meta['method']); + self::assertSame('foo/bar', $meta['path']); + self::assertSame(201, $meta['status_code']); + self::assertSame('spx-full-fake-key', $meta['spx_report_key']); + } + + public function testCapturesTraceWithNullReportKeyWhenSpxDidNotProduceOne(): void + { + config(['features.memory-profiler' => true]); + $this->recorder->next_report_key = null; + $middleware = new MemoryProfiler($this->recorder); + $request = Request::create('/foo', 'GET'); + + $middleware->handle($request, fn ($req) => new Response('ok')); + $middleware->terminate($request, new Response('ok')); + + $files = Storage::disk(FileSystem::PROFILING)->allFiles(); + self::assertCount(1, $files); + $meta = json_decode(Storage::disk(FileSystem::PROFILING)->get($files[0]), true); + self::assertNull($meta['spx_report_key']); + } + + public function testDumpFailureIsLoggedAndDoesNotThrow(): void + { + config(['features.memory-profiler' => true]); + + $broken_disk = \Mockery::mock(\Illuminate\Contracts\Filesystem\Filesystem::class); + $broken_disk->shouldReceive('put')->andThrow(new \RuntimeException('simulated disk failure')); + Storage::set(FileSystem::PROFILING, $broken_disk); + + $middleware = new MemoryProfiler($this->recorder); + $request = Request::create('/foo', 'GET'); + + Log::shouldReceive('error')->once()->with('memory_profiler.dump_failed', \Mockery::type('array')); + + $middleware->handle($request, fn ($req) => new Response('ok')); + $middleware->terminate($request, new Response('ok')); + + Storage::forgetDisk(FileSystem::PROFILING); + } +} diff --git a/tests/Unit/Middleware/OwnerOnlyTest.php b/tests/Unit/Middleware/OwnerOnlyTest.php new file mode 100644 index 00000000000..f8536cb8902 --- /dev/null +++ b/tests/Unit/Middleware/OwnerOnlyTest.php @@ -0,0 +1,77 @@ +may_administrate()->create(); + Configs::set('owner_id', $owner->id); + + $middleware = new OwnerOnly(resolve(ConfigManager::class)); + $request = Request::create('/admin/profiler'); + + $this->assertThrows( + fn () => $middleware->handle($request, fn () => 'ok'), + UnauthorizedException::class + ); + } + + public function testNonOwnerIsRejected(): void + { + $owner = User::factory()->may_administrate()->create(); + $other = User::factory()->may_administrate()->create(); + Configs::set('owner_id', $owner->id); + + $this->actingAs($other); + + $middleware = new OwnerOnly(resolve(ConfigManager::class)); + $request = Request::create('/admin/profiler'); + + $this->assertThrows( + fn () => $middleware->handle($request, fn () => 'ok'), + UnauthorizedException::class + ); + } + + public function testOwnerPassesThrough(): void + { + $owner = User::factory()->may_administrate()->create(); + Configs::set('owner_id', $owner->id); + + $this->actingAs($owner); + + $middleware = new OwnerOnly(resolve(ConfigManager::class)); + $request = Request::create('/admin/profiler'); + + self::assertSame('ok', $middleware->handle($request, fn () => 'ok')); + } +} diff --git a/tests/Unit/Services/Profiling/ProfilingDiskTest.php b/tests/Unit/Services/Profiling/ProfilingDiskTest.php new file mode 100644 index 00000000000..5154c318e53 --- /dev/null +++ b/tests/Unit/Services/Profiling/ProfilingDiskTest.php @@ -0,0 +1,43 @@ +path(''), '/')); + } + + public function testDiskCanWriteAndReadBack(): void + { + $disk = Storage::disk(FileSystem::PROFILING); + + $disk->put('unit-test-marker.txt', 'hello'); + self::assertTrue($disk->exists('unit-test-marker.txt')); + self::assertSame('hello', $disk->get('unit-test-marker.txt')); + $disk->delete('unit-test-marker.txt'); + } +} diff --git a/tests/Unit/Services/Profiling/TracePrunerTest.php b/tests/Unit/Services/Profiling/TracePrunerTest.php new file mode 100644 index 00000000000..e0906502811 --- /dev/null +++ b/tests/Unit/Services/Profiling/TracePrunerTest.php @@ -0,0 +1,96 @@ +allFiles() as $file) { + Storage::disk(FileSystem::PROFILING)->delete($file); + } + } + + protected function tearDown(): void + { + foreach (Storage::disk(FileSystem::PROFILING)->allFiles() as $file) { + Storage::disk(FileSystem::PROFILING)->delete($file); + } + parent::tearDown(); + } + + private function seedTrace(string $sidecar_basename, string $spx_report_key, string $created_at): void + { + $disk = Storage::disk(FileSystem::PROFILING); + $disk->put('lychee-' . $sidecar_basename . '.json', json_encode([ + 'spx_report_key' => $spx_report_key, + 'route_name' => null, + 'method' => 'GET', + 'path' => 'foo', + 'status_code' => 200, + 'duration_ms' => 1.0, + 'peak_memory_bytes' => 1, + 'user_id' => null, + 'created_at' => $created_at, + ])); + $disk->put($spx_report_key . '.json', '{"key":"' . $spx_report_key . '"}'); + $disk->put($spx_report_key . '.txt.gz', 'content'); + } + + public function testKeepsAllWhenUnderCap(): void + { + config(['features.memory-profiler-max-traces' => 5]); + + $this->seedTrace('a', 'spx-full-a', '2026-07-28T10:00:00+00:00'); + $this->seedTrace('b', 'spx-full-b', '2026-07-28T10:01:00+00:00'); + + $removed = (new TracePruner())->prune(); + + self::assertSame(0, $removed); + self::assertCount(6, Storage::disk(FileSystem::PROFILING)->allFiles()); + } + + public function testPrunesOldestBeyondCap(): void + { + config(['features.memory-profiler-max-traces' => 2]); + + $this->seedTrace('oldest', 'spx-full-oldest', '2026-07-28T09:00:00+00:00'); + $this->seedTrace('middle', 'spx-full-middle', '2026-07-28T10:00:00+00:00'); + $this->seedTrace('newest', 'spx-full-newest', '2026-07-28T11:00:00+00:00'); + + $removed = (new TracePruner())->prune(); + + self::assertSame(1, $removed); + + $disk = Storage::disk(FileSystem::PROFILING); + self::assertFalse($disk->exists('lychee-oldest.json')); + self::assertFalse($disk->exists('spx-full-oldest.json')); + self::assertFalse($disk->exists('spx-full-oldest.txt.gz')); + self::assertTrue($disk->exists('lychee-middle.json')); + self::assertTrue($disk->exists('spx-full-middle.json')); + self::assertTrue($disk->exists('lychee-newest.json')); + self::assertTrue($disk->exists('spx-full-newest.json')); + } +}