Skip to main content

The Outcome Store

Introduction

Every run of a control point ends in an Outcome, and every outcome is written to the log as a record. The store keeps the same outcomes in a database table, so they can be queried without a log backend: from monitor:outcomes, from monitor:explain and monitor:points, which show a point's recent history and when it last ran, and from the MCP tools an agent uses.

The store is off by default. Records in the log do not depend on it.

Enabling It

Set records.store.enabled, then create the table:

MONITOR_STORE_ENABLED=true
php artisan vendor:publish --tag=monitor-migrations
php artisan migrate

Publishing copies the migration into database/migrations with a timestamp, the way Laravel's first-party packages ship theirs. The package never loads the migration on its own, so the table exists only once you have published and run it.

'records' => [
'store' => [
'enabled' => env('MONITOR_STORE_ENABLED', false),
'connection' => env('MONITOR_STORE_CONNECTION'),
'table' => 'monitor_outcomes',
'retention_days' => 30,
],
],

connection is a database connection name, or null for the default. table is the table name.

The Table

ColumnTypeContent
idbigintPrimary key.
run_idstring(26), uniqueULID of the run.
parent_run_idstring(26), nullable, indexedThe enclosing run when nested.
trace_idstring(32), indexedThe trace.
pointstring, indexedThe control point name.
domainstring, indexedThe domain.
originstringThe fully qualified class.
profilestring, nullableThe profile the point started from.
statusstring(16), indexedsucceeded, recovered, escalated or refused.
recovered_fromstring, nullableThe exception class a correction handled.
exception_classstring, nullableThe class of the failure, when there was one.
exception_messagetext, nullableIts message, redacted.
attemptsunsigned integerTotal attempts.
duration_msdecimal(14,3)Wall time of the run.
stackjsonPoint names, outermost first.
contextjsonThe point's context, redacted.
limits_breachedjsonLimit name to threshold and actual.
policiesjsonThe policies that ran, described.
timelinejsonEvery transition with its offset in milliseconds.
started_attimestamp(3)When the run started, from the outcome.
ended_attimestamp(3), indexedWhen the run ended, from the outcome; not when the row was written.

Context and exception messages go through the same Redactor profile as records, records.redaction; see Records.

When Rows Are Written

Nothing is written on the request path. Kirschbaum\Monitor\Store\StoreOutcomes listens to PointEnded, keeps the outcome in memory, and writes the buffer in one statement:

  • after each HTTP request, once the response has been sent;
  • after each console command;
  • after each queued job, on JobProcessed and JobExceptionOccurred, so a long-running worker never holds outcomes across jobs;
  • on Queue::looping and when a worker stops; and
  • as soon as the buffer holds 100 outcomes, so a long-running command that runs many points does not hold them until it exits.

Rows are upserted on run_id, so writing the same buffer twice is harmless. A test that wants the rows before the request ends calls app(StoreOutcomes::class)->flush(); pending() says how many outcomes are waiting.

A failing write is caught. The first failure in a process is logged at warning as [Monitor] the outcome store could not be written; outcomes are still in the log; later ones are silent. The store is never the reason a control point fails, and never adds a query to the request that ran it.

Querying

Kirschbaum\Monitor\Store\OutcomeStore is resolved from the container.

use Kirschbaum\Monitor\Store\OutcomeStore;

$store = app(OutcomeStore::class);

$store->enabled(); // bool, from config

recent() returns rows newest first as arrays with the JSON columns decoded:

$rows = $store->recent([
'point' => 'payment.charge', // optional
'domain' => 'Payments', // optional
'status' => 'escalated', // optional
'trace' => $traceId, // optional
'since' => now()->subHours(2), // optional DateTimeInterface
], limit: 100); // default 50

tally() counts rows per status, for one point or for all, optionally since a moment:

$store->tally(); // ['succeeded' => 412, 'recovered' => 9, 'escalated' => 2]
$store->tally('payment.charge', now()->subDay()); // ['succeeded' => 40, 'recovered' => 1]

lastSeen() returns, per point, when it last ran and how it ended; monitor:points shows this column when the store is on:

$store->lastSeen();
// ['payment.charge' => ['ended_at' => '2026-09-14 18:10:25.011', 'status' => 'succeeded'], ...]

prune() deletes rows older than the given number of days, or the configured retention, and returns the count.

The rest of the store's public methods:

MethodMeaning
write(array $outcomes)Upsert a list of Outcome objects and return how many were written. StoreOutcomes calls it; a listener of your own may too.
query()A query builder on the table, for anything the helpers do not cover.
table(), connection(), retentionDays()The configured table, connection and retention.

monitor:outcomes

Lists recent outcomes from the store.

php artisan monitor:outcomes
php artisan monitor:outcomes --point=payment.charge --since=1h
php artisan monitor:outcomes --domain=Payments --status=escalated --since=7d --limit=200
php artisan monitor:outcomes --trace=7f3a2c1d9e8b4a6f0c5d1e2f3a4b5c6d
php artisan monitor:outcomes --json
OptionMeaning
--point=Only this control point.
--domain=Only this domain.
--status=succeeded, recovered, escalated or refused.
--trace=Only this trace id.
--since=How far back: a number followed by m, h or d, e.g. 15m, 2h, 7d. Default 24h.
--limit=Rows to show. Default 50.
--jsonPrint the rows as JSON instead of a table.

The table has the columns Ended, Point, Domain, Status, Attempts, ms, Exception and the first eight characters of the trace id. With --json every column comes back, JSON columns decoded, so the output can be piped to jq.

Exit codes: 0 on success, including when nothing matched; 1 when the store is disabled; 2 when --since is not in the accepted form.

Retention and monitor:prune

Rows are kept for records.store.retention_days, 30 by default. Nothing deletes them unless monitor:prune runs:

php artisan monitor:prune # older than the configured retention
php artisan monitor:prune --days=7 # older than 7 days

Schedule it:

// routes/console.php
Schedule::command('monitor:prune')->daily();

The command reports how many rows it deleted, and does nothing while the store is disabled.