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/chronicleLaravel 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.configStructured 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:
errorCodeis a stable id, liketeams.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 owngetCode(), which Chronicle logs aserror.exception_code.whysays why the error happened.fixsays what to do about it.linkpoints 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, andStructuredOutOfRangeException.StructuredRuntimeException,StructuredOutOfBoundsException,StructuredOverflowException,StructuredRangeException,StructuredUnderflowException, andStructuredUnexpectedValueException.
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 declinedA 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, likebilling.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 fromgetCode(), like aPDOException's SQLSTATE, ornullwhen it is0or 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=trueChronicle 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.nameis the job's class.job.idis the job's UUID, the same for every attempt.job.queueandjob.connectionsay where the job ran.job.attemptcounts the attempts, starting at 1.job.outcomeisprocessed,releasedwhen the job released itself back onto the queue, orfailedwhen the attempt threw. A failed attempt is anerrorevent 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.nameisCHRONICLE_SERVICE, or your app'sAPP_NAMEwhen it is unset.service.versionisCHRONICLE_RELEASE. Chronicle leaves it out when it is unset.deployment.environmentisCHRONICLE_ENVIRONMENT, or your app'sAPP_ENVwhen it is unset.host.nameisCHRONICLE_HOST, or the machine's name fromgethostname()when it is unset. SetCHRONICLE_HOST=to an empty value to leave it out.process.runtime.versionis 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, likeuser.planoruser.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, andcart.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.totalfor a search, so you can find searches that return nothing, and where your own logic gave up, likecheckout.failed_step. - What it called: for each external service, the provider, the duration, and the attempts,
like
payment.provider,payment.duration_ms, andpayment.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
errorevent. - A status from 400 to 499 writes a
warningevent. - Any other status writes an
infoevent.
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-Idheader 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:
filewrites tostorage/logs/chronicle.log. It is the default.posthogsends events to PostHog.stackwrites to bothfileandposthog.
To write to a different channel, set CHRONICLE_CHANNEL in your .env file:
1CHRONICLE_CHANNEL=stackTo 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.comPOSTHOG_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.idbecomesposthogDistinctId.session.idbecomessessionId.
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>_countwith the full count. - A key keeps at most 200 bytes.
- A request event keeps at most 32 query values. Past that,
query_countholds 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
Contextoraddset first, andchronicle.fields_droppedholds 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_ENABLEDdefaults totrue. Set it tofalseto stop writing all events. Requests then also get no id and noX-Request-Idheader.CHRONICLE_CHANNELdefaults tofile. It names the channel inconfig/chronicle.phpthat Chronicle writes to.POSTHOG_API_KEYhas no default. It holds your PostHog project API key.POSTHOG_HOSTdefaults tohttps://us.i.posthog.com.CHRONICLE_LOG_JOBSdefaults tofalse. Set it totrueto log every job.CHRONICLE_LOG_IPdefaults totrue. Set it tofalseto log theipof request events asnull.CHRONICLE_SERVICEdefaults toAPP_NAME. It setsservice.name.CHRONICLE_RELEASEhas no default. It setsservice.version, e.g. to the commit you deploy.CHRONICLE_ENVIRONMENTdefaults toAPP_ENV. It setsdeployment.environment.CHRONICLE_HOSTdefaults to the machine's name. It setshost.name; leave it empty to leavehost.nameout.CHRONICLE_FIELDShas no default. It adds global fields of your own, likeregion=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 returntruefor one of them.assertRecordedTimes($type, $times)passes when exactly that many events of the type were recorded.assertNotRecorded($type, $callback)andassertNothingRecorded()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.