A log line that says "error" => $e->getMessage() is barely a log. It tells you that something failed, not why, not for whom, and not what to do about it.
api-server's domain-purchase flow shows what a log entry needs to actually be useful:
// api-server/app/Exceptions/DomainPurchaseException.php
final class DomainPurchaseException extends RuntimeException
{
public function __construct(
string $message,
public readonly bool $outcomeUnknown = false,
) {
parent::__construct($message);
}
}
$outcomeUnknown isn't decoration. It's the field that governs real money: false means the registrar definitively did nothing, safe to refund. true means the registrar might have charged for the domain anyway, and refunding or retrying blind could double-charge or double-register. If you only logged $e->getMessage(), that distinction is gone the moment the stack trace scrolls past. The exception carries the context because the message alone can't.
LogApiRequestsMiddleware applies the same discipline at the request level, not per-error but for every single call:
// api-infrastructure/src/Middleware/LogApiRequestsMiddleware.php
defer(function () use ($request, $response, $startTime) {
$bearer = $request->attributes->get('bearer');
$sanitizedRequest = SensitiveDataSanitizer::sanitize($request->all());
$sanitizedResponse = SensitiveDataSanitizer::sanitize(ResponseParser::parse($response));
// ...payload-size and gzip-size calculations omitted here...
SendToLogsJob::dispatch(
user_id: $bearer?->id ?? 0,
method: $request->method(),
endpoint: $request->url(),
request: $sanitizedRequest,
response: $sanitizedResponse,
status: $response->getStatusCode(),
execution_time: microtime(true) - $startTime,
bearer_token: $bearer?->token,
);
});
Who called (bearer), what they sent, what came back, how long it took, all of it, on every request, not just the ones that failed. SensitiveDataSanitizer runs first, redacting anything matching a list of known-sensitive field names, password, token, credit_card, ssn, before any of it reaches a log store. Context is only worth keeping if keeping it doesn't turn your log store into the next thing that needs breach-notifying.
Correlating one request across services
A single merchant action can touch three services before it's done: merchants calls api-server, which calls api-license, which calls Statamic. When one of those hops fails, you need to reconstruct the whole chain, not just the leaf that threw.
Here's the honest part: this fleet doesn't hand you a single request ID that threads through all three. LogApiRequestsMiddleware ships user_id, method, endpoint, status, and timing to a shared log store, one entry per service, per hop. There's no trace_id field anywhere in that payload. Nightwatch, separately, captures exceptions and traces per app, also without a fleet-wide ID stitching them together.
What you actually have is the bearer. Every internal hop in one logical operation is authenticated with the same bearer, merchants to api-server, api-server to api-license, carrying the same identity all the way down. LogApiRequestsMiddleware writes that identity into the log payload as user_id, so that field, narrowed to a tight time window, is your real join key: query the shared log store for that bearer's user_id across the timestamps around the failure, and you get every hop the request took, in every service, in order.
merchants request
โ
api-server (same bearer)
โ
api-license (same bearer)
โ
Statamic integration
It's coarser than a dedicated trace ID would be. Two operations from the same bearer inside the same second are genuinely hard to tell apart. That's a real gap, not a design you should copy blind, name it for what it is rather than pretending the bearer makes it precise.