Skip to content

Chronicle

A flight recorder for the kernel that keeps the last 30 seconds of Bus publishes and hook verdicts, queryable with trace() after something already went wrong.

Realm
Shared
Require
ReplicatedStorage.ChloeKernel.Debug.Chronicle
Since
v0.7.0
Source
Debug/Chronicle.luau

Chronicle records what the kernel did: every Bus:publish and every Hooks:fire verdict, across every system, into a ring you can query afterwards. It exists for the report that arrives after the fact (“why did this player lose their sword?”). Without it the answer means adding prints to Inventory, Crafting and Trading and waiting for the bug to happen again. With it the answer is Recorder:trace("Inventory") against history that was already being kept. Forensics is the sibling recorder for player movement, and the HOOKS window decorates the same Hooks.fire to show live counters instead of a timeline.

The decision that defines it: Chronicle has no list of systems to hook into. It decorates the two functions every kernel-integrated system already calls, Bus.publish and Hooks.fire, so coverage is automatic and neither side knows Chronicle is there.

Chronicle.attach(kernel) replaces the functions on the live kernel.Bus and kernel.Hooks tables:

Instance.OriginalPublish = Bus.publish
Instance.PublishWrapper = function(busSelf: any, topic: string, ...)
if not Instance.Detached then
Instance:_record("Bus", topic, nil, ...)
end
return Instance.OriginalPublish(busSelf, topic, ...)
end
Bus.publish = Instance.PublishWrapper

A method call such as kernel.Bus:publish(...) looks up publish on the table at call time, so every existing caller runs through the wrapper with no change on its side. Hooks.fire gets the same treatment with one difference: it returns (passed, context), and both values must survive. The wrapper captures the original call’s results with table.pack, records "REJECTED" when the first result is false and "PASS" otherwise, and returns table.unpack(Results, 1, Results.n). The explicit count matters because a nil context is a valid result and a plain table.unpack(Results) would stop at it.

The hook decoration is conditional (if Hooks and type(Hooks.fire) == "function") because not every kernel wires a Hooks registry. The Bus decoration has no guard: attach expects kernel.Bus.publish to exist and errors when it does not.

Decoration is the only mechanism that covers both sides. Bus supports wildcard subscriptions, but HookRegistry has nothing to subscribe to: fire runs its handlers in priority order and reduces them to a return value without broadcasting anything. A recorder that subscribed could see every Bus event and no hook verdict. Using the same mechanism for both keeps attach and detach symmetric.

Chronicle and HookScope are independent decorators of Hooks.fire. Each saves whatever Hooks.fire was when it attached and wraps it, so attaching both stacks one wrapper on top of the other. Detaching them must work in either order, and since 0.9.0 it does:

  • detach() marks the recorder Detached. Its wrappers stop recording and call straight through to the saved originals.
  • It restores Bus.publish and Hooks.fire only while its own wrapper is still the installed one.
  • If another decorator wrapped it since, the Chronicle wrapper stays in the chain as a pass-through, so that decorator keeps calling a live function. The saved originals are never cleared for the same reason.

Before 0.9.0, detaching the inner decorator first restored a stale function over the outer one. HookScope.spec.luau covers both orders, two HookScopes detached out of order, and a fresh recorder that still records through a leftover pass-through.

attach(kernel, options?) takes Seconds (default 30) and MaxEntries (default 2048). The ring is a fixed array: Head advances with Head % MaxEntries + 1 on every record and overwrites that slot, and Count stops growing at MaxEntries. Nothing shifts and nothing grows past MaxEntries.

The two limits are independent. Seconds applies only when you read: trace() computes Cutoff = Clock() - Seconds and skips older entries, but it never removes them. Only the ring wrapping reclaims a slot. On a quiet kernel an entry can sit in memory far longer than 30 seconds while trace() ignores it. On a busy kernel 2048 entries can fill in well under 30 seconds, and then the ring, not the window, decides how far back you can see.

Each entry:

export type Entry = {
At: number, -- Clock() at record time; Clock defaults to os.clock
Kind: string, -- "Bus" | "Hook"
Topic: string, -- the Bus topic or hook point name
Args: string, -- summarized, see below
Verdict: string?, -- Hook entries only: "PASS" | "REJECTED"
}

Args is never the raw argument list. It is the first 6 arguments passed through summarize() and joined with ", ". This is the one part of Chronicle that has to be exactly right: a recorder that kept a Session or Player reference would pin those objects for as long as the entry lives.

Argument Recorded as
String The string, cut to 40 characters with a trailing ….
Number, boolean tostring(value).
Instance Its Name.
Vector3 "(x, y, z)" with one decimal place.
Table Its .Player or .Name: an Instance’s name, a string as is, or a nested table’s string Name. Otherwise "{…}".
Anything else Its type name.

Every branch returns a string, never the value. A Session shaped { Player = { Name = "Chloe" } } records as "Chloe", and nothing in the entry can be dereferenced back into the session or the player.

function Chronicle.trace(self: any, filter: (string | (entry: Entry) -> boolean)?): { Entry }

With no filter, trace() returns every entry inside the window, oldest first. A string filter keeps an entry when the string appears as a plain substring (no patterns) in its Topic or its Args. trace("Inventory") therefore matches a Craft.Completed entry whose arguments mention Inventory, as well as every Inventory.* topic. A function filter is called through pcall for each entry; the entry is kept only when the call succeeds and returns exactly true, so a filter that errors skips entries instead of breaking the trace.

format() takes the same query as trace(), runs it, and renders one line per entry with string.format("%.2fs %s %s(%s)%s", At, Kind, Topic, Args, Verdict), where the verdict suffix is " [PASS]" or " [REJECTED]" on hook entries and empty on Bus entries. Lines are joined with newlines:

12.40s Bus Inventory.Granted(Chloe, Sword, 1)
12.41s Hook Intent.UseItem(Chloe) [REJECTED]

Attach early in boot, before systems start publishing, so coverage starts with the session:

src/Server/Bootstrap.luau
--!strict
-- Server bootstrap: runs between kernel boot and start.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local Chronicle = require(ReplicatedStorage.ChloeKernel.Debug.Chronicle)
return function(kernel: any)
-- Studio-priced: one summarize per publish and per fire
if RunService:IsStudio() or ReplicatedStorage:GetAttribute("ChloeKernelDebug") == true then
kernel.Recorder = Chronicle.attach(kernel, {
Seconds = 30, -- how far back trace() and format() look
MaxEntries = 2048, -- the memory bound, independent of Seconds
})
end
require(script.Parent.Game)(kernel)
end

When a report comes in, query the history from the command bar or a debug tool instead of adding prints and waiting:

local Recorder = kernel.Recorder
print(Recorder:format("Inventory"))
-- 11.80s Bus Inventory.Granted(Chloe, Sword, 1)
-- 14.30s Bus Inventory.Removed(Chloe, Sword, 1, TradeCancelled)
-- 14.31s Hook Intent.Trade(Chloe) [REJECTED]
local Rejections = Recorder:trace(function(entry)
return entry.Kind == "Hook" and entry.Verdict == "REJECTED"
end)
print(`{#Rejections} rejected hook chains in the last 30 s`)

An Inventory.Removed followed a few milliseconds later by a rejected Intent.Trade turns “the sword is gone” into a sequence: the trade was cancelled after the item had left the inventory. The timeline already exists by the time anyone asks.

Member Description
Chronicle.attach(kernel: any, options: Options?): Chronicle Decorates kernel.Bus.publish (required) and kernel.Hooks.fire (when present). Errors with a Chronicle is already attached to this kernel; detach() it first if this kernel already has a recorder that was not detached.
recorder:trace(filter: (string | (entry: Entry) -> boolean)?): { Entry } Entries inside the window, oldest first, filtered by substring (topic or args) or by predicate.
recorder:format(filter: (string | (entry: Entry) -> boolean)?): string Runs the same query and renders one line per entry.
recorder:detach() Stops recording, restores each function its wrapper still owns, leaves any other wrapper as a pass-through, frees the kernel for a new attach, and clears the ring. Safe to call twice.

Options: { Seconds: number? (default 30), MaxEntries: number? (default 2048), Clock: (() -> number)? (default os.clock) }. Clock exists for specs that drive time by hand.

Chronicle publishes no Bus topic and defines no hook point. Its only interface to the kernel is the decoration of Bus.publish and Hooks.fire, and it passes both functions’ arguments and return values through unchanged.

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.