# PHP

BugWatch for PHP captures exceptions, messages and structured logs from any PHP 8.2+ application. It integrates deeply with Laravel and has adapters for Monolog, PSR-3 and native error handling.

## Requirements

- PHP **8.2+**; hard deps are `psr/log ^3`, `psr/http-client ^1`, `psr/http-factory ^1`, `psr/http-message ^1|^2`
- Default transport is cURL with a PHP-streams fallback; a PSR-18 client is used only if you inject one via `httpClient`
- Laravel 11 to 13 via the bundled auto-discovered service provider; Monolog 2/3 optional

## 1. Install

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

## 2. Initialise

Plain PHP:

```php
use NewInstance\BugWatch\BugWatch;

BugWatch::init([
    'projectKey' => getenv('BUGWATCH_KEY'),
    'release'    => getenv('APP_VERSION'),
]);
```

Laravel: set the env keys and you are done; publish the config to tune more:

```bash
BUGWATCH_KEY=sk_live_KEYID:secret
BUGWATCH_RELEASE=2.4.1
BUGWATCH_ENABLED=true
BUGWATCH_CAPTURE_EXCEPTIONS=true

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

Multi-tenant or multi-project setups construct isolated clients with `createClient([...])` instead of the global singleton.

## 3. Configuration

| Option | Default | What it does |
|--------|---------|--------------|
| `projectKey` | None | Server credential (KEYID:secret). Environment is bound to the key server-side: one key per environment, no environment option in this SDK |
| `release` | None | Version label on every event |
| `serviceName` | None | Logical service name stamped on spans (OTel `service.name`); powers the service map. Tracing only, not on events |
| `enabled` | true | Master switch. A disabled client returns `''` from capture calls |
| `endpoint` / `sessionUrl` | prod API / none | Must be valid http(s) URLs or `ConfigException` is thrown |
| `sampleRate` | 1.0 | Range-validated 0.0 to 1.0; a sampled-out event still returns an id (freshly generated unless the input carried its own `eventId`) |
| `sensitiveFields` | `[]` | Your keys merge with the built-in redaction list |
| `batchSize` | 50 | Validated 1 to 5000; the queue flushes automatically when it fills |
| `maxQueueSize` | 1000 | Queue cap |
| `requestTimeout` | 15000 ms | Per-request timeout |
| `retry` | 3 attempts, 200 ms to 5 s, x2 | Exponential backoff |
| `beforeSend` / `httpClient` / `debug` | none / none / false | Event filter, custom PSR-18 transport, diagnostics |

Flushing happens when the queue reaches `batchSize`, on explicit `flush()`, and at shutdown. `flush()` also drains buffered spans. Under PHP-FPM the shutdown flush runs after `fastcgi_finish_request()`, so delivery adds no response latency.

Laravel maps key, endpoint, release, service name (`BUGWATCH_SERVICE_NAME`), enabled, sample rate and sensitive fields from `config/bugwatch.php`; the remaining options require `createClient` or plain `init()`.

## 4. Capture API

```php
$id = BugWatch::captureException($e, ['tags' => ['route' => $routeName], 'traceId' => $traceId, 'spanId' => $spanId]);
BugWatch::captureMessage('Sync finished', 'info');
BugWatch::captureLog(['level' => 'warn', 'message' => 'Slow query', 'tags' => ['db' => 'orders']]);
BugWatch::setUser(['id' => 'u_123', 'email' => 'ada@example.com']);
BugWatch::setTags(['tenant' => 't_42']);
BugWatch::setContext('payment', ['provider' => 'paystack']);
BugWatch::setRelease('checkout@2.4.1');
BugWatch::setFingerprint(['checkout', 'GATEWAY_TIMEOUT']);
BugWatch::withScope(function ($scope) use ($e) { $scope->setTag('job', 'sync'); BugWatch::captureException($e); });
BugWatch::resetScope();
BugWatch::flush();
BugWatch::close();
```

- Levels accept BugWatch numerics, PSR-3 names and Monolog ints (notice maps to 30; critical, alert and emergency map to 60).
- Only `id`, `email`, `username`, `ip` survive on `user`; other keys are dropped before send.
- Chained `getPrevious()` exceptions serialise into a `causes` array (rendered as the Caused-by chain on the issue page).
- The `captureException` hint may carry `traceId`/`spanId`; a valid hint trace wins over the scope's trace context wholesale (the scope span is never mixed into a hinted trace).
- Capturing the same Throwable instance twice returns the first event id (WeakMap dedupe, per client instance: two `createClient` clients each report it once).
- `client()` exposes the underlying client; `diagnostics()` returns delivery counters.

## 5. Laravel integration

**Deeper pages in this section's sidebar:** Laravel (full guide incl. Octane, queues, tracing), Plain PHP (front controllers, native error handlers, any framework), and Monolog & PSR-3 (log forwarding). The sections below are the quick summary.

- **Exceptions**: reported automatically; disable with `BUGWATCH_CAPTURE_EXCEPTIONS=false`.
- **`Log::*` to BugWatch**: add a channel with `'driver' => 'bugwatch'` in `config/logging.php` (standalone or inside a stack); without it, Laravel logs never reach BugWatch.
- **Per-request users and trace joining**: register `BugWatchContextMiddleware`; it scopes the authenticated user (id only by default), sets `method`, `url` (the request path) and `route` (when the route has a name) tags, reads the inbound `traceparent` header so the request joins the caller's trace, and resets on termination. Customise identity with `BugWatchContextMiddleware::resolveUserUsing(fn ($request) => [...])` from a provider `boot()` (closures registered elsewhere break `config:cache`); a throwing resolver never affects the request.
- **Long-lived runtimes**: Octane's `RequestTerminated` plus queue `JobProcessed`/`JobFailed` flush and reset the scope automatically. Artisan `CommandFinished` only flushes, so looping commands call `resetScope()` themselves. Plain PHP-FPM apps also flush at end of request via the framework `terminating()` hook, even without the middleware. RoadRunner has no automatic hook: use the middleware or manual `flush()` + `resetScope()` per request. With `ext-swoole` loaded, scope is stored per coroutine automatically.
- **Browser session mint**: `BrowserSessionController` is a ready-made route for the browser token flow; non-Laravel backends call `mintBrowserSession(['projectKey' => ...])` which returns `['token', 'expiresAt']` (see **Browser Ingest**).

## 6. Distributed tracing

The PHP SDK creates spans and joins W3C traces, so a PHP service appears on the same waterfall as the JS services calling it (or that it calls). Set `serviceName` so spans land on the right service-map node.

**Joining a trace.** In Laravel the context middleware reads `traceparent` automatically. Anywhere else:

```php
use NewInstance\BugWatch\TraceContext;

$parsed = TraceContext::parseTraceparent($_SERVER['HTTP_TRACEPARENT'] ?? null);
if ($parsed !== null) {
    BugWatch::setTraceContext($parsed['traceId'], $parsed['spanId']);
}
```

IDs are validated strictly (32 and 16 lowercase hex, all-zero rejected). `getTraceContext()` reads the active context; `setTraceContext(null, null)` clears it.

**Spans.** `withSpan(name, fn, opts)` times the callback, links captures and logs inside it to the span, records a thrown exception on the span (type, message, stacktrace, error status), restores the scope and rethrows. `startSpan` gives manual control with `setAttr`, `recordException`, `end()` (idempotent) and `$span->traceparent()`. Options: `kind` (1 internal, 2 server, 3 client, 4 producer, 5 consumer), `attrs`, `traceId`, `parentSpanId`, `links` (a queue consumer links to the producer's `['traceId' => ..., 'spanId' => ...]` and the service map draws the async edge). `code.filepath`/`code.lineno`/`code.function` attributes surface the source location on the span detail.

```php
$result = BugWatch::withSpan('db.query load-cart', function ($span) use ($cartId) {
    $span->setAttr('db.system', 'mysql');
    return loadCart($cartId);
}, ['kind' => 3]);
```

**Propagating out.** `BugWatch::traceHeaders()` returns `['traceparent' => ...]` (or `[]`) for outbound HTTP; inside `withSpan` prefer `$span->traceparent()` so the child links to that exact span. `TraceContext::buildTraceparent($traceId, $spanId)` builds the header from raw ids.

**Limits and delivery.** 50 attributes (200-char keys and values, scalars only), 20 events, 10 links, 200-char names, 8000-char exception stacktraces, 200-span buffer with oldest dropped. Spans post as OTLP JSON to `POST /v1/traces` with the project key and flush with `flush()` and the Laravel lifecycle hooks. Caveats: span export uses cURL directly (`httpClient`, `retry`, `beforeSend`, `sampleRate` and redaction apply to events only, so keep sensitive data out of span attributes), and a `sessionUrl`-only client captures events but exports no spans.

## 7. Logging adapters

```php
$log->pushHandler(new \NewInstance\BugWatch\Integration\Monolog\Handler(BugWatch::client(), 'warning'));
```

- One Monolog handler class serves Monolog 2 arrays and Monolog 3 `LogRecord`; level defaults to `debug`, the third argument is `$bubble`; channel plus scalar context and extra become tags; it never throws into Monolog.
- `getLogger()` returns a PSR-3 logger with `{placeholder}` interpolation; a Throwable in context becomes a captured exception.
- `ErrorHandler::install($client, ['exceptions' => true, 'errors' => true, 'shutdown' => true])` chains existing handlers, respects `error_reporting()` and `@`, is recursion-guarded, and returns a handle with `uninstall()`.

## Testing

```php
use NewInstance\BugWatch\Client;
use NewInstance\BugWatch\Config;
use NewInstance\BugWatch\Testing\InMemoryTransport;

$transport = new InMemoryTransport();
$client = new Client(Config::fromArray(['projectKey' => 'k:s']), $transport);
```

Assert on `$transport->events`; set `$transport->result = false` to simulate delivery failure. The transport receives events only: spans bypass it. To intercept spans, pass a sender callable as the second constructor argument of `Tracing\SpanExporter` (it receives the OTLP JSON body, headers and URL and returns `['status' => 200]`).

## Troubleshooting

- **Silent with a missing key**: Laravel self-disables the client; plain `init()` swallows the `ConfigException` and writes `[BugWatch] disabled: ...` to `error_log` unless `debug => true`.
- **Nothing arrives from a worker loop**: call `flush()` and `resetScope()` at each unit-of-work boundary, otherwise the previous job's identity leaks into the next and events sit queued.
- **Laravel reports twice**: remove manual `captureException` calls from your own handler once automatic capture is on.

Wire calls this SDK makes: `POST /api/v1/bugwatch/ingest` with `Content-Type: application/x-ndjson` and `x-api-key`, `POST /api/v1/bugwatch/browser-session` for the browser flow, and `POST /v1/traces` (OTLP JSON) for spans.
