# Laravel

The `newinstance/bugwatch-php` SDK ships a Laravel integration that is registered through package
discovery. Install it, set three env vars, and unhandled exceptions are already reaching BugWatch.
Everything else on this page is opt in.

Requirements: PHP 8.2+, `laravel/framework ^11|^12|^13`.

## Install

```shell
composer require newinstance/bugwatch-php
```

`NewInstance\BugWatch\Laravel\BugWatchServiceProvider` is auto discovered. Do not add it to
`config/app.php` yourself.

## The env vars (only the key is required)

```dotenv
BUGWATCH_KEY="<keyId>:<secret>"   # from BugWatch project Settings -> API Keys, scope ingest:write
BUGWATCH_RELEASE="1.3.2"          # version string or git SHA, used for grouping and deploy correlation
BUGWATCH_SERVICE_NAME="checkout-api"  # logical service name on spans, powers the service map
```

Optional knobs, all read by `config/bugwatch.php`:

```dotenv
BUGWATCH_ENABLED=true
BUGWATCH_SAMPLE_RATE=1.0
BUGWATCH_CAPTURE_EXCEPTIONS=true   # false disables the exception handler decorator
BUGWATCH_LEVEL="debug"             # minimum level forwarded through the bugwatch log channel
BUGWATCH_ENDPOINT=https://api.newinstance.cloud   # only for self hosted overrides
```

If `BUGWATCH_KEY` is missing the provider builds a disabled client instead of throwing. Your app boots
normally and sends nothing.

## What auto discovery gives you with zero code

- Unhandled exceptions. The provider decorates Laravel's `ExceptionHandler`, so anything Laravel would
  report (it honours the inner handler's `shouldReport`, meaning your `dontReport` list is respected) is
  captured with `level=error` and tag `handled=true`. Laravel's own reporting still runs afterwards.
- End of request flush through the framework `terminating()` hook, so plain PHP-FPM apps deliver events
  without any middleware.
- Queue jobs: `JobProcessed` and `JobFailed` trigger `flush()` plus `resetScope()`.
- Artisan: `CommandFinished` triggers `flush()`.
- Octane: `RequestTerminated` triggers `flush()` plus `resetScope()`.
- A `bugwatch` Monolog log channel driver, ready to be wired into `config/logging.php`.

## The bugwatch log channel

`Log::error(...)` is not forwarded until you add the channel. The driver name is `bugwatch`.

```php
// config/logging.php
'channels' => [
    // standalone
    'bugwatch' => [
        'driver' => 'bugwatch',
        // 'level' => 'warning',   // optional, overrides BUGWATCH_LEVEL for this channel
    ],

    // or inside your default stack
    'stack' => [
        'driver'   => 'stack',
        'channels' => ['daily', 'bugwatch'],
    ],
],
```

Records at or above the level become BugWatch events. Passing an exception in the context array
(`['exception' => $e]`) captures it as an exception, not a plain message.

## Publishing the config

```shell
php artisan vendor:publish --tag=bugwatch-config
```

This copies `config/bugwatch.php` into your app. Publish it when you need `sensitive_fields`, which has
no env var and is the one setting you normally have to edit by hand.

```php
// config/bugwatch.php
'sensitive_fields' => ['otp', 'national_id', 'card_number'],
```

## The context middleware

`BugWatchContextMiddleware` is optional but recommended. Register it globally or on a group.

```php
// bootstrap/app.php (Laravel 11+ style)
->withMiddleware(function (Middleware $middleware) {
    $middleware->append(\NewInstance\BugWatch\Laravel\BugWatchContextMiddleware::class);
})

// or app/Http/Kernel.php, in $middleware or a group such as 'web'
\NewInstance\BugWatch\Laravel\BugWatchContextMiddleware::class,
```

What it does per request:

- Sets tags `method` (HTTP verb), `url` (request path, leading slash normalised) and `route` (the route
  name, only when the route is named).
- Sets the user. By default only the default guard's identifier, as `['id' => '<auth identifier>']`.
- Reads the inbound `traceparent` header and calls `setTraceContext()`, so captures and spans in this
  request join the upstream caller's trace (another service, or the BugWatch JS SDK's `wrapFetch`).
- On `terminate()`, calls `flush()` then `resetScope()`. Events go out after the response, and the scope
  is clean for the next request on a long lived worker.

Every step runs inside a try/catch. Context enrichment can never break a request.

To attach a richer user, register a resolver once from a provider's `boot()`:

```php
// app/Providers/AppServiceProvider.php
use Illuminate\Http\Request;
use NewInstance\BugWatch\Laravel\BugWatchContextMiddleware;

public function boot(): void
{
    BugWatchContextMiddleware::resolveUserUsing(function (Request $request): ?array {
        $user = $request->user();

        return $user ? ['id' => (string) $user->id, 'email' => $user->email, 'tenant' => $user->tenant_id] : null;
    });
}
```

Return `null` for anonymous requests. Registering in `boot()` and not in config keeps
`php artisan config:cache` working, since closures cannot be cached. A throwing resolver is swallowed and
treated as anonymous. `forgetUserResolver()` clears it again, useful in tests.

## Long lived runtimes

| Runtime | Handled by | You do |
|---|---|---|
| PHP-FPM, one shot CLI | Process isolation plus `terminating()` flush | Nothing |
| Octane | `RequestTerminated` flush + reset, and the middleware's `terminate()` | Nothing extra, both together is harmless |
| Queue workers | `JobProcessed` and `JobFailed` flush + reset | Nothing |
| Artisan commands | `CommandFinished` flush only | Looping commands call `resetScope()` per iteration |
| RoadRunner | Not event driven | Call `flush()` + `resetScope()` at each request boundary |
| Swoole / OpenSwoole | Per coroutine scope via `Swoole\Coroutine::getContext()` | Nothing, concurrent requests never share scope |

`CommandFinished` deliberately flushes without resetting, because a command's scope is usually still
wanted while the command runs. A command with its own consumer loop must reset itself:

```php
// app/Console/Commands/ConsumeOrders.php
$client = app(\NewInstance\BugWatch\Client::class);

while ($message = $stream->next()) {
    $client->setTag('order_id', $message->orderId);
    $this->process($message);
    $client->flush();
    $client->resetScope();   // without this, order_id leaks into the next iteration
}
```

## Tracing in Laravel

Spans are attributed to `BUGWATCH_SERVICE_NAME`, so give each app, worker fleet and API a distinct value.
The middleware already joins the inbound trace, so a span created during a request continues the caller's
trace rather than starting a new one.

Resolve the container client and wrap the unit of work:

```php
use NewInstance\BugWatch\Client;
use NewInstance\BugWatch\Tracing\Span;

$client = app(Client::class);

$total = $client->withSpan('cart.total', function (Span $span) use ($cart) {
    $span->setAttr('cart.id', $cart->id);

    return $cart->recalculate();
}, ['kind' => 1]);   // OTel kind: 1 internal, 2 server, 3 client, 4 producer, 5 consumer
```

`withSpan` times the callback, links captures and logs made inside it to the span, records a thrown
exception on the span and rethrows it.

For outbound HTTP, propagate the header so the next service joins the trace:

```php
$client->withSpan('http POST billing/charge', function (Span $span) use ($payload) {
    return Http::withHeaders(['traceparent' => $span->traceparent()])
        ->post('https://billing.internal.example.com/charge', $payload);
}, ['kind' => 3]);
```

Outside a span, `NewInstance\BugWatch\BugWatch::traceHeaders()` returns
`['traceparent' => '...']` from the active scope, or `[]` when there is no trace. Note that the static
facade uses its own client, so in Laravel prefer `$span->traceparent()`, or build the header from the
container client with `TraceContext::buildTraceparent(...)` and `$client->getTraceContext()`.

Span export caveats: spans need a `projectKey`, they bypass `sampleRate`, `beforeSend` and redaction, and
the buffer holds 200 spans (auto-flushing whenever it reaches `batchSize`, and on every `flush()`). Keep sensitive data out of span attributes.

## Browser session minting

Your project key must never reach the browser. Register the bundled controller and point the JS SDK at it.

```php
// routes/web.php
use NewInstance\BugWatch\Laravel\Http\BrowserSessionController;

Route::post('/bugwatch/session', BrowserSessionController::class)->middleware('auth');
```

It reads `bugwatch.key` and `bugwatch.endpoint` from config, mints a short lived token, and returns
`{ token, expiresAt }`, or a 502 with `{"error": "failed to mint BugWatch session"}` on failure. Always
put it behind auth. In the browser, init `@newinstance/bugwatch` with `sessionUrl: '/bugwatch/session'`.

## Complete example

```dotenv
# .env
BUGWATCH_KEY="sk_live_abc123:secret"
BUGWATCH_RELEASE="1.3.2"
BUGWATCH_SERVICE_NAME="checkout-api"
LOG_CHANNEL=stack
```

```php
// config/logging.php
'channels' => [
    'stack'    => ['driver' => 'stack', 'channels' => ['daily', 'bugwatch']],
    'daily'    => ['driver' => 'daily', 'path' => storage_path('logs/laravel.log'), 'days' => 14],
    'bugwatch' => ['driver' => 'bugwatch'],
],
```

```php
// bootstrap/app.php
->withMiddleware(function (Illuminate\Foundation\Configuration\Middleware $middleware) {
    $middleware->web(append: [\NewInstance\BugWatch\Laravel\BugWatchContextMiddleware::class]);
})
```

```php
// app/Providers/AppServiceProvider.php - boot()
use Illuminate\Http\Request;
use NewInstance\BugWatch\Laravel\BugWatchContextMiddleware;

BugWatchContextMiddleware::resolveUserUsing(fn (Request $r): ?array => $r->user()
    ? ['id' => (string) $r->user()->id, 'email' => $r->user()->email]
    : null);
```

```php
// app/Jobs/ProcessPayment.php
namespace App\Jobs;

use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Log;
use NewInstance\BugWatch\Client;
use NewInstance\BugWatch\Tracing\Span;

final class ProcessPayment implements ShouldQueue
{
    public function __construct(private readonly int $orderId, private readonly string $userId)
    {
    }

    public function handle(Client $bugwatch): void
    {
        $bugwatch->setTag('order_id', (string) $this->orderId);
        $bugwatch->setUser(['id' => $this->userId]);

        try {
            $bugwatch->withSpan('payment.charge', function (Span $span) {
                $span->setAttr('payment.gateway', 'paystack');

                return app(\App\Services\Gateway::class)->charge($this->orderId);
            }, ['kind' => 3]);
        } catch (\Throwable $e) {
            Log::channel('bugwatch')->error('Payment job failed', ['exception' => $e]);
            throw $e;
        }
        // JobProcessed / JobFailed flush and reset the scope for you
    }
}
```

## Troubleshooting

**Nothing arrives and nothing errors.** The provider disables the client when `bugwatch.key` is empty, by
design. Check `BUGWATCH_KEY` is a valid `<keyId>:<secret>` string, `BUGWATCH_ENABLED` is not `false`,
`BUGWATCH_SAMPLE_RATE` is not `0.0`, then run `php artisan config:clear` after editing `.env`. Cached
config is the usual culprit.

**`Log::error()` produces no event.** The `bugwatch` channel is missing from `config/logging.php`, or
`LOG_CHANNEL` points at a channel or stack that does not include it. Also check `BUGWATCH_LEVEL` is not
above the level you are logging.

**Static `BugWatch::` calls do nothing.** The static facade holds its own client and self disables unless
you call `BugWatch::init()`. The provider only binds `NewInstance\BugWatch\Client` in the container. In
Laravel, inject or resolve `Client` (as in the job above) rather than using the static facade.

**Double reporting.** An exception both caught and logged to the `bugwatch` channel and then rethrown will
be captured twice, once by the log handler and once by the exception handler decorator. Either do not
rethrow after logging, or set `BUGWATCH_CAPTURE_EXCEPTIONS=false` and rely on the log channel alone.

**Worker loop leakage.** Users or tags from job A showing up on job B means something bypassed the reset:
a custom `queue:work` wrapper that does not fire `JobProcessed`, or a looping Artisan command (only
flushed, never reset). Call `$client->resetScope()` at your own boundary, or skip shared scope entirely
and pass `user` and `tags` per capture through `captureException($e, ['user' => ..., 'tags' => ...])`.
