Skip to main content

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:

1composer 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:

1php 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:

1use Larasell\Chronicle\Exceptions\StructuredInvalidArgumentException;
2
3throw new StructuredInvalidArgumentException(
4    errorCode: 'billing.invalid-amount',
5    message: "Amount must be positive, got {$amount}",
6    why: 'Charges need an amount greater than zero',
7    fix: 'Check the order total before you charge it',
8);

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:

  • 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 interface. The HasStructuredException trait implements it and adds a structured constructor:

 1use Larasell\Chronicle\Exceptions\HasStructuredException;
 2use Larasell\Chronicle\Exceptions\StructuredException;
 3
 4final class CouldNotInviteMember extends Exception implements StructuredException
 5{
 6    use HasStructuredException;
 7
 8    public static function alreadyInvited(Team $team): self
 9    {
10        return self::structured(
11            errorCode: 'teams.could-not-invite-member.already-invited',
12            message: "Could not invite a member to team {$team->id}: they already have a pending invitation.",
13            why: 'The member already has a pending invitation to this team',
14            fix: 'Resend the existing invitation or remove it first',
15        );
16    }
17}

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:

1[2026-11-02 14:03:11] production.INFO: Checkout started {"user_id":42}
2[2026-11-02 14:03:11] production.INFO: Cart loaded {"items":3,"total":8900}
3[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:

 1{
 2    "timestamp": "2026-11-02T14:03:12+00:00",
 3    "type": "request",
 4    "method": "POST",
 5    "route": "checkout.store",
 6    "status": 500,
 7    "duration_ms": 812.4,
 8    "user.id": 42,
 9    "cart.items": 3,
10    "cart.total": 8900,
11    "error.code": "billing.card-declined",
12    "error.message": "Payment failed: card declined"
13}

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 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:

 1use Illuminate\Contracts\Queue\ShouldQueue;
 2use Illuminate\Foundation\Queue\Queueable;
 3use Larasell\Chronicle\WideEvents\Chronicle;
 4
 5final class SyncInventory implements ShouldQueue
 6{
 7    use Queueable;
 8
 9    public function handle(Warehouse $warehouse, Chronicle $chronicle): void
10    {
11        $startedAtNanoseconds = hrtime(true);
12
13        $result = $warehouse->sync();
14
15        $chronicle->record('inventory.synced', [
16            'products' => ['updated' => $result->updated, 'failed' => $result->failed],
17            'duration_ms' => round((hrtime(true) - $startedAtNanoseconds) / 1_000_000, 2),
18        ]);
19    }
20}

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

1{
2    "timestamp": "2026-11-03T09:12:44+00:00",
3    "type": "inventory.synced",
4    "products.updated": 1840,
5    "products.failed": 3,
6    "duration_ms": 5120.37
7}

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.

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:

1use Larasell\Chronicle\WideEvents\Enums\Level;
2
3$chronicle->record('inventory.low', ['sku' => $sku], Level::Warning);
4$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:

1use Larasell\Chronicle\WideEvents\Chronicle;
2
3try {
4    $gateway->charge($order);
5} catch (PaymentFailed $exception) {
6    app(Chronicle::class)->recordError($exception);
7
8    return back()->withErrors(['payment' => $exception->getMessage()]);
9}

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. Recording the exception from Write your own exception:

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

writes this event:

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

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:

1app(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 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. Add what you know wherever you know it, and Chronicle adds it to every event written afterwards:

1use Illuminate\Support\Facades\Context;
2
3Context::add([
4    'cart' => [
5        'items' => $cart->items->count(),
6        'total' => $cart->total,
7    ],
8]);

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 or 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:

1use Larasell\Chronicle\Facades\Chronicle;
2
3Chronicle::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, is an error event, and scope throws the exception on:

1use Larasell\Chronicle\Facades\Chronicle;
2
3$imported = Chronicle::scope('products.imported', function () use ($file) {
4    $rows = $this->importer->import($file);
5
6    Chronicle::add('import', ['file' => $file->name, 'rows' => count($rows)]);
7
8    return $rows;
9});

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:

1CHRONICLE_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, 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:

1CHRONICLE_RELEASE=3f2a9c1
2CHRONICLE_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:

1'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 and 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. What makes an event useful is what only your app knows. Add it 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 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, Stripe's Canonical Log Lines, and 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 you want in it.

Prepend LogRequests to your app's global middleware in bootstrap/app.php:

1use Illuminate\Foundation\Configuration\Middleware;
2use Larasell\Chronicle\WideEvents\Requests\Middleware\LogRequests;
3
4->withMiddleware(function (Middleware $middleware): void {
5    $middleware->prepend(LogRequests::class);
6})

Prepend it, so it runs before any other middleware. It gives the request its id 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. 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:

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

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

1{
2    "url": "https://shop.test/reset-password/{token}",
3    "query.email": "nils@example.com",
4    "route": "password.reset",
5    "route_params.token": "[redacted]",
6    "chronicle.redacted": "[\"route_params.token\"]"
7}

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, 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():

1use Larasell\Chronicle\WideEvents\Requests\RequestId;
2
3return response()->json([
4    'title' => 'Payment failed',
5    'instance' => RequestId::current(),
6], 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. The sampler receives the request, the response, and the finished event, so it can decide by the outcome:

 1use Illuminate\Http\Request;
 2use Larasell\Chronicle\WideEvents\Requests\Sampler;
 3use Symfony\Component\HttpFoundation\Response;
 4
 5final class KeepFailuresAndSlowRequests implements Sampler
 6{
 7    public function shouldLog(Request $request, Response $response, array $event): bool
 8    {
 9        if ($event['status'] >= 400) {
10            return true;
11        }
12
13        if ($event['duration_ms'] > 500) {
14            return true;
15        }
16
17        return random_int(1, 100) <= 10;
18    }
19}

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

1'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.
  • stack writes to both file and posthog.

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

1CHRONICLE_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:

1'posthog' => [
2    'driver' => 'posthog',
3    'api_key' => env('POSTHOG_API_KEY'),
4    'host' => env('POSTHOG_HOST', 'https://us.i.posthog.com'),
5    'level' => 'warning',
6],

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:

1CHRONICLE_CHANNEL=stack
2POSTHOG_API_KEY=phc_...
3POSTHOG_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 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:

1'redact' => [
2    'password', 'secret', 'token', // ...
3    'tax_id',
4],

To hide values by what they contain, like email addresses, implement Redactor and set the class as redactor in config/chronicle.php. Chronicle calls it for every value after it redacted the keys:

 1use Larasell\Chronicle\WideEvents\Redactor;
 2
 3class MaskEmails implements Redactor
 4{
 5    public function redact(string $field, mixed $value): mixed
 6    {
 7        if (is_string($value) && str_contains($value, '@')) {
 8            return '***@'.substr($value, strpos($value, '@') + 1);
 9        }
10
11        return $value;
12    }
13}

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, 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:

1{
2    "type": "checkout",
3    "order.id": 7,
4    "conversion": null,
5    "chronicle.unencodable": "{\"conversion\":\"NAN\"}"
6}

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.
  • 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 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:

 1use Larasell\Chronicle\Facades\Chronicle;
 2
 3it('logs the payment provider with the checkout', function () {
 4    Chronicle::fake();
 5
 6    $this->post('/checkout', ['provider' => 'stripe']);
 7
 8    Chronicle::assertRecorded(
 9        'request',
10        fn (array $event) => $event['payment.provider'] === 'stripe',
11    );
12    Chronicle::assertNotRecorded('error');
13});

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:

1it('adds the order to the event', function () {
2    Chronicle::fake();
3
4    app(MarkOrderPaid::class)->handle($order);
5
6    Chronicle::assertAdded('order.id', $order->id);
7    Chronicle::assertNotAdded('order.coupon');
8});

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.