# Plain PHP

The BugWatch PHP SDK has no framework dependency. The core works the same in a raw front controller, Slim, Symfony, a CLI script, or a custom worker loop. Integrations activate only when their library is present, so nothing is pulled in that you do not use.

## Install

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

Requires PHP 8.2 or higher. `ext-curl` is optional and used for the default transport, with a PHP stream fallback when it is absent.

Store the key in the environment, never in code:

```dotenv
BUGWATCH_KEY="<keyId>:<secret>"
APP_VERSION="1.3.2"
```

Get the key from [www.newinstance.cloud](https://www.newinstance.cloud) under your project's Settings then API Keys, with the `ingest:write` scope. The key format is `<keyId>:<secret>` and is server-side only.

## Initialise

Call `BugWatch::init()` once, as early as possible in your bootstrap:

```php
use NewInstance\BugWatch\BugWatch;

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

After `init()` the static facade (`BugWatch::captureException()`, `BugWatch::setTag()`, and so on) works anywhere in the process. If you need more than one configuration, for example a multi-tenant app sending to different projects, use `createClient()` instead and pass the instance around:

```php
use function NewInstance\BugWatch\createClient;

$client = createClient(['projectKey' => getenv('BUGWATCH_KEY')]);
$client->captureException($e);
```

`createClient()` returns a `Client` with the same API surface as the facade. One difference: only `BugWatch::init()` registers the automatic shutdown flush, so a `createClient()` instance must call `flush()` (or `close()`) itself before the process exits.

## Native error handlers

`try/catch` cannot see uncaught exceptions, PHP warnings, or fatal shutdowns. `ErrorHandler::install()` registers handlers for all three. It is opt-in by design: the SDK never installs global handlers on its own.

```php
use NewInstance\BugWatch\Handlers\ErrorHandler;

BugWatch::init(['projectKey' => getenv('BUGWATCH_KEY')]);
ErrorHandler::install(BugWatch::client());
```

Each of the three handlers can be toggled independently. All three default to `true`:

```php
$handler = ErrorHandler::install(BugWatch::client(), [
    'exceptions' => true,   // set_exception_handler
    'errors'     => false,  // set_error_handler
    'shutdown'   => true,   // register_shutdown_function
]);
```

What each one does:

- `exceptions`: captures the uncaught `Throwable` at level `fatal` with a `handled=false` tag, flushes immediately, then calls the previously registered exception handler if there was one. Your existing handler still runs.
- `errors`: captures `E_WARNING`, `E_NOTICE`, `E_DEPRECATED` and friends as log events, mapped to `warning`, `notice`, `info`, or `error` by errno, tagged with `file`, `line`, `errno`, and `handled=false`. It returns `false`, so PHP's normal error handling continues to run as well.
- `shutdown`: on shutdown, inspects `error_get_last()` and captures only if the last error is `E_ERROR`, `E_PARSE`, `E_CORE_ERROR`, or `E_COMPILE_ERROR`. Anything else is ignored, so a clean request produces nothing. Fatal captures are flushed before the process dies.

The error handler respects `error_reporting()`. A diagnostic suppressed with the `@` operator, or one below your `error_reporting` threshold, is skipped entirely. All handlers are recursion guarded: if capture itself raises, the exception is swallowed rather than being allowed to escape into your application. `ErrorHandler::install()` is process-wide and bound to the first client it was installed with; a second `install()` with a different client keeps the first.

Removing the handlers:

```php
$handler->uninstall();
```

`uninstall()` restores the previous exception and error handlers. PHP cannot unregister a shutdown function, so the shutdown handler stays registered but stays a no-op unless `error_get_last()` reports a fatal type. Calling `install()` again after `uninstall()` will not register a duplicate shutdown function.

## Capturing manually

```php
$eventId = BugWatch::captureException($e);

$eventId = BugWatch::captureException($e, [
    'level' => 'fatal',
    'tags'  => ['component' => 'checkout'],
    'user'  => ['id' => 'u_123'],
]);

BugWatch::captureMessage('Payment gateway timed out', 'warn');

BugWatch::captureLog([
    'level'       => 'error',
    'message'     => 'Order creation failed',
    'exception'   => $e,
    'tags'        => ['order_id' => '1234', 'gateway' => 'paystack'],
    'user'        => ['id' => 'u_123'],
    'fingerprint' => 'order-creation-failure',
]);
```

## Per-request isolation outside Laravel

Under PHP-FPM, mod_php, or a one-shot CLI script, every request is its own OS process, so scope is process private and nothing can bleed between requests. You do not have to do anything.

When the `swoole` extension is loaded, the SDK stores each coroutine's scope in `Swoole\Coroutine::getContext()`, so concurrent requests inside one worker never share a user or tags. Also automatic.

The case that needs attention is a persistent, sequential runtime: RoadRunner, a hand-rolled accept loop, or a long-running consumer. One process serves many units of work in order, so state set for one survives into the next unless you clear it.

```php
// Wrong on a persistent worker: request B inherits request A's user
while ($request = $server->accept()) {
    BugWatch::setUser(['id' => $request->userId]);
    handle($request);
}

// Correct: flush and reset at the boundary
while ($request = $server->accept()) {
    BugWatch::setUser(['id' => $request->userId]);
    handle($request);
    BugWatch::client()->flush();
    BugWatch::client()->resetScope();
}
```

`resetScope()` wipes user, tags, context, release, fingerprint, and the trace context. Two alternatives avoid the shared scope altogether:

```php
// withScope: state set inside is discarded on return, outer scope untouched
BugWatch::withScope(function ($scope) use ($tenantId) {
    $scope->tags['tenant'] = $tenantId;
    $scope->user = ['id' => 'u_999'];
    BugWatch::captureMessage('Scoped event', 'warn');
});

// Explicit per-capture context: nothing shared at all
BugWatch::captureException($e, [
    'user' => ['id' => $userId],
    'tags' => ['route' => $routeName, 'tenant' => $tenantId],
]);
```

Explicit per-capture context is the safest default for any custom worker loop, because every event is self-contained.

## Joining a trace

Outside Laravel there is no middleware reading the inbound `traceparent` header, so parse it yourself at the top of the request:

```php
use NewInstance\BugWatch\TraceContext;

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

`parseTraceparent()` returns `['traceId' => ..., 'spanId' => ...]` or `null`. Validation is strict: 32 and 16 hex characters (uppercase input is normalised to lowercase), all-zero IDs rejected. Invalid input yields `null` rather than an exception, so the guard above is all you need. `BugWatch::setTraceContext(null, null)` clears the context, and this should happen alongside `resetScope()` on a persistent runtime.

On outbound calls, propagate the context so the next service joins your trace:

```php
$headers = BugWatch::traceHeaders(); // ['traceparent' => '00-<traceId>-<spanId>-01'] or []
```

Inside a `withSpan()` callback, prefer `$span->traceparent()` so the downstream service links to that exact span rather than the scope's current one.

## Flushing at the end of a request

`BugWatch::init()` registers a shutdown function that flushes the queue, so a normal request needs no explicit call. Under PHP-FPM that shutdown function calls `fastcgi_finish_request()` first, which returns the HTTP response to the client before the events are transmitted, so ingest adds no latency to the response.

Call `flush()` yourself when the process will not exit soon: at the end of each iteration of a worker loop, before a long sleep, or right before a hard `exit`. It returns `bool`. The queue is bounded by `maxQueueSize` (default 1000) and drops oldest first on overflow, so flushing more often keeps worker memory flat.

## Complete example: front controller

```php
<?php
// public/index.php

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

use NewInstance\BugWatch\BugWatch;
use NewInstance\BugWatch\Handlers\ErrorHandler;
use NewInstance\BugWatch\TraceContext;

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

ErrorHandler::install(BugWatch::client());

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

BugWatch::setTag('method', $_SERVER['REQUEST_METHOD'] ?? 'CLI');
BugWatch::setTag('path', parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?? '/');

if ($userId = currentUserId()) {
    BugWatch::setUser(['id' => (string) $userId]);
}

try {
    $response = router()->dispatch($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI']);
    http_response_code($response->status);
    echo $response->body;
} catch (\Throwable $e) {
    BugWatch::captureException($e, ['tags' => ['component' => 'router']]);
    http_response_code(500);
    echo json_encode(['error' => 'Internal server error']);
}

// The shutdown hook flushes after fastcgi_finish_request(); no explicit flush needed here.
```

## Troubleshooting

Events do not appear in the dashboard. Set `'debug' => true` in `init()` and check `error_log` for `[BugWatch]` entries. Then call `BugWatch::flush()` explicitly and watch for transport errors. Confirm `BUGWATCH_KEY` is a valid `<keyId>:<secret>` string, that `enabled` is not `false`, and that `sampleRate` is not `0.0`.

`BugWatch: "projectKey" (or "sessionUrl") is required.` means `init()` ran with `enabled` true but no key. Load your environment before `init()`.

Uncaught exceptions are not captured. Check that `ErrorHandler::install()` runs after `BugWatch::init()` and that nothing later in your bootstrap calls `set_exception_handler()` without chaining. The SDK chains the handler that existed when it installed, but a framework installing its own afterwards can displace it.

Warnings are not captured. The error handler honours `error_reporting()` and the `@` operator by design. Raise `error_reporting` or remove the suppression on the call site you care about.

Fatal errors are missing. Only `E_ERROR`, `E_PARSE`, `E_CORE_ERROR`, and `E_COMPILE_ERROR` are captured from the shutdown path. A memory exhaustion fatal may also leave too little memory for the flush to complete; lower `memory_limit` headroom pressure or reduce `batchSize`.

Worker memory keeps growing. `maxQueueSize` bounds the buffer at 1000 events by default and drops oldest first. Call `flush()` at each iteration boundary rather than raising the limit.

Events land on the wrong trace. On a persistent runtime, `setTraceContext()` persists until it is changed or cleared. Call `resetScope()` at each boundary or pass `traceId` and `spanId` in the capture hint.
