From f93d26036b5e6ee1f8835077c39ef4a70ae503f5 Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Sat, 22 Aug 2026 11:06:05 +0200 Subject: [PATCH 1/4] feat(metrics): expose queue depth endpoint - Add a private port 8081 metrics listener for Nginx and FrankenPHP - Report Laravel queue depth with Horizon and queue configuration autodiscovery --- php/base.Dockerfile | 4 + php/common/etc/entrypoint.d/50-metrics.sh | 11 +++ .../usr/local/share/metrics/metrics.caddyfile | 9 +++ php/common/usr/local/share/metrics/nginx.conf | 19 +++++ .../usr/local/share/metrics/queue-size.php | 80 +++++++++++++++++++ 5 files changed, 123 insertions(+) create mode 100755 php/common/etc/entrypoint.d/50-metrics.sh create mode 100644 php/common/usr/local/share/metrics/metrics.caddyfile create mode 100644 php/common/usr/local/share/metrics/nginx.conf create mode 100644 php/common/usr/local/share/metrics/queue-size.php diff --git a/php/base.Dockerfile b/php/base.Dockerfile index 523eb33..bd22567 100644 --- a/php/base.Dockerfile +++ b/php/base.Dockerfile @@ -57,6 +57,10 @@ COPY --chmod=755 common/ / # opcache.file_cache needs the directory to exist and be writable RUN mkdir -p /var/cache/opcache && chown www-data:www-data /var/cache/opcache +# The entrypoint installs the metrics listener config as www-data at runtime. +# The nginx variants already chown /etc/nginx, the frankenphp one doesn't. +RUN if [ -d /etc/frankenphp/caddyfile.d ]; then chown www-data:www-data /etc/frankenphp/caddyfile.d; fi + ARG TARGETARCH ARG PHP_VERSION ARG OS diff --git a/php/common/etc/entrypoint.d/50-metrics.sh b/php/common/etc/entrypoint.d/50-metrics.sh new file mode 100755 index 0000000..49b4951 --- /dev/null +++ b/php/common/etc/entrypoint.d/50-metrics.sh @@ -0,0 +1,11 @@ +#!/bin/sh +# Expose the private metrics listener on port 8081, but only for Laravel apps: +# the endpoint boots the framework to read the queue depth. + +[ -f "${APP_BASE_DIR:-/app}/artisan" ] || exit 0 + +if command -v nginx >/dev/null 2>&1; then + cp /usr/local/share/metrics/nginx.conf /etc/nginx/conf.d/metrics.conf +elif [ -f /etc/frankenphp/Caddyfile ]; then + cp /usr/local/share/metrics/metrics.caddyfile /etc/frankenphp/caddyfile.d/metrics.caddyfile +fi diff --git a/php/common/usr/local/share/metrics/metrics.caddyfile b/php/common/usr/local/share/metrics/metrics.caddyfile new file mode 100644 index 0000000..906ff42 --- /dev/null +++ b/php/common/usr/local/share/metrics/metrics.caddyfile @@ -0,0 +1,9 @@ +# Private metrics listener, installed by /etc/entrypoint.d/50-metrics.sh. +:8081 { + root * /usr/local/share/metrics + + @metrics path /_metrics/queue-size + rewrite @metrics /queue-size.php + + php_server +} diff --git a/php/common/usr/local/share/metrics/nginx.conf b/php/common/usr/local/share/metrics/nginx.conf new file mode 100644 index 0000000..702f5db --- /dev/null +++ b/php/common/usr/local/share/metrics/nginx.conf @@ -0,0 +1,19 @@ +# Private metrics listener, installed by /etc/entrypoint.d/50-metrics.sh. +server { + listen 8081; + + server_name _; + access_log off; + + location = /_metrics/queue-size { + include fastcgi_params; + fastcgi_param SCRIPT_NAME /_metrics/queue-size; + fastcgi_param SCRIPT_FILENAME /usr/local/share/metrics/queue-size.php; + fastcgi_read_timeout 5s; + fastcgi_pass 127.0.0.1:9000; + } + + location / { + return 404; + } +} diff --git a/php/common/usr/local/share/metrics/queue-size.php b/php/common/usr/local/share/metrics/queue-size.php new file mode 100644 index 0000000..8a89fdd --- /dev/null +++ b/php/common/usr/local/share/metrics/queue-size.php @@ -0,0 +1,80 @@ + {"name":"high,default","size":42} + * + * Queues and connection are auto-discovered from the app when not given, so + * apps need no configuration. Failures answer 500 on purpose: a scaler that + * reads 0 from a broken app scales the workers away and jobs stop running. + */ + +use Illuminate\Http\Request; +use Illuminate\Support\Arr; + +ini_set('default_socket_timeout', '3'); + +header('Content-Type: application/json'); + +if (parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) !== '/_metrics/queue-size') { + http_response_code(404); + echo json_encode(['error' => 'not found']); + + return; +} + +try { + $basePath = getenv('APP_BASE_DIR') ?: '/app'; + + require $basePath.'/vendor/autoload.php'; + + $request = Request::capture(); + + $app = require $basePath.'/bootstrap/app.php'; + $app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap(); + + // A "queue" is a name, a comma separated list of names, or an array of either. + $split = fn ($value) => collect(Arr::wrap($value)) + ->flatMap(fn ($item) => explode(',', (string) $item)) + ->map(fn (string $queue) => trim($queue)) + ->filter() + ->values(); + + $connection = $request->query('connection'); + $queues = $split($request->query('queues', [])); + + if ($connection === null || $queues->isEmpty()) { + // Horizon keeps the real queue list in its supervisors, and the + // per-environment block overrides the defaults block. + $supervisors = config('horizon.environments.'.$app->environment()) ?: config('horizon.defaults') ?: []; + + foreach ($supervisors as $supervisor) { + $queues = $queues->merge($split($supervisor['queue'] ?? [])); + $connection ??= $supervisor['connection'] ?? null; + } + } + + $connection ??= config('queue.default'); + + $queues = $queues->unique()->values() + ->whenEmpty(fn () => $split(config("queue.connections.{$connection}.queue", 'default'))); + + $queue = $app->make('queue')->connection($connection); + + // Queue::size() is driver agnostic: on redis it counts pending + delayed + + // reserved, on database every row in "jobs". No per-driver branching, and + // delayed jobs are included so a scaled-to-zero worker still wakes up. + echo json_encode([ + 'name' => $queues->implode(','), + 'size' => $queues->sum(fn (string $name) => $queue->size($name)), + ]); +} catch (Throwable $e) { + http_response_code(500); + echo json_encode(['error' => $e->getMessage()]); +} From 746149f8add31637a1def8cb267832503acc8632 Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Sat, 22 Aug 2026 13:44:30 +0200 Subject: [PATCH 2/4] fix(metrics): merge Horizon supervisor queue defaults --- php/common/usr/local/share/metrics/queue-size.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/php/common/usr/local/share/metrics/queue-size.php b/php/common/usr/local/share/metrics/queue-size.php index 8a89fdd..61a1947 100644 --- a/php/common/usr/local/share/metrics/queue-size.php +++ b/php/common/usr/local/share/metrics/queue-size.php @@ -50,9 +50,10 @@ $queues = $split($request->query('queues', [])); if ($connection === null || $queues->isEmpty()) { - // Horizon keeps the real queue list in its supervisors, and the - // per-environment block overrides the defaults block. - $supervisors = config('horizon.environments.'.$app->environment()) ?: config('horizon.defaults') ?: []; + // Detect queues from Horizon configuration + $supervisors = collect(config('horizon.environments.'.$app->environment()) ?: []) + ->map(fn (array $options, string $name) => array_merge(config("horizon.defaults.{$name}", []), $options)) + ->whenEmpty(fn () => collect(config('horizon.defaults') ?: [])); foreach ($supervisors as $supervisor) { $queues = $queues->merge($split($supervisor['queue'] ?? [])); From 16a1de45b76804517b4063bb3838014f7a64f15e Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Sat, 22 Aug 2026 14:32:53 +0200 Subject: [PATCH 3/4] fix(metrics): accept singular queue query parameter --- php/common/usr/local/share/metrics/queue-size.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/php/common/usr/local/share/metrics/queue-size.php b/php/common/usr/local/share/metrics/queue-size.php index 61a1947..242a31c 100644 --- a/php/common/usr/local/share/metrics/queue-size.php +++ b/php/common/usr/local/share/metrics/queue-size.php @@ -47,9 +47,10 @@ ->values(); $connection = $request->query('connection'); - $queues = $split($request->query('queues', [])); + // "queue" is accepted too: it is what artisan and the chart call it. + $queues = $split($request->query('queues', $request->query('queue', []))); - if ($connection === null || $queues->isEmpty()) { + if ($queues->isEmpty()) { // Detect queues from Horizon configuration $supervisors = collect(config('horizon.environments.'.$app->environment()) ?: []) ->map(fn (array $options, string $name) => array_merge(config("horizon.defaults.{$name}", []), $options)) From b906a3a3b8875cae6589615421f9b948bf557994 Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Sat, 22 Aug 2026 15:06:58 +0200 Subject: [PATCH 4/4] chore(entrypoint): improve startup status messages - Add emoji indicators to Laravel and OPcache startup logs - Report metrics listener activation and warn on OPcache warm-up failures --- php/common/etc/entrypoint.d/40-prepare-laravel.sh | 2 +- php/common/etc/entrypoint.d/45-warm-opcache.sh | 4 ++-- php/common/etc/entrypoint.d/50-metrics.sh | 2 ++ 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/php/common/etc/entrypoint.d/40-prepare-laravel.sh b/php/common/etc/entrypoint.d/40-prepare-laravel.sh index 517f9c5..1b4fc4d 100755 --- a/php/common/etc/entrypoint.d/40-prepare-laravel.sh +++ b/php/common/etc/entrypoint.d/40-prepare-laravel.sh @@ -1,7 +1,7 @@ #!/bin/sh if [ -f "$APP_BASE_DIR/artisan" ]; then - echo "Preparing Laravel application..." + echo "🤖 Preparing Laravel application..." cd $APP_BASE_DIR diff --git a/php/common/etc/entrypoint.d/45-warm-opcache.sh b/php/common/etc/entrypoint.d/45-warm-opcache.sh index cef9ae8..f5920c7 100755 --- a/php/common/etc/entrypoint.d/45-warm-opcache.sh +++ b/php/common/etc/entrypoint.d/45-warm-opcache.sh @@ -2,8 +2,8 @@ # Populate the opcache file cache once per container. if [ -f "$APP_BASE_DIR/artisan" ] && [ -w /var/cache/opcache ]; then - echo "Warming opcache file cache..." + echo "🤖 Warming opcache file cache..." cd "$APP_BASE_DIR" || exit 0 php -d opcache.jit=off artisan --version >/dev/null 2>&1 || \ - echo "opcache warm-up failed, continuing without it" >&2 + echo "⚠️ WARNING: opcache warm-up failed, continuing without it" >&2 fi diff --git a/php/common/etc/entrypoint.d/50-metrics.sh b/php/common/etc/entrypoint.d/50-metrics.sh index 49b4951..bfa0019 100755 --- a/php/common/etc/entrypoint.d/50-metrics.sh +++ b/php/common/etc/entrypoint.d/50-metrics.sh @@ -4,6 +4,8 @@ [ -f "${APP_BASE_DIR:-/app}/artisan" ] || exit 0 +echo "📊 Metrics enabled on port 8081" + if command -v nginx >/dev/null 2>&1; then cp /usr/local/share/metrics/nginx.conf /etc/nginx/conf.d/metrics.conf elif [ -f /etc/frankenphp/Caddyfile ]; then