Changelog
All the changes made to Chronicle.
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.channelsintologging.channelson every event, so a change tochronicle.channelsat 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.channelsafter the app booted: in a provider'sboot(), a middleware, a job or a test.Apps that only set
chronicle.channelsinconfig/chronicle.php, and switch channels withCHRONICLE_CHANNELorchronicle.default, are not affected.How to upgrade
Define channels in
config/chronicle.php, or set them in a provider'sregister(), which runs before boot:1// Before: in AppServiceProvider::boot() 2config()->set('chronicle.channels.audit', ['driver' => 'single', 'path' => storage_path('logs/audit.log')]); 3 4// After: in AppServiceProvider::register() 5config()->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 withchronicle.default:1protected function defineEnvironment($app): void 2{ 3 config()->set('chronicle.channels.test', ['driver' => 'custom', 'via' => fn () => new Logger('test', [new TestHandler])]); 4} 5 6// In a test 7config()->set('chronicle.default', 'test');Chronicleis nowLarasell\Chronicle\WideEvents\ChronicleThe package has two features: structured exceptions in
Larasell\Chronicle\Exceptions, and wide events inLarasell\Chronicle\WideEvents.Chroniclerecords wide events, so it now lives with the rest of them. The newLevelenum lives there too, asLarasell\Chronicle\WideEvents\Enums\Level.Who is affected
Every app that imports or type-hints
Larasell\Chronicle\Chronicle, e.g. to callrecord()orrecordError(). There is no alias for the old name. Apps that only use the structured exceptions or theLogRequestsmiddleware are not affected.How to upgrade
Change the import:
1// Before 2use Larasell\Chronicle\Chronicle; 3 4// After 5use Larasell\Chronicle\WideEvents\Chronicle;To update every file at once:
1grep -rl 'Larasell\\Chronicle\\Chronicle\b' app routes bootstrap config tests \ 2 | 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
Contextcould 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 withsubstr(), 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 thanChronicle::EVENT_BYTES_MAXbytes, 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>_countholds 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 inContextare dropped first, latest first, andchronicle.fields_droppedholds 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:
1use Larasell\Chronicle\WideEvents\Chronicle; 2 3// Before: the whole payload, any size 4app(Chronicle::class)->record('webhook', ['payload' => $request->getContent()]); 5 6// After: the fields you query on, and a pointer to the full body 7app(Chronicle::class)->record('webhook', [ 8 'webhook.provider' => 'stripe', 9 'webhook.event' => $request->input('type'), 10 'webhook.body_path' => $storedPath, 11]);When you read a list field, check
<key>_countto see if elements were left out:1{"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 aJsonSerializablewas 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| whatjsonSerialize()returns | |Arrayable, e.g. aCollectionor a model | whattoArray()returns | |stdClass| its properties | | Any other object | its public properties, throughjson_encode|So an associative result becomes dotted fields, a list becomes one bounded JSON value with a
<key>_countwhen 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:1app(Chronicle::class)->record('checkout', ['customer' => (object) ['id' => 7, 'tier' => 'gold']]);1// Before 2{"type":"checkout","customer":{"id":7,"tier":"gold"}} 3 4// After 5{"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.tierrather thancustomer->tier. AjsonSerialize()ortoArray()that throws no longer costs the event: the field becomesnullandchronicle.unencodablenames the method that threw.The
posthogchannel sends batches of OTLP JSON, without the OpenTelemetry packagesChronicle 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
posthogchannel 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 own
composer.json. - Apps whose
composer.jsonallows thephp-http/discoveryandtbachert/spiplugins only for Chronicle. You can remove those entries.
Logs in PostHog now carry your
APP_NAMEasservice.name, instead ofunknown_service:php. Saved PostHog filters on the old service name need updating.Apps that only set
POSTHOG_API_KEYandPOSTHOG_HOSTneed no changes.How to upgrade
If your app uses OpenTelemetry itself, require it directly:
1composer 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
posthogchannel send to PostHog directly, or have your collector read the JSON lines thefilechannel writes tostorage/logs/chronicle.log.A failed query's
error.messageno longer contains its valuesLaravel's
QueryExceptionputs 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.1// Before 2SQLSTATE[HY000]: General error (Connection: mysql, SQL: insert into users (email) values (jane@example.com)) 3 4// After 5SQLSTATE[HY000]: General error (Connection: mysql, SQL: insert into users (email) values (?))Who is affected
Apps that read the bindings of failed queries from
error.messagein 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:
1// Before: the email was in error.message 2$user->save(); 3 4// After 5Context::add('signup.email_domain', Str::after($user->email, '@')); 6$user->save();recordError()takes its arguments in the same order asrecord(), with Chronicle's ownLevelrecordError()took the level second and the fields third, the reverse ofrecord(), and named the fields$contextinstead of$fields. Both methods now take fields second and the level last. Levels are now Chronicle's ownLarasell\Chronicle\WideEvents\Enums\Levelenum, so apps no longer need Monolog to pick one. Level names as strings still work, in any case; an unknown name now throws a structuredchronicle.level.unknownexception instead of Monolog'sValueError.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)andrecord($type, $fields)without a level are not affected.How to upgrade
1// Before 2use Monolog\Level; 3 4app(Chronicle::class)->recordError($exception, Level::Warning, ['order.id' => 7]); 5app(Chronicle::class)->recordError($exception, context: ['order.id' => 7]); 6app(Chronicle::class)->record('inventory.low', ['sku' => $sku], Level::Warning); 7 8// After 9use Larasell\Chronicle\WideEvents\Enums\Level; 10 11app(Chronicle::class)->recordError($exception, ['order.id' => 7], Level::Warning); 12app(Chronicle::class)->recordError($exception, fields: ['order.id' => 7]); 13app(Chronicle::class)->record('inventory.low', ['sku' => $sku], Level::Warning); 14 15// A level without fields: name it 16app(Chronicle::class)->recordError($exception, level: 'warning');Context can no longer replace the request fields Chronicle measured
A request's event used to take any
Contextfield with the same key as a request field, so a package that putstatusorrouteinto 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,ipanduser.id, now always win, and so do an event'stimestampandtype. Context can still fill in a request field that isnull.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
Contextto replace a request field that Chronicle measured, e.g. auser.idset in Context while a user is signed in through the default guard, or any Context key namedtimestamportype.Apps that only add their own keys to Context, or fill in
user.idfor 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:
1use Illuminate\Support\Facades\Context; 2 3// Before: replaced the id of the signed-in user 4Context::add('user.id', $customer->id); 5 6// After 7Context::add('customer.id', $customer->id);For fields that only belong on the request's event:
1use Larasell\Chronicle\WideEvents\Chronicle; 2 3app(Chronicle::class)->add('payment', ['provider' => 'stripe', 'attempts' => 2]);chronicle.samplingis nowchronicle.samplerThe config key holds a sampler class, so it is now named after one. The
Samplercontract's third parameter is renamed from$payloadto$eventtoo, 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 withchronicle.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 ofSamplerkeep working with any parameter name.
Apps that never set a sampler are not affected.
How to upgrade
Rename the key in
config/chronicle.php:1// Before 2'sampling' => App\Logging\KeepFailuresAndSlowRequests::class, 3 4// After 5'sampler' => App\Logging\KeepFailuresAndSlowRequests::class,Optionally rename the parameter in your sampler to match the contract:
1// Before 2public function shouldLog(Request $request, Response $response, array $payload): bool 3 4// After 5public 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_keyortokenthat reached an event through fields,Context, an exception'scontext()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 newchronicle.redactlist 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:tokenmatchesaccess_token,X-Csrf-TokenandapiToken, but nottokens. An event with hidden values lists their paths inchronicle.redacted, e.g.["user.password","items.0.token"].Who is affected
Apps that log a value under a key such as
token,secret,signatureorpasswordand read it from the log, e.g. atoken_typeorpassword_changed_atfield. Apps with a publishedconfig/chronicle.phpget 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:1// Before: logged as is 2Context::add('password_changed_at', $user->password_changed_at); 3 4// After: logged as "[redacted]", so give it a key without the word 5Context::add('user.days_since_reset', $user->password_changed_at->diffInDays());1// config/chronicle.php: your own list replaces the default one 2'redact' => [ 3 'password', 'secret', 'token', 'api_key', 'apikey', 'authorization', 'cookie', 4 'credential', 'credentials', 'private_key', 'signature', 'sig', 'card_number', 5 'cvc', 'cvv', 'iban', 6],Structured exceptions take
errorCode:instead ofcode:PHP exceptions already have a code, the one
getCode()returns. Chronicle'scode:argument set something else, its string error code, sonew StructuredRuntimeException(code: 'billing.declined', ...)still returned0fromgetCode(). The argument is now namederrorCode:, like theerrorCode()getter it fills.Events also gain an
error.exception_codefield with PHP's own code, e.g. aPDOException's SQLSTATE or an HTTP status, for every exception. It isnullwhen the code is0or''.error.codeis unchanged: Chronicle's string code, ornullfor exceptions that aren't structured.Who is affected
Calls that pass
code:to anyStructured*Exceptionconstructor or tostructured()in your own exceptions. They now fail withUnknown named parameter $code.Code that only reads
errorCode(), or theerror.codefield, is not affected.How to upgrade
Rename the argument:
1use Larasell\Chronicle\Exceptions\StructuredRuntimeException; 2 3// Before 4throw new StructuredRuntimeException(code: 'billing.declined', message: 'Payment failed'); 5 6// After 7throw new StructuredRuntimeException(errorCode: 'billing.declined', message: 'Payment failed');1// Before 2return self::structured(code: 'billing.declined', message: 'Payment failed'); 3 4// After 5return self::structured(errorCode: 'billing.declined', message: 'Payment failed');To find the calls, including ones that put each argument on its own line:
1grep -rnE "\bcode: ['\"]" appThe error fields of structured exceptions are private
$errorCode,$errorWhy,$errorFixand$errorLinkwere 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 througherrorCode(),errorWhy(),errorFix()anderrorLink().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(...), orwithError()in their constructor are not affected, and neither are calls to the getters.How to upgrade
Read through the getters:
1// Before 2$code = $exception->errorCode; 3 4// After 5$code = $exception->errorCode();Set the fields while the exception is built, with
withError()orstructured():1use Larasell\Chronicle\Exceptions\HasStructuredException; 2use Larasell\Chronicle\Exceptions\StructuredException; 3 4// Before 5final class PaymentDeclined extends RuntimeException implements StructuredException 6{ 7 use HasStructuredException; 8} 9 10$exception = new PaymentDeclined('Payment failed'); 11$exception->errorCode = 'billing.declined'; 12$exception->errorFix = 'Try a different card'; 13 14// After 15final class PaymentDeclined extends RuntimeException implements StructuredException 16{ 17 use HasStructuredException; 18 19 public static function forCard(): self 20 { 21 return self::structured( 22 errorCode: 'billing.declined', 23 message: 'Payment failed', 24 fix: 'Try a different card', 25 ); 26 } 27} 28 29$exception = PaymentDeclined::forCard();Values JSON can't encode become
null, instead of costing the whole eventA field
json_encodecouldn't encode, such asNAN,INF, a resource, or an object whosejsonSerialize()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 becomesnull, andchronicle.unencodablesays which fields and why, as a JSON object:1{"type":"checkout","order.id":7,"conversion":null,"ratio":null, 2 "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 forchronicle.unencodableinstead. - The posthog channel: a message that isn't a JSON object now throws
chronicle.posthog.invalid-eventin 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:
1grep '"chronicle.unencodable"' storage/logs/chronicle.logTo keep the information, log the value in a form JSON can hold:
1// Before: NAN when there were no visits 2app(Chronicle::class)->record('report', ['conversion' => $orders / $visits]); 3 4// After 5app(Chronicle::class)->record('report', ['conversion' => $visits > 0 ? $orders / $visits : null]);A request's
urlhas no query and the route's template as its pathurlused to be the full url, query included, so tokens and signed-url signatures in the query were logged as they were.urlis 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_countholds how many the query has. A url without a query gets noqueryfields.Values in the path, such as a password reset token, were logged as they were too. When a route matched, the path in
urlis 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.1// Before: /orders?page=2&token=abc 2{"url": "https://shop.test/orders?page=2&token=abc"} 3 4// After 5{"url": "https://shop.test/orders", "query.page": "2", "query.token": "[redacted]"} 6 7// Before: /reset-password/9f2c1a 8{"url": "https://shop.test/reset-password/9f2c1a"} 9 10// After 11{"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 aSampler. Grouping byurlnow groups by route, which merges the requests of every order, user or token.Apps that only use
route, orurlof routes without parameters, are not affected.How to upgrade
Read query values from their own fields:
1// Before 2public function shouldLog(Request $request, Response $response, array $event): bool 3{ 4 return str_contains($event['url'], 'debug=1'); 5} 6 7// After 8public function shouldLog(Request $request, Response $response, array $event): bool 9{ 10 return ($event['query.debug'] ?? null) === '1'; 11}Read path values from
route_params:1// Before: /orders/7 2$orderId = (int) basename($event['url']); 3 4// After 5$orderId = (int) $event['route_params.order'];0.3.0 (2026-11-03)
⚠ BREAKING CHANGES
- CHRONICLE_POSTHOG_ENABLED and the chronicle.posthog config are removed. Set CHRONICLE_CHANNEL=stack, or posthog, to send events to PostHog. CHRONICLE_CHANNEL now names a channel in config/chronicle.php, not in config/logging.php. The chronicle log driver and the logging.channels.chronicle, chronicle.file, and posthog channels are removed.
- rename structured errors to structured exceptions
- record() now flattens nested fields and adds the Context fields. Chronicle::flattenContext() is removed.
- ChronicleException and ChronicleException::make() are removed. Catch InvalidSampler or MissingPostHogApiKey instead.
- classes move namespace, e.g. Larasell\Chronicle\Http\Middleware\LogRequests is now Larasell\Chronicle\WideEvents\Requests\Middleware\LogRequests and Larasell\Chronicle\StructuredError is now Larasell\Chronicle\Errors\StructuredError. Chronicle::errorPayload() is removed.
- CHRONICLE_SAMPLING is no longer read. Set 'sampling' in config/chronicle.php instead.
Features
- add structured versions of the SPL exceptions to throw inline (7d4fafc)
- assign request ids at the start of each request and fall back to the uri for unnamed routes (70dd527)
- build every wide event the same way (67867a3)
- configure chronicle's log channels like config/logging.php (94db0cc)
- install the opentelemetry packages with chronicle (f0aff78)
- set the sampler in the config file only (ab63275)
Bug Fixes
- require php 8.4 (97d5d96)
Miscellaneous Chores
- add Amp orb setup script (4fecb22)
Code Refactoring
- organise the package around wide events and structured errors (fafefd1)
- rename structured errors to structured exceptions (402c8e5)
- replace ChronicleException with named structured errors (e4b525c)
Upgrade guide
ChronicleExceptionis removedChronicleException::make()built a structured exception without a class of its own. Chronicle now has a structured version of each SPL exception for that, such asStructuredRuntimeException, so a thrown exception keeps a meaningful parent type. Chronicle's own errors are named classes:InvalidSamplerandMissingPostHogApiKey.Who is affected
Apps that throw
Larasell\Chronicle\ChronicleExceptionor catch it. Apps that never mention it are not affected.How to upgrade
Throw the structured exception that matches the error.
ChronicleExceptionextendedRuntimeException, soStructuredRuntimeExceptionkeeps yourcatchblocks working. It takes the same arguments asmake():1// Before 2use Larasell\Chronicle\ChronicleException; 3 4throw ChronicleException::make( 5 code: 'billing.declined', 6 message: 'Payment failed', 7 why: 'The issuer declined the charge', 8); 9 10// After 11use Larasell\Chronicle\Exceptions\StructuredRuntimeException; 12 13throw new StructuredRuntimeException( 14 code: 'billing.declined', 15 message: 'Payment failed', 16 why: 'The issuer declined the charge', 17);To catch Chronicle's configuration errors, catch
Larasell\Chronicle\WideEvents\Requests\Exceptions\InvalidSamplerorLarasell\Chronicle\WideEvents\Outputs\Exceptions\MissingPostHogApiKey. Both extendLogicException.Classes moved to new namespaces
Chronicle's classes are now grouped by its two concepts: wide events in
WideEvents\and structured exceptions inExceptions\. The structured error contract is now called a structured exception, since it is implemented by exceptions.Who is affected
Apps that import any of these classes:
Larasell\Chronicle\Http\Middleware\LogRequestsis nowLarasell\Chronicle\WideEvents\Requests\Middleware\LogRequests.Larasell\Chronicle\Sampleris nowLarasell\Chronicle\WideEvents\Requests\Sampler.Larasell\Chronicle\StructuredErroris nowLarasell\Chronicle\Exceptions\StructuredException.Larasell\Chronicle\HasStructuredErroris nowLarasell\Chronicle\Exceptions\HasStructuredException.
RequestLogger,ChronicleFormatterandPostHogHandlermoved too, and are now marked@internal. Use theLogRequestsmiddleware and thechronicleandposthoglog drivers instead of these classes.Chronicle::errorPayload()is removed.How to upgrade
Update the imports, and rename
StructuredErrorandHasStructuredErrorwhere your exceptions use them. The trait's methods, such asstructured()anderrorCode(), are unchanged.1// Before 2use Larasell\Chronicle\HasStructuredError; 3use Larasell\Chronicle\StructuredError; 4 5final class PaymentDeclined extends Exception implements StructuredError 6{ 7 use HasStructuredError; 8} 9 10// After 11use Larasell\Chronicle\Exceptions\HasStructuredException; 12use Larasell\Chronicle\Exceptions\StructuredException; 13 14final class PaymentDeclined extends Exception implements StructuredException 15{ 16 use HasStructuredException; 17}1// Before 2use Larasell\Chronicle\Http\Middleware\LogRequests; 3use Larasell\Chronicle\Sampler; 4 5// After 6use Larasell\Chronicle\WideEvents\Requests\Middleware\LogRequests; 7use Larasell\Chronicle\WideEvents\Requests\Sampler;record()flattens fields and addsContextrecord()now builds its event like request and error events do. It flattens nested arrays into dotted keys and adds everyContextfield, so your own events carry the same fields as the requests they happen in.Who is affected
Apps that call
Chronicle::record()with nested fields, or that read those events and expect nested objects or noContextfields. Apps that callChronicle::flattenContext(), which is removed. Apps that only useLogRequestsandrecordError()are not affected.How to upgrade
Read nested fields by their dotted key. A field you pass still wins over a
Contextfield with the same key.1Context::add('user', ['id' => 7]); 2 3app(Chronicle::class)->record('order.paid', ['order' => ['id' => 42]]);1// Before 2{"timestamp": "...", "type": "order.paid", "order": {"id": 42}} 3 4// After 5{"timestamp": "...", "type": "order.paid", "user.id": 7, "order.id": 42}Instead of calling
flattenContext()beforerecord(), pass the nested array torecord().Request ids are assigned when the request starts
LogRequestsnow gives each request its id before the app handles it, instead of when it writes the request event. The id is stored inContextasrequest.id, so every log line of the request carries it, and is sent back in theX-Request-Idresponse header. The id is the client'sX-Request-Idwhen it is at most 200 visible ASCII characters, and otherwise a UUIDv7 instead of 16 random hex characters.Who is affected
Apps that use
LogRequestson some routes only, or after other middleware, because log lines written beforeLogRequestsruns have norequest.id. Apps that parserequest.idand expect 16 hex characters. WithCHRONICLE_ENABLED=false, requests get no id and no header.How to upgrade
Prepend
LogRequeststo the global middleware inbootstrap/app.php, and remove it from your routes, so it runs once and before anything else:1use Illuminate\Foundation\Configuration\Middleware; 2use Larasell\Chronicle\WideEvents\Requests\Middleware\LogRequests; 3 4// Before 5Route::middleware(LogRequests::class)->group(function () { 6 // ... 7}); 8 9// After 10->withMiddleware(function (Middleware $middleware): void { 11 $middleware->prepend(LogRequests::class); 12})To read the id in your own code, call
Larasell\Chronicle\WideEvents\Requests\RequestId::current(). It returnsnulloutside a request.CHRONICLE_SAMPLINGis no longer readA sampler is a class name, which belongs in code that is reviewed and versioned with the class, not in an environment variable that can name a class the deployed code does not have.
Who is affected
Apps that set
CHRONICLE_SAMPLINGin their environment. Without the change, they log every request again. Apps that setsamplinginconfig/chronicle.phpwithoutenv()are not affected.How to upgrade
Publish the config file if you haven't, set the sampler class in it, and remove
CHRONICLE_SAMPLINGfrom your environment:1php artisan vendor:publish --tag=chronicle.config1// config/chronicle.php 2 3// Before 4'sampling' => env('CHRONICLE_SAMPLING'), 5 6// After 7'sampling' => App\Logging\KeepFailuresAndSlowRequests::class,