# Monolog & PSR-3

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 handler

```php
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.

```php
// 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 3

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 tags

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:

```php
// 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 context

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:

```php
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 logger

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:

```php
$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:

```php
$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 logs

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:

```php
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 example

```php
<?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();
```

## Troubleshooting

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.
