Repository navigation
v0.4.0
0.4.0 (2026-11-03)
⚠ BREAKING CHANGES
- log a query exception's SQL without its bindings
- log the query and path values as redacted fields
- redact values of secret keys in every event
- keep the request fields Chronicle measured over Context
- name the structured error code argument errorCode
- make the error fields of structured exceptions private
- flatten objects through their JSON form, like arrays
- rename the sampling config to sampler
- register Chronicle's channels once at boot
- replace values JSON can't encode instead of losing the event
- bound every event in depth, field count and bytes
- move Chronicle into the WideEvents namespace
- take recordError arguments in record's order, with own Level
- send PostHog events in batches, without OpenTelemetry
Features
- add a facade with a fake and scoped events (3a7f656)
- add fields to the current request's event with add() (c9fa94d)
- assert that redaction left no secret behind (fb91e10)
- bound every event in depth, field count and bytes (14fdf0f)
- check Chronicle's own guarantees with assertions (957059a)
- flatten objects through their JSON form, like arrays (6f4d6a5)
- keep the request fields Chronicle measured over Context (e9180d1)
- list redacted keys in chronicle.redacted (3b6f383)
- log a query exception's SQL without its bindings (01a9d7e)
- log PHP's own exception code as error.exception_code (a81161d)
- log queued jobs when CHRONICLE_LOG_JOBS is on (93a48e6)
- log the query and path values as redacted fields (da90cca)
- make logging the client's ip optional (c86d790)
- redact values of secret keys in every event (09bded9)
- replace values JSON can't encode instead of losing the event (8ab9e3a)
- send PostHog events in batches, without OpenTelemetry (3675e9f)
- take recordError arguments in record's order, with own Level (944e379)
Bug Fixes
- bound every value by its JSON-escaped size (7ab24ab)
- log a request once when LogRequests runs twice (25dcc8c)
- log no request id when the request has none (2b160d7)
- measure request durations with the monotonic clock (fb1551b)
- report an exception context that is not an array (e007949)
- warn when the PostHog channel has no API key (8567155)
Performance Improvements
- register Chronicle's channels once at boot (2acb392)
- resolve the sampler once, when the RequestLogger is built (7a96839)
Code Refactoring
- make the error fields of structured exceptions private (ea0712e)
- move Chronicle into the WideEvents namespace (0c46634)
- name the structured error code argument errorCode (1ca5427)
- rename the sampling config to sampler (8816fea)
Upgrade guide
Chronicle's channels are registered once, when the app boots
Chronicle used to copy every channel in chronicle.channels into logging.channels on every
event, so a change to chronicle.channels at runtime took effect on the next event. That cost
work on every event, and Laravel caches each logger on first use anyway, so changes to a channel
already used were ignored. Chronicle now registers the channels once, before any provider boots.
chronicle.default, the channel to write to, is still read on every event.
Who is affected
Code that changes chronicle.channels after the app booted: in a provider's boot(), a
middleware, a job or a test.
Apps that only set chronicle.channels in config/chronicle.php, and switch channels with
CHRONICLE_CHANNEL or chronicle.default, are not affected.
How to upgrade
Define channels in config/chronicle.php, or set them in a provider's register(), which runs
before boot:
// Before: in AppServiceProvider::boot()
config()->set('chronicle.channels.audit', ['driver' => 'single', 'path' => storage_path('logs/audit.log')]);
// After: in AppServiceProvider::register()
config()->set('chronicle.channels.audit', ['driver' => 'single', 'path' => storage_path('logs/audit.log')]);In tests with Orchestra Testbench, set them in defineEnvironment(). Then pick one per test with
chronicle.default:
protected function defineEnvironment($app): void
{
config()->set('chronicle.channels.test', ['driver' => 'custom', 'via' => fn () => new Logger('test', [new TestHandler])]);
}
// In a test
config()->set('chronicle.default', 'test');Chronicle is now Larasell\Chronicle\WideEvents\Chronicle
The package has two features: structured exceptions in Larasell\Chronicle\Exceptions, and wide
events in Larasell\Chronicle\WideEvents. Chronicle records wide events, so it now lives with
the rest of them. The new Level enum lives there too, as Larasell\Chronicle\WideEvents\Enums\Level.
Who is affected
Every app that imports or type-hints Larasell\Chronicle\Chronicle, e.g. to call record() or
recordError(). There is no alias for the old name. Apps that only use the structured exceptions
or the LogRequests middleware are not affected.
How to upgrade
Change the import:
// Before
use Larasell\Chronicle\Chronicle;
// After
use Larasell\Chronicle\WideEvents\Chronicle;To update every file at once:
grep -rl 'Larasell\\Chronicle\\Chronicle\b' app routes bootstrap config tests \
| xargs sed -i 's/Larasell\\Chronicle\\Chronicle\b/Larasell\\Chronicle\\WideEvents\\Chronicle/g'Every event is bounded: strings, keys, nesting and field count have limits
Chronicle used to log a string field or a nested array at whatever size the app passed. One large
field in Context could make every log line of a request megabytes long. Lists were the only
values that had a limit, and they were cut at byte 1,000 with substr(), which could leave
invalid JSON or split a UTF-8 character. Now every part of an event has a limit. Each byte limit
counts the bytes a value takes in the log line, escapes included, so a string of quotes or control
characters keeps fewer characters than one of letters, and no event's line is longer than
Chronicle::EVENT_BYTES_MAX bytes, about 311 KB:
| What | Limit | What happens past it |
|---|---|---|
| A string value | 1,000 bytes (Chronicle::VALUE_BYTES_MAX) |
Cut to the longest prefix that fits, on a UTF-8 character boundary |
| A list, stored as JSON | 1,000 bytes | Whole elements are left out, so it stays valid JSON, and <key>_count holds the full count |
| A key | 200 bytes | Cut on a character boundary |
| Nesting | 32 levels | Deeper arrays are stored as one JSON value |
| Fields per event | 256 | Fields only in Context are dropped first, latest first, and chronicle.fields_dropped holds how many |
Who is affected
- Apps that log strings longer than 1,000 bytes, such as request bodies, SQL or stack traces,
and read them back in full. - Apps that parse list fields. A cut list used to be truncated JSON; now it's valid JSON with
fewer elements, plus a<key>_countfield. - Apps with more than 256 fields in one event, Context included.
Apps whose events stay within these limits see no difference, except that log lines now contain
non-ASCII characters as they are, e.g. ü instead of \u00fc, and / instead of \/ inside
list values. JSON readers decode both the same way.
How to upgrade
Nothing to change in code. To keep more of a long value, log the parts you need as separate
fields, or log a reference to the full value:
use Larasell\Chronicle\WideEvents\Chronicle;
// Before: the whole payload, any size
app(Chronicle::class)->record('webhook', ['payload' => $request->getContent()]);
// After: the fields you query on, and a pointer to the full body
app(Chronicle::class)->record('webhook', [
'webhook.provider' => 'stripe',
'webhook.event' => $request->input('type'),
'webhook.body_path' => $storedPath,
]);When you read a list field, check <key>_count to see if elements were left out:
{"type":"tags","user.tags":"[\"tag-0\",\"tag-1\",…]","user.tags_count":200}Objects in an event are flattened into dotted fields, like arrays
An object used to be stored as one value, whole: a Collection, a DTO or a JsonSerializable
was written at whatever size its JSON had, past every limit of the event. Now Chronicle first
turns an object into plain data, and that data follows the same rules as arrays:
| Object | Turned into |
|---|---|
JsonSerializable |
what jsonSerialize() returns |
Arrayable, e.g. a Collection or a model |
what toArray() returns |
stdClass |
its properties |
| Any other object | its public properties, through json_encode |
So an associative result becomes dotted fields, a list becomes one bounded JSON value with a
<key>_count when elements were left out, and a string is cut to 1,000 bytes.
Who is affected
Apps that pass objects as fields or put them in Context, and parse those fields:
app(Chronicle::class)->record('checkout', ['customer' => (object) ['id' => 7, 'tier' => 'gold']]);// Before
{"type":"checkout","customer":{"id":7,"tier":"gold"}}
// After
{"type":"checkout","customer.id":7,"customer.tier":"gold"}Apps that only log scalars and arrays are not affected.
How to upgrade
Query the dotted fields instead of the nested object, e.g. customer.tier rather than
customer->tier. A jsonSerialize() or toArray() that throws no longer costs the event: the
field becomes null and chronicle.unencodable names the method that threw.
The posthog channel sends batches of OTLP JSON, without the OpenTelemetry packages
Chronicle used the OpenTelemetry SDK to send each event to PostHog in its own blocking HTTP
request, with the SDK's defaults of a 10 second timeout and 3 retries. A slow PostHog could hold
up every request of the app. The SDK also pulled in 11 packages, two of them Composer plugins
every app had to allow.
The posthog channel now sends OTLP JSON, the format PostHog's own SDKs use, with Laravel's HTTP
client. It collects a request's events and sends them in one request when the app terminates,
which is after the response under PHP-FPM, after each queued job, and early once a batch holds
100 events or 1 MB. Each batch gets a 1 second connect timeout, a 2 second timeout and one retry,
at most about 4 seconds. A batch PostHog doesn't accept is dropped and reported to PHP's error
log, never thrown.
Who is affected
- Apps that bound
OpenTelemetry\SDK\Logs\LoggerProviderInterfacein the container so Chronicle
would send through their own OpenTelemetry pipeline. Chronicle no longer reads that binding. - Apps that use the OpenTelemetry packages themselves but never required them, because Chronicle
installed them. Require them in your owncomposer.json. - Apps whose
composer.jsonallows thephp-http/discoveryandtbachert/spiplugins only for
Chronicle. You can remove those entries.
Logs in PostHog now carry your APP_NAME as service.name, instead of unknown_service:php.
Saved PostHog filters on the old service name need updating.
Apps that only set POSTHOG_API_KEY and POSTHOG_HOST need no changes.
How to upgrade
If your app uses OpenTelemetry itself, require it directly:
composer require open-telemetry/api open-telemetry/sdk open-telemetry/exporter-otlpIf you sent Chronicle's events through your own OpenTelemetry pipeline, there is no replacement
for the container binding. Either let the posthog channel send to PostHog directly, or have your
collector read the JSON lines the file channel writes to storage/logs/chronicle.log.
A failed query's error.message no longer contains its values
Laravel's QueryException puts the query's bindings into its message, e.g.
insert into users (email, password) values (jane@example.com, $2y$12$…), so Chronicle logged
emails, password hashes and tokens with every failed query. Chronicle now logs the SQL with its
? placeholders instead. The database's own message, which comes first, is unchanged and can
still quote a value, e.g. the entry that broke a unique index.
// Before
SQLSTATE[HY000]: General error (Connection: mysql, SQL: insert into users (email) values (jane@example.com))
// After
SQLSTATE[HY000]: General error (Connection: mysql, SQL: insert into users (email) values (?))
Who is affected
Apps that read the bindings of failed queries from error.message in their logs or in PostHog.
Error events of other exceptions are not affected.
How to upgrade
Log the values you need yourself, under keys that aren't secret:
// Before: the email was in error.message
$user->save();
// After
Context::add('signup.email_domain', Str::after($user->email, '@'));
$user->save();recordError() takes its arguments in the same order as record(), with Chronicle's own Level
recordError() took the level second and the fields third, the reverse of record(), and named
the fields $context instead of $fields. Both methods now take fields second and the level last.
Levels are now Chronicle's own Larasell\Chronicle\WideEvents\Enums\Level enum, so apps no longer need Monolog to
pick one. Level names as strings still work, in any case; an unknown name now throws a structured
chronicle.level.unknown exception instead of Monolog's ValueError.
Who is affected
- Calls to
recordError()that pass a level, or fields by the namecontext:. - Calls to
record()orrecordError()that pass aMonolog\Levelcase. Strings such as
'warning'keep working.
Apps that call recordError($exception) and record($type, $fields) without a level are not
affected.
How to upgrade
// Before
use Monolog\Level;
app(Chronicle::class)->recordError($exception, Level::Warning, ['order.id' => 7]);
app(Chronicle::class)->recordError($exception, context: ['order.id' => 7]);
app(Chronicle::class)->record('inventory.low', ['sku' => $sku], Level::Warning);
// After
use Larasell\Chronicle\WideEvents\Enums\Level;
app(Chronicle::class)->recordError($exception, ['order.id' => 7], Level::Warning);
app(Chronicle::class)->recordError($exception, fields: ['order.id' => 7]);
app(Chronicle::class)->record('inventory.low', ['sku' => $sku], Level::Warning);
// A level without fields: name it
app(Chronicle::class)->recordError($exception, level: 'warning');Context can no longer replace the request fields Chronicle measured
A request's event used to take any Context field with the same key as a request field, so a
package that put status or route into Context changed the event of every request, also the
requests of other packages. The fields Chronicle measured itself, request.id, method, url,
route, status, duration_ms, ip and user.id, now always win, and so do an event's
timestamp and type. Context can still fill in a request field that is null.
To add fields to the event of the current request without sharing them with every log line and
queued job, use the new Chronicle::add().
Who is affected
Apps that use Context to replace a request field that Chronicle measured, e.g. a user.id set
in Context while a user is signed in through the default guard, or any Context key named
timestamp or type.
Apps that only add their own keys to Context, or fill in user.id for guests, e.g. for customers
signed in through another guard, are not affected.
How to upgrade
Keep a measured value under a key of its own:
use Illuminate\Support\Facades\Context;
// Before: replaced the id of the signed-in user
Context::add('user.id', $customer->id);
// After
Context::add('customer.id', $customer->id);For fields that only belong on the request's event:
use Larasell\Chronicle\WideEvents\Chronicle;
app(Chronicle::class)->add('payment', ['provider' => 'stripe', 'attempts' => 2]);chronicle.sampling is now chronicle.sampler
The config key holds a sampler class, so it is now named after one. The Sampler contract's
third parameter is renamed from $payload to $event too, because it receives the whole
flattened request event, not only the request's own fields.
Who is affected
- Apps that published
config/chronicle.phpand setsampling. Chronicle stops at boot with
chronicle.sampling is now chronicle.sampleruntil it is renamed, rather than quietly keeping
every request event. - Apps that call
Sampler::shouldLog()with the named argumentpayload:. Implementations of
Samplerkeep working with any parameter name.
Apps that never set a sampler are not affected.
How to upgrade
Rename the key in config/chronicle.php:
// Before
'sampling' => App\Logging\KeepFailuresAndSlowRequests::class,
// After
'sampler' => App\Logging\KeepFailuresAndSlowRequests::class,Optionally rename the parameter in your sampler to match the contract:
// Before
public function shouldLog(Request $request, Response $response, array $payload): bool
// After
public function shouldLog(Request $request, Response $response, array $event): boolChronicle redacts the values of secret keys
Chronicle used to log every value as it was, so a password, api_key or token that reached an
event through fields, Context, an exception's context() or the query was written to the log
file and sent to PostHog. Chronicle now logs the value of every key that contains a word from the
new chronicle.redact list as "[redacted]", wherever the key is in an event, lists and objects
included. The default list covers passwords, secrets, tokens, API keys, cookies, signatures and
card numbers. Keys match by whole words, in any case: token matches access_token,
X-Csrf-Token and apiToken, but not tokens. An event with hidden values lists their paths in
chronicle.redacted, e.g. ["user.password","items.0.token"].
Who is affected
Apps that log a value under a key such as token, secret, signature or password and read it
from the log, e.g. a token_type or password_changed_at field. Apps with a published
config/chronicle.php get the default list too, since the config merges it in.
How to upgrade
Rename fields you need to keep, or set your own list in config/chronicle.php:
// Before: logged as is
Context::add('password_changed_at', $user->password_changed_at);
// After: logged as "[redacted]", so give it a key without the word
Context::add('user.days_since_reset', $user->password_changed_at->diffInDays());// config/chronicle.php: your own list replaces the default one
'redact' => [
'password', 'secret', 'token', 'api_key', 'apikey', 'authorization', 'cookie',
'credential', 'credentials', 'private_key', 'signature', 'sig', 'card_number',
'cvc', 'cvv', 'iban',
],Structured exceptions take errorCode: instead of code:
PHP exceptions already have a code, the one getCode() returns. Chronicle's code: argument set
something else, its string error code, so new StructuredRuntimeException(code: 'billing.declined', ...)
still returned 0 from getCode(). The argument is now named errorCode:, like the
errorCode() getter it fills.
Events also gain an error.exception_code field with PHP's own code, e.g. a PDOException's
SQLSTATE or an HTTP status, for every exception. It is null when the code is 0 or ''.
error.code is unchanged: Chronicle's string code, or null for exceptions that aren't structured.
Who is affected
Calls that pass code: to any Structured*Exception constructor or to structured() in your own
exceptions. They now fail with Unknown named parameter $code.
Code that only reads errorCode(), or the error.code field, is not affected.
How to upgrade
Rename the argument:
use Larasell\Chronicle\Exceptions\StructuredRuntimeException;
// Before
throw new StructuredRuntimeException(code: 'billing.declined', message: 'Payment failed');
// After
throw new StructuredRuntimeException(errorCode: 'billing.declined', message: 'Payment failed');// Before
return self::structured(code: 'billing.declined', message: 'Payment failed');
// After
return self::structured(errorCode: 'billing.declined', message: 'Payment failed');To find the calls, including ones that put each argument on its own line:
grep -rnE "\bcode: ['\"]" appThe error fields of structured exceptions are private
$errorCode, $errorWhy, $errorFix and $errorLink were public, so any code that caught a
structured exception could rewrite or erase them before Chronicle logged it. They are now private:
an exception sets them once, while it is built, and everyone else reads them through
errorCode(), errorWhy(), errorFix() and errorLink().
Who is affected
- Code that reads the properties directly, e.g.
$exception->errorCode. - Exceptions that use
HasStructuredExceptionorHasStructuredFieldsand set the properties
from outside the class, e.g. right afternew.
Exceptions built with structured(), new Structured*Exception(...), or withError() in their
constructor are not affected, and neither are calls to the getters.
How to upgrade
Read through the getters:
// Before
$code = $exception->errorCode;
// After
$code = $exception->errorCode();Set the fields while the exception is built, with withError() or structured():
use Larasell\Chronicle\Exceptions\HasStructuredException;
use Larasell\Chronicle\Exceptions\StructuredException;
// Before
final class PaymentDeclined extends RuntimeException implements StructuredException
{
use HasStructuredException;
}
$exception = new PaymentDeclined('Payment failed');
$exception->errorCode = 'billing.declined';
$exception->errorFix = 'Try a different card';
// After
final class PaymentDeclined extends RuntimeException implements StructuredException
{
use HasStructuredException;
public static function forCard(): self
{
return self::structured(
errorCode: 'billing.declined',
message: 'Payment failed',
fix: 'Try a different card',
);
}
}
$exception = PaymentDeclined::forCard();Values JSON can't encode become null, instead of costing the whole event
A field json_encode couldn't encode, such as NAN, INF, a resource, or an object whose
jsonSerialize() throws, used to make the whole event fail to encode, and Chronicle wrote an
empty line. The event was lost without a trace. Now only that field becomes null, and
chronicle.unencodable says which fields and why, as a JSON object:
{"type":"checkout","order.id":7,"conversion":null,"ratio":null,
"chronicle.unencodable":"{\"conversion\":\"NAN\",\"ratio\":\"INF\"}"}In a list, only the bad elements become null: [1,null,2].
Who is affected
- Apps that log floats that can be
NANorINF, e.g. a ratio divided by zero, or resources
such as file handles. Those events now show up, withnullin place of the value. - Apps that look for empty lines in
chronicle.logto spot failed events. Search for
chronicle.unencodableinstead. - The posthog channel: a message that isn't a JSON object now throws
chronicle.posthog.invalid-event
in debug mode. Outside debug mode it is sent withchronicle.decode_error.
Apps that only log strings, ints, bools and arrays of them are not affected.
How to upgrade
Nothing is required. To find the events that had a bad value:
grep '"chronicle.unencodable"' storage/logs/chronicle.logTo keep the information, log the value in a form JSON can hold:
// Before: NAN when there were no visits
app(Chronicle::class)->record('report', ['conversion' => $orders / $visits]);
// After
app(Chronicle::class)->record('report', ['conversion' => $visits > 0 ? $orders / $visits : null]);A request's url has no query and the route's template as its path
url used to be the full url, query included, so tokens and signed-url signatures in the query
were logged as they were. url is now the url without the query, and each query value is a field
of its own, query.<name>, so redaction can hide the values of secret
keys. A request event keeps at most 32 query values; past that, query_count holds how many the
query has. A url without a query gets no query fields.
Values in the path, such as a password reset token, were logged as they were too. When a route
matched, the path in url is now the route's template, and each route parameter is a field of its
own, route_params.<name>, with its value from the path, so the same redaction applies. A request
that matches no route, such as a 404, keeps its path.
// Before: /orders?page=2&token=abc
{"url": "https://shop.test/orders?page=2&token=abc"}
// After
{"url": "https://shop.test/orders", "query.page": "2", "query.token": "[redacted]"}
// Before: /reset-password/9f2c1a
{"url": "https://shop.test/reset-password/9f2c1a"}
// After
{"url": "https://shop.test/reset-password/{token}", "route_params.token": "[redacted]"}Who is affected
Dashboards, alerts, samplers and queries that read the query or a path value from url, e.g. in
PostHog or with $event['url'] in a Sampler. Grouping by url now groups by route, which
merges the requests of every order, user or token.
Apps that only use route, or url of routes without parameters, are not affected.
How to upgrade
Read query values from their own fields:
// Before
public function shouldLog(Request $request, Response $response, array $event): bool
{
return str_contains($event['url'], 'debug=1');
}
// After
public function shouldLog(Request $request, Response $response, array $event): bool
{
return ($event['query.debug'] ?? null) === '1';
}Read path values from route_params:
// Before: /orders/7
$orderId = (int) basename($event['url']);
// After
$orderId = (int) $event['route_params.order'];