Skip to main content

Records

Introduction

Every transition of a control point produces one record: it started, it was retried, it breached a limit, it recovered, it escalated, it was refused, it ended. Every circuit breaker transition produces one too. A record is written as a log line whose context carries a fixed set of fields, so a log backend indexes point, domain and status as fields rather than finding them inside free text.

The fields, the event names and the status values are fixed by the schema at resources/schema/record-1.json, versioned as monitor/1. Nothing downstream needs to parse a message.

The Record Shape

A record is the context array of one log line. The Recorder subscribes to Monitor's events and writes one line per event; the message is human readable, and everything a machine needs is in the context.

Fields on Every Point Event

FieldTypeMeaning
schemastringAlways monitor/1.
eventstringOne of the event names below.
pointstringThe control point name, e.g. payment.charge.
run_idstringULID of this run.
parent_run_idstring or nullULID of the enclosing run when the point is nested inside another.
stacklist of stringsPoint names from the outermost to this one.
trace_idstring32 lowercase hex characters. See Tracing.
domainstringThe business area the point belongs to, derived from its origin's namespace.
originstringThe fully qualified class the point belongs to.
profilestring or nullThe profile the point started from, if any.
contextobjectThe point's context, redacted.

Fields by Event

EventExtra fields
point.startedattempt (always 1).
point.retriedattempt (the attempt that failed), backoff_ms, exception.
point.limitlimit with name (duration or attempts), threshold and actual.
point.recoveredstatus, attempts, duration_ms, limits_breached, exception, risk (the exception class the correction handled).
point.escalatedstatus, attempts, duration_ms, limits_breached, exception.
point.refusedstatus, attempts, duration_ms, limits_breached, exception, breaker with name, state and retry_after_s.
point.endedstatus, attempts, duration_ms, limits_breached, and exception when the run failed.
escalation.failedEverything point.ended carries, plus escalation_exception: the exception the escalation handler itself threw. The original failure still propagates.
escalation.throttledEverything point.ended carries, plus throttle_seconds: an escalation was due but one for the same point already fired inside the window. Written at notice.

status is one of succeeded, recovered, escalated or refused. limits_breached is an object keyed by limit name, each holding threshold and actual; it is empty when nothing was breached.

exception is a summary, never the object: class, message, file, line, code, an optional previous with the class and message one level down, and a trace only when configured.

Every outcome event is followed by point.ended, so a consumer that only wants one line per run listens for point.ended and reads status.

Breaker Events

breaker.opened, breaker.half_open and breaker.closed carry schema, event and a breaker object with name, state (closed, open or half_open), failures (failures inside the window) and open_for (seconds the circuit was opened for, or null). See Breakers.

Events and Levels

Each event is written at the level in records.levels. The defaults:

EventLevel
point.starteddebug
point.retriednotice
point.limitwarning
point.recoveredwarning
point.refusedwarning
point.escalatederror
point.endedinfo
escalation.failedcritical
breaker.openederror
breaker.half_opennotice
breaker.closedinfo

Change any of them in config/monitor.php:

'records' => [
'levels' => [
'point.started' => 'debug',
'point.ended' => 'notice',
// ...
],
],

An event missing from the map is written at info.

The Message

The message is for a person tailing a file. It starts with [Domain:Origin], where Origin is the short class name, then the point name, then what happened:

[Payments:StripeCharger] payment.charge started
[Payments:StripeCharger] payment.charge attempt 1 failed with App\Exceptions\GatewayTimeout, retrying after 200ms
[Payments:StripeCharger] payment.charge breached the duration limit: 14012.4 against 5000
[Payments:StripeCharger] payment.charge recovered from App\Exceptions\CardDeclined in 893.2ms
[Payments:StripeCharger] payment.charge escalated App\Exceptions\GatewayDown after 3 attempt(s) in 4120ms
[Payments:StripeCharger] payment.charge refused: breaker stripe is open
[Payments:StripeCharger] payment.charge succeeded in 412ms
[Payments:StripeCharger] payment.charge escalation handler threw LogicException; the original failure still propagates
[Monitor] breaker stripe is now open

Redaction

Before a record is written, its context and the message of any exception summary go through Kirschbaum Redactor with the profile in records.redaction. The default is Redactor's observability profile, which is built for this: it replaces credentials and personal data and leaves identifiers alone, so records stay joinable on run_id, trace_id and the like. No _redacted marker is added.

Only application data is redacted. Point names, run ids, trace ids, domains and origins are never touched.

'records' => [
'redaction' => env('MONITOR_REDACTION_PROFILE', 'observability'),
],

Set it to null or an empty string to write context and messages as they are. Any other value must be a profile Redactor knows.

Exception Traces

By default an exception summary has no stack trace. records.exception_trace controls that:

ValueBehaviour
neverNo trace. The default.
debugA trace when app.debug is true.
alwaysA trace in every environment.

When a trace is included it is cut to records.exception_trace_lines lines (15 by default), and exception.trace_truncated says how many were dropped.

'records' => [
'exception_trace' => env('MONITOR_EXCEPTION_TRACE', 'never'),
'exception_trace_lines' => 15,
],

The Channel

Records go to the application's default log channel unless records.channel names another:

'records' => [
'channel' => env('MONITOR_LOG_CHANNEL'),
],

The channel must exist in config/logging.php. Laravel's Context is attached to every record like any other log line, so the trace_id and control_point that Monitor keeps there appear in the record's extra data too.

NDJSON with the JSON Tap

Laravel's default formatter writes the message followed by the context as JSON. For a backend that wants one JSON object per line with the record's fields at the top level, add the tap to the channel:

'channels' => [
'monitor' => [
'driver' => 'daily',
'path' => storage_path('logs/monitor.log'),
'level' => 'debug',
'days' => 14,
'tap' => [Kirschbaum\Monitor\Logging\JsonTap::class],
],
],

JsonTap installs RecordFormatter on every formattable handler of the channel. The formatter writes ts, level, channel and message first, then every key from the record's extra data, then every context key; a context key wins over an extra key of the same name. A line that cannot be encoded is replaced rather than dropped, so a record with bad bytes still leaves a line.

{"ts":"2026-09-14T18:10:25.011+00:00","level":"warning","channel":"monitor","message":"[Payments:StripeCharger] payment.charge recovered from App\\Exceptions\\CardDeclined in 893.2ms","schema":"monitor/1","event":"point.recovered","point":"payment.charge","run_id":"01K5A3ZR7Q8V1J3M0X6Y2N9WCD","parent_run_id":null,"stack":["payment.charge"],"trace_id":"7f3a2c1d9e8b4a6f0c5d1e2f3a4b5c6d","domain":"Payments","origin":"App\\Services\\Payments\\StripeCharger","profile":"external","context":{"invoice":48211,"amount":12900},"status":"recovered","attempts":2,"duration_ms":893.2,"limits_breached":{},"exception":{"class":"App\\Exceptions\\CardDeclined","message":"Card declined: insufficient_funds","file":"/app/app/Services/Payments/StripeCharger.php","line":41,"code":0},"risk":"App\\Exceptions\\CardDeclined"}

Point the channel at records.channel and every record lands there in this shape. The tap is also fine on a channel the rest of the application writes to; an ordinary log line simply has fewer fields.

The Origin-Bound Logger

Records cover control points. For any other line you want grouped by domain, Monitor::log() returns a PSR-3 logger bound to an origin:

use Kirschbaum\Monitor\Facades\Monitor;

Monitor::log($this)->info('Index rebuilt', ['documents' => 4120]);

The origin is the class of the object passed, or the string passed. Inside a running control point the origin can be left out: Monitor::log() binds to the innermost point's origin, so an ad hoc line inside payment.charge carries the same origin and domain as the point's records. Outside a point, Monitor::log() with no origin uses the Kirschbaum\Monitor\Monitor class. The line is written as:

[Search:Indexer] Index rebuilt {"documents":4120,"origin":"App\\Services\\Search\\Indexer","domain":"Search"}

The prefix is [Domain:ShortClass], and origin and domain are added to the context so a backend can filter on them. Domain resolution is the same as for control points; see Configuration.

with() returns a logger that adds context to every line it writes, and channel() one that writes to a named channel:

$log = Monitor::log($this)->with(['tenant' => $tenant->id])->channel('search');

$log->info('Index rebuilt');
$log->warning('Index stale', ['age_minutes' => 42]);

Both return new instances; the original is unchanged. The logger implements Psr\Log\LoggerInterface, so it can be handed to anything that accepts one, and uses Conditionable, so when() and unless() work on it. A level that is neither a string nor Stringable is written at info.

Events

Every record is written by a listener, and the events are public. Alerting, metrics and anything else that reacts to a control point should listen to the events rather than to log lines.

EventCarries
Kirschbaum\Monitor\Events\PointStartedrun: a RunInfo with the point, run id, parent id, trace id, domain, origin, profile, stack and context.
Kirschbaum\Monitor\Events\PointRetriedrun, exception, attempt (the attempt that failed), backoffMs.
Kirschbaum\Monitor\Events\PointLimitBreachedrun, limit (duration or attempts), threshold, actual.
Kirschbaum\Monitor\Events\PointRecoveredoutcome.
Kirschbaum\Monitor\Events\PointEscalatedoutcome.
Kirschbaum\Monitor\Events\PointRefusedoutcome.
Kirschbaum\Monitor\Events\PointEndedoutcome. Dispatched after the status event, for every run.
Kirschbaum\Monitor\Events\EscalationFailedoutcome, exception: what the escalation handler threw.
Kirschbaum\Monitor\Events\EscalationThrottledoutcome, seconds: an escalation skipped by throttleEscalation(). Recorded as escalation.throttled.
Kirschbaum\Monitor\Events\BreakerOpenedbreaker (the name), state: a BreakerState.
Kirschbaum\Monitor\Events\BreakerHalfOpenbreaker, state.
Kirschbaum\Monitor\Events\BreakerClosedbreaker, state.

Outcome is described in Getting Started; its startedAt and endedAt are CarbonImmutable instances, in toArray() as started_at and ended_at, and the store writes both. Records carry duration_ms rather than the two timestamps. Kirschbaum\Monitor\RunInfo is the readonly object the in-progress events carry, with public point, id, parentId, traceId, domain, origin, profile, stack and context; Outcome::info() returns the same object for a finished run. A listener that pages on escalation:

use Kirschbaum\Monitor\Events\PointEscalated;

Event::listen(PointEscalated::class, function (PointEscalated $event): void {
if ($event->outcome->domain === 'Payments') {
Pager::page('payments', $event->outcome->toArray());
}
});

Recovered runs do not dispatch PointEscalated, and a point's own escalate() handler is separate from these events; see Risks and Corrections. The outcome store is itself a listener on PointEnded.

The Schema File

resources/schema/record-1.json is a JSON Schema (draft 2020-12) for the record's context. It lists every field, the event enumeration and the status enumeration. It is also served to agents as an MCP resource; see Agents.