---
title: Chronicle
published: 2026-11-02
lastmod: 2026-11-03
url: https://larasell.dev/docs/packages/chronicle
---

## Table of contents
- [Installation](#installation)
- [Structured exceptions](#structured-exceptions)
  - [Throw a structured exception](#throw-a-structured-exception)
  - [Write your own exception](#write-your-own-exception)
- [Wide events](#wide-events)
  - [Log an event](#log-an-event)
  - [Add data to an event](#add-data-to-an-event)
  - [Log a unit of work](#log-a-unit-of-work)
  - [Log jobs](#log-jobs)
  - [Global fields](#global-fields)
  - [What to log](#what-to-log)
- [Request logging](#request-logging)
  - [Request ids](#request-ids)
  - [Sample requests](#sample-requests)
- [Choose where events go](#choose-where-events-go)
- [Send events to PostHog](#send-events-to-posthog)
- [Redact secrets](#redact-secrets)
- [Limits](#limits)
- [Configuration](#configuration)
- [Testing](#testing)

# Chronicle

Chronicle adds two things to your Laravel app: structured exceptions and wide event logging.

Structured exceptions give an exception a stable code, the reason it happened, and a fix. You can
count and alert on an error by its code, and whoever reads it knows what to do next.

Wide event logging writes one event per unit of work, like a request or a job, that holds
everything your app knows about it. When something goes wrong, you read one event instead of
piecing the story together from scattered log lines.

## Installation

Chronicle requires PHP 8.4 and Laravel 12 or 13. Install it with Composer:

```bash
composer require larasell-dev/chronicle
```

Laravel discovers the service provider. To set a sampler or change other defaults, publish the
config file to `config/chronicle.php`:

```bash
php artisan vendor:publish --tag=chronicle.config
```

## Structured exceptions

A normal exception has a class and a message. The message often contains values, like an order
number or a team id, so two occurrences of the same error rarely have the same text. That
makes the error hard to count, group, or alert on. The message also seldom says what to do about
the error.

A structured exception is an exception with four more fields:

- `errorCode` is a stable id, like `teams.could-not-invite-member.already-invited`. It stays the
  same when the values change or you reword the message, so you can count, group, and alert on it.
  It is separate from PHP's own `getCode()`, which Chronicle logs as `error.exception_code`.
- `why` says why the error happened.
- `fix` says what to do about it.
- `link` points to more information, like a page in your docs.

### Throw a structured exception

For an error that needs no class of its own, throw one of Chronicle's structured exceptions. Each
one extends the PHP exception of the same name, so your `catch` blocks keep working:

```php
use Larasell\Chronicle\Exceptions\StructuredInvalidArgumentException;

throw new StructuredInvalidArgumentException(
    errorCode: 'billing.invalid-amount',
    message: "Amount must be positive, got {$amount}",
    why: 'Charges need an amount greater than zero',
    fix: 'Check the order total before you charge it',
);
```

They also accept a `link` and a `previous` exception. Only `errorCode` and `message` are required.
Chronicle has a structured version of each of PHP's
[SPL exceptions](https://www.php.net/manual/en/spl.exceptions.php):

- `StructuredLogicException`, `StructuredBadFunctionCallException`,
  `StructuredBadMethodCallException`, `StructuredDomainException`,
  `StructuredInvalidArgumentException`, `StructuredLengthException`, and
  `StructuredOutOfRangeException`.
- `StructuredRuntimeException`, `StructuredOutOfBoundsException`, `StructuredOverflowException`,
  `StructuredRangeException`, `StructuredUnderflowException`, and
  `StructuredUnexpectedValueException`.

All of them live in the `Larasell\Chronicle\Exceptions` namespace.

### Write your own exception

For an error you throw from several places, or want to catch by its class, write your own
exception and implement the
`StructuredException`{href="https://raw.githubusercontent.com/Larasell-dev/chronicle/refs/heads/main/src/Exceptions/StructuredException.php"}
interface. The `HasStructuredException`{href="https://raw.githubusercontent.com/Larasell-dev/chronicle/refs/heads/main/src/Exceptions/HasStructuredException.php"}
trait implements it and adds a `structured` constructor:

```php
use Larasell\Chronicle\Exceptions\HasStructuredException;
use Larasell\Chronicle\Exceptions\StructuredException;

final class CouldNotInviteMember extends Exception implements StructuredException
{
    use HasStructuredException;

    public static function alreadyInvited(Team $team): self
    {
        return self::structured(
            errorCode: 'teams.could-not-invite-member.already-invited',
            message: "Could not invite a member to team {$team->id}: they already have a pending invitation.",
            why: 'The member already has a pending invitation to this team',
            fix: 'Resend the existing invitation or remove it first',
        );
    }
}
```

`structured` also accepts a `link` and a `previous` exception. Only `errorCode` and `message` are
required. The fields are set once, while the exception is built. Read them with `errorCode()`,
`errorWhy()`, `errorFix()`, and `errorLink()`.

Keep personal data, like email addresses, out of `message`, `why`, and `fix`. They end up in your
logs as they are.

## Wide events

Most apps log as the code runs. One line when a checkout starts, one when the cart loads, one when
the payment fails. Each line holds a few facts, and to debug a request you find its lines and put
them back together:

```text
[2026-11-02 14:03:11] production.INFO: Checkout started {"user_id":42}
[2026-11-02 14:03:11] production.INFO: Cart loaded {"items":3,"total":8900}
[2026-11-02 14:03:12] production.ERROR: Payment failed: card declined
```

A wide event works the other way around. Your app collects facts while it does the work and
writes them once, at the end, as a single event with many fields. The same checkout becomes one
event:

```json
{
    "timestamp": "2026-11-02T14:03:12+00:00",
    "type": "request",
    "method": "POST",
    "route": "checkout.store",
    "status": 500,
    "duration_ms": 812.4,
    "user.id": 42,
    "cart.items": 3,
    "cart.total": 8900,
    "error.code": "billing.card-declined",
    "error.message": "Payment failed: card declined"
}
```

Because every fact about the work sits in the same event, you can ask questions you did not plan
for when you wrote the code. For example, "which checkouts with more than 10 items took longer
than a second?" Normal logs can't answer that, because the cart size and the duration are on
different lines.

### Log an event

Log an event for each unit of work whose outcome you want to look up later, for example:

- A queued job finished, like an inventory sync or an export.
- A scheduled task or an Artisan command ran.
- Something happened in your business, like an order was paid or a refund was issued.
- Your app called an external API, and you want to know how long it took and what it returned.

Requests are the exception: [request logging](#request-logging) logs them for you.

To log an event, call `record` with a type and the fields. This queued job logs the result of an
inventory sync:

```php
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Larasell\Chronicle\WideEvents\Chronicle;

final class SyncInventory implements ShouldQueue
{
    use Queueable;

    public function handle(Warehouse $warehouse, Chronicle $chronicle): void
    {
        $startedAtNanoseconds = hrtime(true);

        $result = $warehouse->sync();

        $chronicle->record('inventory.synced', [
            'products' => ['updated' => $result->updated, 'failed' => $result->failed],
            'duration_ms' => round((hrtime(true) - $startedAtNanoseconds) / 1_000_000, 2),
        ]);
    }
}
```

Chronicle writes this event, on a single line in the log file:

```json
{
    "timestamp": "2026-11-03T09:12:44+00:00",
    "type": "inventory.synced",
    "products.updated": 1840,
    "products.failed": 3,
    "duration_ms": 5120.37
}
```

Chronicle adds `timestamp` and `type`, and flattens nested arrays into dotted keys, so the example
writes `products.updated` and `products.failed`. A list, like `['pro', 'shop']`, becomes a JSON
string. Objects are turned into plain data first and follow the same rules. Every event has
[limits](#limits).

Pass a level as the third argument to write the event at a level other than `info`, either as a
name or as Chronicle's `Level` enum:

```php
use Larasell\Chronicle\WideEvents\Enums\Level;

$chronicle->record('inventory.low', ['sku' => $sku], Level::Warning);
$chronicle->record('inventory.low', ['sku' => $sku], 'warning');
```

Errors are events too. Laravel's exception handler only sees the exceptions your app does not
catch. When your app catches an exception and carries on, for example by showing the customer a
message, the failure leaves no trace in your logs. Pass the exception to `recordError` to log an
`error` event:

```php
use Larasell\Chronicle\WideEvents\Chronicle;

try {
    $gateway->charge($order);
} catch (PaymentFailed $exception) {
    app(Chronicle::class)->recordError($exception);

    return back()->withErrors(['payment' => $exception->getMessage()]);
}
```

Every error event contains these fields:

- `error.message`: the exception message.
- `error.code`: a stable code, like `billing.card-declined`.
- `error.why`: why the error happened.
- `error.fix`: what the reader can do about it.
- `error.link`: a link to more information.
- `error.exception_code`: PHP's own code from `getCode()`, like a `PDOException`'s SQLSTATE, or
  `null` when it is `0` or empty.

`error.code`, `error.why`, `error.fix`, and `error.link` are `null` unless the exception is a
[structured exception](#structured-exceptions). Recording the exception from
[Write your own exception](#write-your-own-exception):

```php
app(Chronicle::class)->recordError(CouldNotInviteMember::alreadyInvited($team));
```

writes this event:

```json
{
    "timestamp": "2026-11-03T09:12:44+00:00",
    "type": "error",
    "error.code": "teams.could-not-invite-member.already-invited",
    "error.exception_code": null,
    "error.message": "Could not invite a member to team 42: they already have a pending invitation.",
    "error.why": "The member already has a pending invitation to this team",
    "error.fix": "Resend the existing invitation or remove it first",
    "error.link": null
}
```

The message names the team, but `error.code` stays the same for every team where an invitation
fails this way.

Error events use the `error` level by default. `recordError` takes its arguments in the same
order as `record`: extra fields second, the level last:

```php
app(Chronicle::class)->recordError($exception, ['order' => ['id' => $order->id]], 'warning');
```

If the exception has a `context` method that returns an array, Chronicle adds those fields too.
Fields you pass to `recordError` replace fields from the exception with the same key. A `context`
method that returns something else throws when `APP_DEBUG` is `true`. Otherwise Chronicle still
logs the error and says why its context is missing in `chronicle.context_error`.

Logging an event and adding data to an event are different things. `record` writes a new event
right away. [Adding data](#add-data-to-an-event) writes nothing by itself. It adds fields to the
events your app writes later, like the job's event above or the request's event. Log an event for
work that has its own outcome. Add data for facts about work that is logged anyway.

### Add data to an event

A wide event is only as useful as the fields in it. To add data to an event, use Laravel's
`Context`{href="https://laravel.com/docs/context"}. Add what you know wherever you know it, and
Chronicle adds it to every event written afterwards:

```php
use Illuminate\Support\Facades\Context;

Context::add([
    'cart' => [
        'items' => $cart->items->count(),
        'total' => $cart->total,
    ],
]);
```

The example writes `cart.items` and `cart.total` to every event of the request or job. Laravel
passes Context on to the jobs a request dispatches, so a job's events carry the fields of the
request that dispatched it, like its `request.id`.

A field you pass to `record` replaces a `Context` field with the same key.

To add a field only to the event of the current [request](#request-logging) or
[unit of work](#log-a-unit-of-work), use `add`. Unlike Context, the field isn't added to other
events or passed on to jobs. Outside a request or unit of work, `add` discards the field:

```php
use Larasell\Chronicle\Facades\Chronicle;

Chronicle::add('payment', ['provider' => 'stripe', 'attempts' => 2]);
```

### Log a unit of work

To log work that isn't a request, like an import in a command or a job, wrap it in `scope`.
Chronicle writes one event of the type you pass when the work ends. It contains the fields you
`add` while it runs and its `duration_ms`. If the work throws, the event also contains the
[error fields](#log-an-event), is an `error` event, and `scope` throws the exception on:

```php
use Larasell\Chronicle\Facades\Chronicle;

$imported = Chronicle::scope('products.imported', function () use ($file) {
    $rows = $this->importer->import($file);

    Chronicle::add('import', ['file' => $file->name, 'rows' => count($rows)]);

    return $rows;
});
```

`scope` returns what the callback returns. Inside a request, the scope writes its own event, and
`add` within the callback adds to that event, not to the request's.

### Log jobs

To log every queued job without wrapping it in `scope`, set `CHRONICLE_LOG_JOBS` in your `.env`
file:

```ini
CHRONICLE_LOG_JOBS=true
```

Chronicle then writes one event of type `job` for every attempt of a job. It contains the fields
you `add` while the job runs, its `duration_ms`, and these fields:

- `job.name` is the job's class.
- `job.id` is the job's UUID, the same for every attempt.
- `job.queue` and `job.connection` say where the job ran.
- `job.attempt` counts the attempts, starting at 1.
- `job.outcome` is `processed`, `released` when the job released itself back onto the queue, or
  `failed` when the attempt threw. A failed attempt is an `error` event with the
  [error fields](#log-an-event), even if the job is retried.

A job's event also carries the `Context` of the request that dispatched it, like its
`request.id`. Fields you `add` in the request don't reach the job, and the job's don't reach the
request.

### Global fields

Every event carries a few fields that say where it comes from, so you can tell apart the events
of several apps, deploys, or machines. They use OpenTelemetry's names:

- `service.name` is `CHRONICLE_SERVICE`, or your app's `APP_NAME` when it is unset.
- `service.version` is `CHRONICLE_RELEASE`. Chronicle leaves it out when it is unset.
- `deployment.environment` is `CHRONICLE_ENVIRONMENT`, or your app's `APP_ENV` when it is unset.
- `host.name` is `CHRONICLE_HOST`, or the machine's name from `gethostname()` when it is unset.
  Set `CHRONICLE_HOST=` to an empty value to leave it out.
- `process.runtime.version` is PHP's version.

Set `CHRONICLE_RELEASE` in your deploy, to the commit or tag you deploy. Chronicle doesn't read it
from git or Composer, since a server often has neither, and only the deploy knows what it
deployed. To add fields of your own, like the region the app runs in, set `CHRONICLE_FIELDS` in
OpenTelemetry's `key=value,key=value` format:

```ini
CHRONICLE_RELEASE=3f2a9c1
CHRONICLE_FIELDS="region=eu,team=shop"
```

A value that holds a comma or an `=` writes it percent-encoded, as `%2C` or `%3D`. You can also set
`fields` in `config/chronicle.php` to an array of strings, numbers, and bools:

```php
'fields' => ['region' => 'eu', 'team' => 'shop'],
```

Chronicle reads the global fields once, when the app boots. A malformed setting, like
`CHRONICLE_FIELDS="region"` without a value, a key Chronicle sets itself, or more than 128 global
fields, its own included, throws an exception that explains the fix when `APP_DEBUG` is `true` or
`APP_ENV` is `local` or `testing`. Elsewhere logging must never take your app down, so Chronicle
ignores that setting, uses its default, and writes a line like
`Chronicle ignored a malformed setting: CHRONICLE_FIELDS must be key=value pairs separated by commas, found [region]`
to PHP's error log.

Global fields are the least specific fields of an event. A field with the same key from
`Context`, `add`, or `record`, or one Chronicle measured, like `status`, replaces it. Chronicle
[redacts](#redact-secrets) and [limits](#limits) them like every other field. The examples on
this page leave them out.

### What to log

Chronicle logs what it can measure itself: the request's id, route, status, duration, and user,
the error, and the [global fields](#global-fields). What makes an event useful is what only your
app knows. [Add it](#add-data-to-an-event) where your code knows it, so that "checkout failed"
becomes "a customer on the pro plan couldn't pay for a cart of 2,499 EUR":

- **Who it was for**, beyond `user.id`: facts that change how much a failure matters, like
  `user.plan` or `user.account_age_days`.
- **What it worked on**: the business objects by their id, and the numbers that describe them, like
  `order.id`, `order.status`, `cart.items`, `cart.total`, and `cart.currency`.
- **Which code ran**: the feature flags and experiment variants that were on, like
  `flags.new_checkout`. Without them, a bug behind a flag looks random.
- **What came out**: result counts, like `products.total` for a search, so you can find searches
  that return nothing, and where your own logic gave up, like `checkout.failed_step`.
- **What it called**: for each external service, the provider, the duration, and the attempts,
  like `payment.provider`, `payment.duration_ms`, and `payment.attempts`.

Use the same key for the same thing everywhere, like `order.id` rather than `orderId` in one place
and `order_id` in another, so you can query them together. Put units last, like `duration_ms`.
Instead of an extra `Log::info` line in a handler, add the fact to the event.

Leave out:

- Personal data, like names, email addresses, phone numbers, and street addresses. Log the user's
  id instead.
- Secrets. [Redaction](#redact-secrets) hides common keys, but don't rely on it for values you
  choose to log.
- Request and response bodies, of your app and of the services it calls.

For more on choosing fields, read [Logging Sucks](https://loggingsucks.com/), Stripe's
[Canonical Log Lines](https://stripe.com/blog/canonical-log-lines), and
[Observability Wide Events 101](https://boristane.com/blog/observability-wide-events-101/).

## Request logging

You don't need to call `record` for requests. The `LogRequests` middleware logs one event for
every request automatically, and you only [add the data](#add-data-to-an-event) you want in it.

Prepend
`LogRequests`{href="https://raw.githubusercontent.com/Larasell-dev/chronicle/refs/heads/main/src/WideEvents/Requests/Middleware/LogRequests.php"}
to your app's global middleware in `bootstrap/app.php`:

```php
use Illuminate\Foundation\Configuration\Middleware;
use Larasell\Chronicle\WideEvents\Requests\Middleware\LogRequests;

->withMiddleware(function (Middleware $middleware): void {
    $middleware->prepend(LogRequests::class);
})
```

Prepend it, so it runs before any other middleware. It gives the request its
[id](#request-ids) first, so every log line your app writes while it handles the request carries
the id.

Add `LogRequests` once, in your app. Packages shouldn't add it. If it runs twice in one request,
Chronicle logs the request once and writes a warning to your app's log.

Chronicle writes the event after your app has built the response. Each event contains the
`request.id`, `method`, `url`, `route`, `status`, `duration_ms`, `ip`, and `user.id` of the
request. `url` has no query: each query value gets a `query.<name>` field of its own, so secrets
in it can be [redacted](#redact-secrets). The path in `url` is the matched route's template, such
as `/reset-password/{token}`, and each route parameter gets a `route_params.<name>` field with its
value from the path, so a token in the path is redacted too. A request that matches no route, such
as a 404, keeps its path. `route` is the route name, or the URI when the route
has no name. A route that only has a group's name prefix, like `shop.`, counts as having no name.
A request to `/checkout?step=payment&token=abc` writes this event, on a single line in the log file:

```json
{
    "timestamp": "2026-11-02T14:03:11+00:00",
    "type": "request",
    "request.id": "019a48c2-7f3e-7b21-9c4d-2e5f8a1b3c6d",
    "method": "POST",
    "url": "https://shop.test/checkout",
    "query.step": "payment",
    "query.token": "[redacted]",
    "route": "checkout.store",
    "status": 200,
    "duration_ms": 84.21,
    "ip": "203.0.113.7",
    "user.id": 42,
    "chronicle.redacted": "[\"query.token\"]"
}
```

A request to `/reset-password/9f2c1a?email=nils@example.com` logs the template and hides the token:

```json
{
    "url": "https://shop.test/reset-password/{token}",
    "query.email": "nils@example.com",
    "route": "password.reset",
    "route_params.token": "[redacted]",
    "chronicle.redacted": "[\"route_params.token\"]"
}
```

The level of the event depends on the response status:

- A status from 500 to 599 writes an `error` event.
- A status from 400 to 499 writes a `warning` event.
- Any other status writes an `info` event.

If the request failed with an exception your app did not catch, the event also contains the
[error fields](#log-an-event), so you don't need `recordError` for it.

`Context` and `add` never replace a request field that Chronicle measured, such as `status`, or
an error field. They can only fill in a request field that is `null`. For example,
`Context::add('user.id', $customer->id)` sets the id of a customer who signed in through another
guard.

### Request ids

`LogRequests` gives every request an id:

- If the client sends an `X-Request-Id` header with at most 200 visible ASCII characters, like an
  id from your load balancer, Chronicle keeps it.
- Otherwise, Chronicle generates a UUIDv7.

Chronicle sends the id back in the `X-Request-Id` response header, so a customer or your frontend
can quote it in a bug report. It also adds the id to Laravel's `Context` as `request.id`, so
Laravel writes it to every log line of the request, not only to Chronicle's event.

To use the id in your own code, for example in an error response, call `RequestId::current()`:

```php
use Larasell\Chronicle\WideEvents\Requests\RequestId;

return response()->json([
    'title' => 'Payment failed',
    'instance' => RequestId::current(),
], 402);
```

`RequestId::current()` returns `null` outside a request, for example in a queued job.

### Sample requests

Sampling applies only to request logging. Chronicle always writes the events you log with `record`
and `recordError`.

By default, Chronicle writes every request event. On a busy app, you may not want to store every
successful request. To keep only some of them, implement
`Sampler`{href="https://raw.githubusercontent.com/Larasell-dev/chronicle/refs/heads/main/src/WideEvents/Requests/Sampler.php"}. The
sampler receives the request, the response, and the finished event, so it can decide by the
outcome:

```php
use Illuminate\Http\Request;
use Larasell\Chronicle\WideEvents\Requests\Sampler;
use Symfony\Component\HttpFoundation\Response;

final class KeepFailuresAndSlowRequests implements Sampler
{
    public function shouldLog(Request $request, Response $response, array $event): bool
    {
        if ($event['status'] >= 400) {
            return true;
        }

        if ($event['duration_ms'] > 500) {
            return true;
        }

        return random_int(1, 100) <= 10;
    }
}
```

Then set the class as `sampler` in `config/chronicle.php`:

```php
'sampler' => App\Logging\KeepFailuresAndSlowRequests::class,
```

Chronicle resolves the sampler once, when the app boots, so a sampler can keep state across
requests, for example under Octane. If the class does not implement `Sampler`, the app fails at
boot with an exception that explains the fix. A published config that still sets the old
`sampling` key fails at boot too, so you can't keep every request by accident.

## Choose where events go

Chronicle defines its log channels in `config/chronicle.php`. They work like the channels in
Laravel's `config/logging.php` and accept the same drivers and options. Chronicle comes with three
channels:

- `file` writes to `storage/logs/chronicle.log`. It is the default.
- `posthog` sends events to [PostHog](#send-events-to-posthog).
- `stack` writes to both `file` and `posthog`.

To write to a different channel, set `CHRONICLE_CHANNEL` in your `.env` file:

```ini
CHRONICLE_CHANNEL=stack
```

To change a channel or add one, publish the config file and edit `channels`. Chronicle registers a
`posthog` driver for you, which accepts `api_key`, `host`, `scope`, and `level`. With this
`posthog` channel, the `stack` channel writes every event to the file, but sends only warnings and
errors to PostHog:

```php
'posthog' => [
    'driver' => 'posthog',
    'api_key' => env('POSTHOG_API_KEY'),
    'host' => env('POSTHOG_HOST', 'https://us.i.posthog.com'),
    'level' => 'warning',
],
```

Chronicle registers its channels once, when the app boots, so changes to `channels` after boot
have no effect. To add a channel from code, set it in a service provider's `register` method.
`CHRONICLE_CHANNEL` is read on every event.

A stack lists channel names from `config/chronicle.php`. If none of Chronicle's channels has a
name, the stack uses the channel with that name from `config/logging.php`. For example, add your
app's `slack` channel to the `stack` channel to also post Chronicle's events to Slack.

## Send events to PostHog

Sending events to PostHog is optional. By default, Chronicle writes only to the log file.

To send events to PostHog, add your project API key to your `.env` file and write to the `stack`
channel:

```ini
CHRONICLE_CHANNEL=stack
POSTHOG_API_KEY=phc_...
POSTHOG_HOST=https://eu.i.posthog.com
```

`POSTHOG_HOST` defaults to `https://us.i.posthog.com`. Set it to `https://eu.i.posthog.com` if
your project is in PostHog's EU region.

Each event becomes a PostHog log record. The event `type` is the log body, and every other field
is an attribute. Chronicle also copies two fields to the names PostHog uses to link logs to people
and sessions:

- `user.id` becomes `posthogDistinctId`.
- `session.id` becomes `sessionId`.

Chronicle does not set `session.id` itself. Add it with `Context` if you want PostHog to link
logs to sessions.

Chronicle sends the [global fields](#global-fields) once per batch, as the resource of the OTLP
request, rather than on every record. PostHog groups and filters logs by `service.name`. A record
repeats a global field only when its event replaced the field's value, for example with
`Context`.

Chronicle sends PostHog's events in batches, never one request per event: after the response is
sent, after each queued job, and early once a batch holds 100 events or 1 MB. Your app never waits
on PostHog during a request. A batch PostHog doesn't accept after one retry is dropped, and a line
like `Chronicle dropped 12 events for PostHog: PostHog answered HTTP 503` goes to PHP's error log.

If `POSTHOG_API_KEY` is empty, Chronicle drops the PostHog events, and the `stack` channel keeps
writing the log file. It writes one warning to PHP's error log per process, and reports how many
events it dropped. When `APP_DEBUG` is `true`, Chronicle throws an exception instead, so you
notice the missing key during development.

## Redact secrets

Chronicle logs the value of every key that contains a word from `redact` in
`config/chronicle.php` as `[redacted]`, wherever the key is in an event: in fields, `Context`,
`add`, exception context, the query, and route parameters. `token` matches `access_token`, `X-Csrf-Token`, and
`apiToken`, but not `tokens`. The default list covers passwords, secrets, tokens, API keys,
cookies, signatures, and card numbers. `chronicle.redacted` lists the paths of the values
Chronicle hid, so you can tell a hidden value from one that was never set. Add your own keys to
the list:

```php
'redact' => [
    'password', 'secret', 'token', // ...
    'tax_id',
],
```

To hide values by what they contain, like email addresses, implement
`Redactor`{href="https://raw.githubusercontent.com/Larasell-dev/chronicle/refs/heads/main/src/WideEvents/Redactor.php"}
and set the class as `redactor` in `config/chronicle.php`. Chronicle calls it for every value
after it redacted the keys:

```php
use Larasell\Chronicle\WideEvents\Redactor;

class MaskEmails implements Redactor
{
    public function redact(string $field, mixed $value): mixed
    {
        if (is_string($value) && str_contains($value, '@')) {
            return '***@'.substr($value, strpos($value, '@') + 1);
        }

        return $value;
    }
}
```

A failed database query logs its SQL with `?` placeholders, not the values it was run with.

## Limits

Chronicle bounds every event, so one large value can't make your log lines or PostHog requests
huge. It counts the bytes a value takes in the log line, so `"` or a control character, which JSON
escapes, counts more than once, and no event is larger than about 311 KB:

- A string value keeps at most 1,000 bytes. Chronicle cuts it on a character boundary.
- A list keeps at most 1,000 bytes of JSON. Chronicle leaves out whole elements, so it stays valid
  JSON, and adds `<key>_count` with the full count.
- A key keeps at most 200 bytes.
- A request event keeps at most 32 query values. Past that, `query_count` holds how many the
  query has.
- Chronicle walks at most 32 levels into nested arrays. It stores deeper arrays as one JSON value.
- An event has at most 256 fields. Past that, Chronicle drops fields that only `Context` or
  `add` set first, and `chronicle.fields_dropped` holds how many it dropped.
- An app has at most 128 [global fields](#global-fields), Chronicle's own included.

A value JSON can't encode, like `NAN`, `INF`, or a resource, becomes `null`, and
`chronicle.unencodable` says which fields and why:

```json
{
    "type": "checkout",
    "order.id": 7,
    "conversion": null,
    "chronicle.unencodable": "{\"conversion\":\"NAN\"}"
}
```

Chronicle also checks its own guarantees, like these limits. If one ever fails, it throws during
development. In production it reports the failure once through Laravel's `report()`, still writes
the event, and marks it with `chronicle.assertion_failed`.

## Configuration

Chronicle reads these variables from your `.env` file:

- `CHRONICLE_ENABLED` defaults to `true`. Set it to `false` to stop writing all events. Requests
  then also get no id and no `X-Request-Id` header.
- `CHRONICLE_CHANNEL` defaults to `file`. It names the channel in `config/chronicle.php` that
  Chronicle writes to.
- `POSTHOG_API_KEY` has no default. It holds your PostHog project API key.
- `POSTHOG_HOST` defaults to `https://us.i.posthog.com`.
- `CHRONICLE_LOG_JOBS` defaults to `false`. Set it to `true` to [log every job](#log-jobs).
- `CHRONICLE_LOG_IP` defaults to `true`. Set it to `false` to log the `ip` of request events as
  `null`.
- `CHRONICLE_SERVICE` defaults to `APP_NAME`. It sets `service.name`.
- `CHRONICLE_RELEASE` has no default. It sets `service.version`, e.g. to the commit you deploy.
- `CHRONICLE_ENVIRONMENT` defaults to `APP_ENV`. It sets `deployment.environment`.
- `CHRONICLE_HOST` defaults to the machine's name. It sets `host.name`; leave it empty to leave
  `host.name` out.
- `CHRONICLE_FIELDS` has no default. It adds [global fields](#global-fields) of your own, like
  `region=eu,team=shop`.

The sampler has no environment variable. Set it as `sampler` in `config/chronicle.php`.

## Testing

Call `Chronicle::fake()` at the start of a test. Chronicle then builds every event as usual, with
redaction and limits, but keeps it instead of writing it, and you can assert on it:

```php
use Larasell\Chronicle\Facades\Chronicle;

it('logs the payment provider with the checkout', function () {
    Chronicle::fake();

    $this->post('/checkout', ['provider' => 'stripe']);

    Chronicle::assertRecorded(
        'request',
        fn (array $event) => $event['payment.provider'] === 'stripe',
    );
    Chronicle::assertNotRecorded('error');
});
```

`fake` also replaces Chronicle in the container, so code that receives `Chronicle` through
dependency injection or `app(Chronicle::class)` is faked too.

Events contain the fields as Chronicle would write them, with dotted keys, so
`['payment' => ['provider' => 'stripe']]` is `$event['payment.provider']`. The callback also gets
the event's level as its second argument.

To test a class that adds data to an event without running a request, assert on what it added.
The fake keeps every field you `add`, also outside a request:

```php
it('adds the order to the event', function () {
    Chronicle::fake();

    app(MarkOrderPaid::class)->handle($order);

    Chronicle::assertAdded('order.id', $order->id);
    Chronicle::assertNotAdded('order.coupon');
});
```

The fake offers these assertions:

- `assertRecorded($type, $callback)` passes when an event of the type was recorded. With a
  callback, the callback must return `true` for one of them.
- `assertRecordedTimes($type, $times)` passes when exactly that many events of the type were
  recorded.
- `assertNotRecorded($type, $callback)` and `assertNothingRecorded()` are the opposites.
- `assertAdded($key, $value)` passes when a field was added. Without a value, any value passes.
  With a callback instead of a key, the callback gets all added fields.
- `assertNotAdded($key)` passes when no field with the key was added.

For anything else, `Chronicle::recorded($type)` returns the recorded events and
`Chronicle::added()` the added fields.
