Monolog & PSR-3

BugWatch with Monolog and PSR-3link

Most PHP applications already log through Monolog or a PSR-3 logger. BugWatch does not replace either. You add one handler, or take the bundled PSR-3 logger, and events flow to BugWatch alongside every destination you already write to.

Monolog: wiring the handlerlink

use Monolog\Logger;
use NewInstance\BugWatch\BugWatch;
use NewInstance\BugWatch\Integration\Monolog\Handler;

BugWatch::init(['projectKey' => getenv('BUGWATCH_KEY')]);

$log = new Logger('payments');
$log->pushHandler(new Handler(BugWatch::client()));

$log->warning('Cache miss', ['key' => 'u:1']);
$log->error('Charge failed', ['exception' => $e]);

The constructor is new Handler(Client $client, int|string|\Monolog\Level $level = 'debug', bool $bubble = true).

  • $level is the handler's minimum severity. The default 'debug' forwards everything Monolog passes to this handler. Accepts a PSR-3 level name, a Monolog integer, or a Monolog\Level case.
  • $bubble defaults to true, meaning the record continues to handlers pushed before this one. Leave it as true. Setting it to false makes BugWatch swallow the record and starves your file, stderr, and Slack handlers.
// Only warning and above reach BugWatch; everything still reaches the other handlers
$log->pushHandler(new Handler(BugWatch::client(), 'warning'));

Keep the BugWatch handler as one entry in your existing stack. There is nothing to remove.

Monolog 2 and Monolog 3link

One class supports both. Monolog 2 passes an array record, Monolog 3 passes a Monolog\LogRecord, and the handler's write() deliberately omits the parameter type so it satisfies both parent signatures. The record normaliser branches on the runtime type and produces the same shape either way. Monolog is an optional integration (^2 || ^3 both supported); install monolog/monolog yourself, and no configuration flag selects a version.

Monolog levels arrive as their native integers (100 through 600) and BugWatch maps them onto its own scale: 100 debug, 200 info, 300 warning, 400 error, 500 and above critical or fatal.

What becomes tagslink

Three sources feed the event's tags:

  1. The Monolog channel name, as the channel tag, when it is not empty.
  2. Every scalar value in context.
  3. Every scalar value in extra, which is where Monolog processors put their output.

context and extra are merged in that order, so an extra key overwrites a context key of the same name.

Only scalars survive. Arrays, objects, closures, and resources are dropped silently rather than being serialised or stringified. If you need structured data on the event, flatten it at the call site:

// Dropped: 'order' is an array
$log->error('Checkout failed', ['order' => ['id' => 42, 'total' => 1999]]);

// Kept: scalars only
$log->error('Checkout failed', [
    'order_id'    => 42,
    'order_total' => 1999,
]);

// Or serialise it yourself into one scalar
$log->error('Checkout failed', ['order' => json_encode($order)]);

The handler never throws into Monolog. Its whole body is wrapped so that a transport failure, a serialisation problem, or a misconfigured client cannot break the logging call that triggered it. This also means a broken configuration fails silently; use 'debug' => true in init() while you are setting it up.

Exceptions in contextlink

A Throwable under the exception key is treated as an exception, not a message. The handler removes it from context and calls captureException() with the record's level and the remaining tags, so you get a stack trace and proper issue grouping rather than a flat log line:

try {
    $gateway->charge($order);
} catch (\Throwable $e) {
    $log->error('Charge failed', [
        'exception' => $e,
        'order_id'  => $order->id,   // still forwarded as a tag
        'gateway'   => 'paystack',
    ]);
}

Wrapped exceptions carry their causes. BugWatch walks getPrevious() up to three levels deep and records each cause's class and message on the event, so a RuntimeException wrapping a PDOException shows both. Exception messages are clamped to 2048 characters and stack traces to 50 frames.

A non-Throwable value under the exception key is left alone. If it is scalar it becomes an ordinary tag; if it is not, it is dropped like any other non-scalar.

The built-in PSR-3 loggerlink

If your app has no logging library, or a third-party package wants a Psr\Log\LoggerInterface, use the bundled logger and skip Monolog entirely:

$log = BugWatch::getLogger();          // Psr\Log\LoggerInterface
$logger = $client->getLogger();        // same, on a createClient() instance

It implements every PSR-3 level and interpolates {placeholder} tokens in the message from context, using scalar and Stringable values:

$log->info('User {user} signed in', ['user' => 'alice']);
// message: "User alice signed in"

$log->warning('Cache miss for key {key}', ['key' => 'u:1']);
$log->error('Charge failed', ['exception' => $e]);
$log->critical('Database connection lost');

Context handling matches the Monolog handler: an exception key holding a Throwable becomes a captured exception at the given level, and the remaining context becomes tags. Note that a value used for interpolation is also forwarded as a tag, so {user} above appears both in the message text and as a user tag. There is no channel tag, since PSR-3 has no channel concept; use BugWatch::setTag('component', ...) if you want the equivalent.

Trace correlation on forwarded logslink

Forwarded log records pick up the active trace context automatically. Whatever is on the scope when the log call happens, whether set by Laravel's BugWatchContextMiddleware or by your own setTraceContext() call, is stamped onto the event. Inside a span the same applies, so a log written during withSpan() links to that span:

BugWatch::withSpan('db.query load-cart', function ($span) use ($log, $cartId) {
    $span->setAttr('db.system', 'mysql');
    $log->warning('Cart lookup slow', ['cart_id' => $cartId]); // linked to this span

    return loadCart($cartId);
}, ['kind' => 3]);

There is no per-record trace override on the Monolog path. When you need to pin one event to a specific trace, call captureLog() or captureException() directly with traceId and spanId hints.

Complete examplelink

<?php
require __DIR__ . '/vendor/autoload.php';

use Monolog\Handler\StreamHandler;
use Monolog\Logger;
use Monolog\Processor\IntrospectionProcessor;
use NewInstance\BugWatch\BugWatch;
use NewInstance\BugWatch\Integration\Monolog\Handler as BugWatchHandler;

BugWatch::init([
    'projectKey'  => getenv('BUGWATCH_KEY'),
    'release'     => getenv('APP_VERSION'),
    'serviceName' => 'payments-api',
]);

$log = new Logger('payments');
$log->pushProcessor(new IntrospectionProcessor());              // adds scalar extra: file, line, class
$log->pushHandler(new StreamHandler('php://stderr', Logger::DEBUG));
$log->pushHandler(new BugWatchHandler(BugWatch::client(), 'warning', true));

$log->debug('Building charge request');            // stderr only, below the BugWatch threshold
$log->warning('Retrying charge', ['attempt' => 2, 'order_id' => 'o_42']);

try {
    $gateway->charge($order);
} catch (\Throwable $e) {
    // Captured exception in BugWatch with causes chain, plus tags:
    // channel=payments, order_id=o_42, gateway=paystack, and the processor's extra
    $log->error('Charge failed', [
        'exception' => $e,
        'order_id'  => 'o_42',
        'gateway'   => 'paystack',
    ]);
}

BugWatch::flush();

Troubleshootinglink

Records reach the handler but nothing appears in the dashboard. Confirm BugWatch::init() ran before the handler was constructed, since the handler needs a live Client. Then check the handler's minimum level (default 'debug'). An unrecognised level string does not filter silently: Monolog throws from the constructor, so a handler that constructed at all has a valid threshold. (The Laravel bugwatch log channel is more forgiving and falls back to debug.) Set 'debug' => true in init() and read error_log for [BugWatch] lines.

Some log calls appear and others do not. Two filters stack: Monolog's own logger and channel level, and the handler's $level. A record dropped by Monolog never reaches BugWatch at all.

Other handlers stopped working after adding BugWatch. You passed false for $bubble. Set it back to true.

Context data is missing from the event. Only scalars become tags. Flatten nested arrays or json_encode() them into a single scalar at the call site.

An exception logged with Log::error('...', ['exception' => $e]) shows as a message with no stack trace. The value under exception must be a Throwable instance. A pre-formatted string or an array will be treated as ordinary context.

Duplicate events in the dashboard. Check that the handler is pushed once. Pushing inside a factory that runs per request on a persistent runtime accumulates handlers on the same logger instance.