Skip to content

Metrics

Pillar includes first‑class support for application‑level metrics. These metrics give you deep visibility into what your system is doing—how often commands run, how quickly queries execute, how many events are appended, how the outbox worker behaves, how replays progress, and more.

Pillar's metrics system is optional, lightweight, and designed to work seamlessly in highly concurrent multi‑process environments. When enabled, Pillar emits a rich set of counters and histograms suitable for scraping by Prometheus.


Overview

Installing the Prometheus client

To use the prometheus metrics driver, your application must install the Prometheus PHP client library:

bash
composer require promphp/prometheus_client_php

If the library is not installed, Pillar will automatically fall back to the none driver at runtime and log a warning.

Pillar exposes metrics through a pluggable Metrics interface with two drivers:

  • prometheus — backed by promphp/prometheus_client_php, optionally using Redis for cross‑process aggregation.
  • none — a noop metrics implementation used when metrics are disabled or the client library is not installed.

The driver is configured using:

php
pillar.metrics.driver = 'prometheus' // or 'none'

When prometheus is selected, Pillar automatically discovers whether the Prometheus client is installed. If the dependency is missing, Pillar logs a warning and transparently falls back to NullMetrics.

The metrics driver is safe to enable unconditionally: metrics do nothing unless the Prometheus library is available.

Default Labels

Pillar allows you to define default labels that apply to every metric emitted by the Prometheus driver. These labels are useful for attaching high‑level context such as the application name, environment, region, or deployment identifier.

Default labels are configured in config/pillar.php:

php
'prometheus' => [
    'default_labels' => [
        'app' => env('APP_NAME', 'laravel'),
        'env' => env('APP_ENV', 'local'),
    ],
],

These are automatically merged into every metric's labels. Metric‑specific labels continue to work as normal, and default labels always come first in label ordering.

If you do not define any default labels, the metrics driver simply omits them.


Process Model Considerations

PHP‑FPM (in‑memory storage)

If you configure the Prometheus client for in‑memory storage under PHP‑FPM:

  • Each FPM worker maintains its own independent metric state.
  • A /metrics scrape only exposes metrics from the worker handling the request.
  • Worker recycling resets that worker's metrics.

For this reason, in‑memory storage is not recommended in production under PHP‑FPM. It is suitable for tests or single‑process CLI tools.

If you configure Prometheus to use Redis:

  • All PHP‑FPM workers, queue workers, and outbox workers push metrics into the same shared Redis store.
  • Aggregation is handled centrally.
  • Scraping the web process exposes combined metrics from the whole system.

This is the preferred setup in production.


Where Metrics Are Emitted

Pillar instruments the execution flow across several core components:

Event Store

  • EventStoreRepository: appends, aggregate loads, snapshot loads/saves.
  • DatabaseEventStore: concurrency conflicts, getByGlobalSequence, and recent() queries.

Outbox

  • DatabaseOutbox: enqueued, claimed, published, failed messages.
  • WorkerRunner: per‑message dispatch success/failure, per‑tick execution, tick duration.

Command and Query Buses

  • LaravelCommandBus: total commands, command failures, and execution duration.
  • InMemoryQueryBus: total queries, query failures, and execution duration.

Event Replay

  • EventReplayer: replay starts, replayed events, replay failures.

If you replace any of these components with your own implementations, you are responsible for emitting metrics in the same places. Pillar's built‑in versions are useful references for which metrics to emit and how to label them.


Metrics Reference

Below is a complete list of all metrics emitted by Pillar, grouped by subsystem. All metric names are prefixed internally to avoid collisions, but listed here without namespace for clarity.

Event Store Metrics

MetricTypeLabelsDescription
eventstore_appends_totalcounteraggregate_typeNumber of events appended for aggregates.
eventstore_load_totalcounteraggregate_typeNumber of times an aggregate is loaded.
eventstore_snapshot_load_totalcounteraggregate_type, hitSnapshot loads, with hit/miss label.
eventstore_snapshot_save_totalcounteraggregate_typeSnapshots saved by the snapshot policy.
eventstore_conflicts_totalcounterstream_idOptimistic locking failures during append.
eventstore_get_by_sequence_totalcounterfoundReads by global sequence number, labelled by hit/miss.
eventstore_recent_queries_totalcounterNumber of calls to recent().

Outbox Metrics

MetricTypeLabelsDescription
outbox_enqueued_totalcounterpartitionMessages added to the outbox.
outbox_claimed_totalcounterpartitionMessages claimed by a worker for processing.
outbox_published_totalcounterpartitionSuccessfully published messages.
outbox_failed_totalcounterpartitionMessages that failed and were rescheduled.
outbox_dispatch_totalcountersuccessDelivery attempts inside WorkerRunner.
outbox_tick_totalcounteremptyWorker ticks (empty vs work found).
outbox_tick_duration_secondshistogramDuration of a worker tick.

Command Bus Metrics

MetricTypeLabelsDescription
commands_totalcountercommand, successTotal commands dispatched, labelled by success/failure.
commands_failed_totalcountercommandCommands that threw exceptions.
command_duration_secondshistogramcommandExecution time for command handlers.

Query Bus Metrics

MetricTypeLabelsDescription
queries_totalcounterquery, successTotal queries executed, labelled by success/failure.
queries_failed_totalcounterqueryQueries that threw exceptions.
query_duration_secondshistogramqueryExecution time for query handlers.

Replay Metrics

MetricTypeLabelsDescription
replay_started_totalcounterReplays initiated.
replay_events_processed_totalcounterTotal events processed during replays.
replay_failed_totalcounterListener failures during replay.

Exporting Metrics

Pillar does not force you to expose metrics in any particular way. A typical Laravel project adds a route such as:

php
use Illuminate\Support\Facades\Route;
use Pillar\Metrics\Prometheus\CollectorRegistryFactory;
use Prometheus\RenderTextFormat;

Route::get('/metrics', function (CollectorRegistryFactory $factory, RenderTextFormat $renderer) {
    $registry = $factory->get();

    return response(
        $renderer->render($registry->getMetricFamilySamples()),
        200,
        ['Content-Type' => RenderTextFormat::MIME_TYPE],
    );
});

Or uses a dedicated controller if preferred.

If Redis storage is used, all metrics from workers, FPM processes, web requests, and CLI commands are aggregated automatically.


Notes for Custom Implementations

If you provide your own implementations of any of Pillar’s interfaces—such as CommandBusInterface, QueryBusInterface, EventStore, Outbox, or projector runners—Pillar will not automatically instrument them. You are responsible for emitting the metrics you care about.

The built‑in instrumented classes serve as the canonical examples.


Example: How Default Labels Appear in Output

If you enable default labels in your pillar.php config:

php
'default_labels' => [
    'app' => 'myapp',
    'env' => 'production',
],

a Prometheus scrape will look like:

text
eventstore_appends_total{app="myapp",env="production",aggregate_type="User"} 42
eventstore_conflicts_total{app="myapp",env="production",stream_id="user-123"} 1
outbox_published_total{app="myapp",env="production",partition="default"} 532

Default labels appear first and are applied consistently to every metric emitted by Pillar.


Defining Custom Metrics

You can emit your own metrics anywhere in your application by type-hinting the Metrics interface:

php
use Pillar\Metrics\Metrics;

class MyService
{
    public function __construct(private Metrics $metrics) {}

    public function doWork(): void
    {
        $this->metrics->counter('myservice_operations_total', ['result'])
            ->inc(1, ['result' => 'success']);
    }
}

Custom metrics automatically receive default labels (if configured) and follow the same naming rules as internal metrics.


Testing With Metrics

During tests, Pillar will typically use the none driver (NullMetrics), which makes all metric calls no-ops. If you want to assert against metrics in tests, you can bind your own in-memory implementation of Metrics:

php
use Pillar\Metrics\Metrics;

class TestMetrics implements Metrics
{
    public array $counters = [];

    public function counter(string $name, array $labelNames = []): \Pillar\Metrics\Counter
    {
        // return a simple Counter implementation that records calls into $this->counters
    }

    public function histogram(string $name, array $labelNames = []): \Pillar\Metrics\Histogram
    {
        // implement if needed
    }

    public function gauge(string $name, array $labelNames = []): \Pillar\Metrics\Gauge
    {
        // implement if needed
    }
}

$this->app->instance(Metrics::class, new TestMetrics());

// run code under test and then inspect TestMetrics::$counters

This keeps your test environment deterministic and free from Redis or other shared state while still letting you assert on metric behaviour.

Summary

Pillar's metrics system is designed to give you production‑grade observability with minimal setup. It works safely in both development and multi‑process production environments, provides rich insight into command handling, queries, event storage, the outbox and workers, and is fully optional.

As your application grows, you can extend metrics emission to your own subsystems or custom infrastructure.