Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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) #
###################################################################
Expand Down
4 changes: 4 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@
bash \
gosu \
ghostscript \
zlib1g-dev \
# Update with respect to vulnerabilities detected with Trivy
libgssapi-krb5-2 \
libssh2-1t64 \
Expand All @@ -105,6 +106,7 @@
intl \
imagick \
redis \
spx \
&& apt-get clean -qy \
&& rm -rf /var/lib/apt/lists/*

Expand All @@ -121,7 +123,7 @@
COPY --from=node --chown=www-data:www-data /app/public/embed ./public/embed

# Ensure storage and bootstrap/cache are writable with minimal permissions
RUN mkdir -p storage/framework/cache \

Check failure on line 126 in Dockerfile

View workflow job for this annotation

GitHub Actions / 3️⃣ Dockerfile Lint

SC2086 info: Double quote to prevent globbing and word splitting.
storage/framework/sessions \
storage/framework/views \
storage/logs \
Expand Down Expand Up @@ -151,6 +153,7 @@
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

Expand All @@ -160,6 +163,7 @@
/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 \
Expand Down
39 changes: 39 additions & 0 deletions app/Console/Commands/Profiling/PruneTraces.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

/**
* SPDX-License-Identifier: MIT
* Copyright (c) 2017-2018 Tobias Reich
* Copyright (c) 2018-2026 LycheeOrg.
*/

namespace App\Console\Commands\Profiling;

use App\Services\Profiling\TracePruner;
use Illuminate\Console\Command;

/**
* CLI-053-01: prune the oldest memory-profiler trace pairs beyond the
* configured retention cap (FR-053-07). Also callable from the schedule
* (`app/Console/Kernel.php`) and from the admin page's "Prune old traces"
* button (via {@see \App\Services\Profiling\TracePruner}).
*/
class PruneTraces extends Command
{
/**
* @var string
*/
protected $signature = 'lychee:profiler:prune';

/**
* @var string
*/
protected $description = 'Delete the oldest memory-profiler trace pairs beyond the configured retention cap.';

public function handle(TracePruner $pruner): int
{
$removed = $pruner->prune();
$this->line(sprintf('Removed %d trace pair(s) from storage/profiling.', $removed));

return 0;
}
}
1 change: 1 addition & 0 deletions app/Console/Kernel.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

/**
Expand Down
1 change: 1 addition & 0 deletions app/Constants/FileSystem.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
70 changes: 70 additions & 0 deletions app/DTO/Profiling/ProfilingTraceMeta.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

/**
* SPDX-License-Identifier: MIT
* Copyright (c) 2017-2018 Tobias Reich
* Copyright (c) 2018-2026 LycheeOrg.
*/

namespace App\DTO\Profiling;

/**
* Metadata sidecar stored under `storage/profiling` for each profiled
* request (DO-053-01). `spx_report_key` correlates this sidecar with the
* `spx` extension's own report files (`{spx_report_key}.json` /
* `{spx_report_key}.txt.gz`, written directly by the extension into the
* same directory), so the admin page can show meaningful per-request
* context (route, method, status) while linking out to SPX's own analysis
* screen for the actual call-graph.
*/
final class ProfilingTraceMeta
{
public function __construct(
public readonly ?string $spx_report_key,
public readonly ?string $route_name,
public readonly string $method,
public readonly string $path,
public readonly int $status_code,
public readonly float $duration_ms,
public readonly int $peak_memory_bytes,
public readonly ?int $user_id,
public readonly string $created_at,
) {
}

/**
* @return 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}
*/
public function toJsonArray(): array
{
return [
'spx_report_key' => $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'] ?? '',
);
}
}
93 changes: 93 additions & 0 deletions app/Http/Controllers/Admin/ProfilerController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
<?php

/**
* SPDX-License-Identifier: MIT
* Copyright (c) 2017-2018 Tobias Reich
* Copyright (c) 2018-2026 LycheeOrg.
*/

namespace App\Http\Controllers\Admin;

use App\Constants\FileSystem;
use App\DTO\Profiling\ProfilingTraceMeta;
use App\Services\Profiling\TracePruner;
use Illuminate\Http\RedirectResponse;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Storage;
use Illuminate\View\View;
use function Safe\json_decode;

/**
* Owner-only admin surface for browsing memory-profiler traces
* (Feature 053). Blade-only, no Vue/API surface (NFR-053-03).
*
* Traces themselves are captured by the `spx` extension (via
* {@see \App\Http\Middleware\MemoryProfiler}); this controller only lists
* our own metadata sidecars (`lychee-*.json`) and links out to SPX's own
* analysis screen (protected by `spx.http_key` / `spx.http_ip_whitelist`,
* not by this page's owner-only gate — see ADR-0008) for the call-graph
* itself. There is no in-app rendering.
*/
class ProfilerController extends Controller
{
private const SIDECAR_PREFIX = 'lychee-';

/**
* List every trace currently in `storage/profiling` (FR-053-03).
*/
public function index(): View
{
$disk = Storage::disk(FileSystem::PROFILING);
$spx_key = config('features.memory-profiler-spx-key');

$traces = collect($disk->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<string,mixed> $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=<report 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,
]);
}
}
2 changes: 2 additions & 0 deletions app/Http/Kernel.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class Kernel extends HttpKernel
* @var array<int,string>
*/
protected $middleware = [
\App\Http\Middleware\MemoryProfiler::class,
\App\Http\Middleware\FixStatusCode::class,
\Illuminate\Http\Middleware\TrustProxies::class,
\Illuminate\Foundation\Http\Middleware\PreventRequestsDuringMaintenance::class,
Expand Down Expand Up @@ -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,
];
}
99 changes: 99 additions & 0 deletions app/Http/Middleware/MemoryProfiler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<?php

/**
* SPDX-License-Identifier: MIT
* Copyright (c) 2017-2018 Tobias Reich
* Copyright (c) 2018-2026 LycheeOrg.
*/

namespace App\Http\Middleware;

use App\Assets\Features;
use App\Constants\FileSystem;
use App\DTO\Profiling\ProfilingTraceMeta;
use App\Services\Profiling\SpxRecorder;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use function Safe\json_encode;
use Symfony\Component\HttpFoundation\Response;

/**
* Bounds a memory-profiling span (via {@see SpxRecorder}, i.e. the `spx`
* PECL extension's `spx_profiler_start()`/`spx_profiler_stop()`) around the
* whole lifetime of a request, when the `memory-profiler` feature flag is
* enabled and the extension is loaded. See Feature 053.
*
* Manual start/stop (rather than SPX's own ini-only "always profiling" mode)
* is deliberate: it 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
* NFR-053-06 / ADR-0008. Requires `spx.http_profiling_auto_start=0` (set by
* the Docker startup script, `docker/scripts/06-configure-profiler.sh`).
*
* State is intentionally carried on the {@see Request} instance (attributes)
* rather than on `$this`, because Laravel resolves a **new** middleware
* instance from the container for {@see self::terminate()} — the instance
* that ran {@see self::handle()} is not the same object.
*/
class MemoryProfiler
{
private const ATTR_START_TIME = 'memory_profiler.start_time';

public function __construct(
private SpxRecorder $recorder,
) {
}

public function handle(Request $request, \Closure $next): mixed
{
if (!Features::active('memory-profiler') || !$this->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);
}
}
Loading