Observability & mismatch logs
Shadow mode’s only output is the iam.shadow.mismatch stream. Making that stream visible, routable, and
aggregatable is what turns shadow from “running” into “useful”.
The record
The default MismatchRecorder emits a structured warning for every divergence:
$this->logger->warning('iam.shadow.mismatch', [
'subject_id' => $subjectId,
'ability' => $ability,
'spatie_allows' => $spatieAllows,
'iam_allows' => $iamAllows,
'direction' => $spatieAllows ? 'spatie_allow_iam_deny' : 'spatie_deny_iam_allow',
]);
| Field | Use |
|---|---|
subject_id |
correlate to a user across systems (IAM subject id) |
ability |
the original Spatie ability string |
spatie_allows / iam_allows |
the two decisions |
direction |
aggregate severity: spatie_allow_iam_deny (lockout) vs spatie_deny_iam_allow (escalation) |
It is logged at warning level and never throws — recording a mismatch cannot break a request.
Route the channel
Set the channel with IAM_SPATIE_MISMATCH_CHANNEL and define it in config/logging.php. A dedicated channel
keeps the diff out of your general logs and makes it easy to query:
// config/logging.php
'channels' => [
'iam-shadow' => [
'driver' => 'daily',
'path' => storage_path('logs/iam-shadow.log'),
'level' => 'warning',
'days' => 30,
],
],
IAM_SPATIE_MISMATCH_CHANNEL=iam-shadow
A null channel (the default) uses the application’s default log channel.
Swap the sink
MismatchRecorder is just the default implementation of the RecordsMismatch interface:
interface RecordsMismatch
{
public function record(string $subjectId, string $ability, bool $spatieAllows, bool $iamAllows): void;
}
Bind your own implementation to push divergences to a dashboard, a metrics counter, or a review queue —
without touching ShadowGate:
// A custom sink that also increments a metric and queues for review.
final class DashboardMismatchRecorder implements RecordsMismatch
{
public function __construct(private readonly Dashboard $dashboard) {}
public function record(string $subjectId, string $ability, bool $spatieAllows, bool $iamAllows): void
{
$this->dashboard->pushMismatch([
'subject_id' => $subjectId,
'ability' => $ability,
'spatie_allows' => $spatieAllows,
'iam_allows' => $iamAllows,
'direction' => $spatieAllows ? 'spatie_allow_iam_deny' : 'spatie_deny_iam_allow',
]);
}
}
// In a service provider:
$this->app->bind(RecordsMismatch::class, DashboardMismatchRecorder::class);
Whatever sink you use, aggregate the stream by ability and direction rather than reading lines. A whole
permission diverging one way is one mapping bug, not a hundred incidents — see
reviewing mismatches.
What “clean” looks like
A migration is ready to cut over when, over a representative window, the channel shows zero unexplained
mismatches. Archive that evidence — it is the audit artifact behind the cutover decision.
- The recorder is bound as a singleton; bind your custom implementation before the container resolves
ShadowGate(a service providerregister()is the right place). record()must not throw — a sink that can fail should swallow/queue internally, or it could affect the
request path it is observing.- The logged
abilityis the original Spatie string, not the IAMfull_key; map it back via
slugging when correlating with the manifest.
Next
- Reviewing mismatches — the human loop over this stream.
- Troubleshooting — when the stream looks wrong.