Chronicle
Chronicle answers a different question than Forensics does. Forensics is a movement flight recorder — it reconstructs where a flagged player’s body was. Chronicle is a kernel flight recorder — it reconstructs what the kernel did: every Bus:publish and every Hooks:fire verdict, across every system, for whoever is asking. The question Chronicle exists to answer is the one every live-service game eventually hits: “why did this player lose their sword?” Without it, that question means adding print statements to Inventory, Crafting, and Trading, then trying to reproduce a bug that already happened to one specific player five minutes ago. With it, the answer is Recorder:trace("Inventory") — a lookup against history that was already being recorded.
The design decision that defines it: Chronicle doesn’t hook into a handful of systems it knows about. It decorates the two chokepoints every kernel-integrating system already calls through — Bus.publish and Hooks.fire — so recording coverage is automatic and total. Attach Chronicle once and it sees every event anything on the bus ever publishes and every verdict any hook chain ever reaches, without either side knowing Chronicle exists.
Mental model
Section titled “Mental model”Decorating the live kernel, not wrapping a copy
Section titled “Decorating the live kernel, not wrapping a copy”Chronicle.attach(kernel) does not create its own event stream that other systems have to opt into. It reaches into the live kernel.Bus and kernel.Hooks instances and replaces their functions in place:
Instance.OriginalPublish = Bus.publishBus.publish = function(busSelf, topic, ...) Instance:_record("Bus", topic, nil, ...) return Instance.OriginalPublish(busSelf, topic, ...)endThis is monkey-patching, precisely: Bus.publish — the actual function every other module’s kernel.Bus:publish(...) call resolves to at call time, because Lua method calls look up the field on the table at the moment of the call — gets reassigned to a new closure. The new closure records the call, then calls through to whatever Bus.publish used to be, captured up front as Instance.OriginalPublish. Every existing caller keeps calling kernel.Bus:publish(...) exactly as before; they’re just now running through Chronicle’s wrapper first without any code change on their end. Hooks.fire gets the same treatment, with one difference: Hooks.fire returns (passed, context), and both values have to survive the decoration undisturbed, so the wrapper captures the original call’s results with table.pack, records the verdict, and returns everything back out with 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 truncate at the first nil.
Hooks decoration is conditional — if Hooks and type(Hooks.fire) == "function" — because not every kernel wires up a Hooks registry the same way Bus is guaranteed to exist. Bus decoration has no such guard: attach(kernel) assumes kernel.Bus and its publish method exist, and errors immediately if they don’t.
Decoration, rather than subscription, is the only mechanism that works for both sides. Bus.subscribe does support a trailing-wildcard topic ("Combat.*"), and — reading Bus.luau directly — a subscription of exactly ".*" trims to an empty prefix, which every topic’s string.sub(topic, 1, 0) == "" check satisfies, so it would in fact fire for every published topic. That still only solves half the problem: HookRegistry has no signal to subscribe to at all. HookRegistry.fire runs registered handlers synchronously in priority order and reduces the outcome to a (passed, context) return value — there is no broadcast a passive listener could attach to the way there is for Bus. A subscription-based recorder could have watched every bus event through a ".*" handler; it could never have seen a single hook verdict, because hooks don’t publish anything to subscribe to. Decorating Hooks.fire directly is the only way to observe a verdict, and using the same mechanism for Bus.publish means one attach/detach pair covers both instead of mixing a subscription for one and a wrapper for the other.
detach() reverses exactly this, and only this:
function Chronicle.detach(self: any) if self.OriginalPublish then self.Kernel.Bus.publish = self.OriginalPublish self.OriginalPublish = nil end if self.OriginalFire then self.Kernel.Hooks.fire = self.OriginalFire self.OriginalFire = nil end table.clear(self.Ring) self.Count = 0endBus.publish and Hooks.fire get reassigned back to the exact function references saved at attach time — not a re-derived “default” implementation, the literal closures that were live before Chronicle touched them. That’s what makes this safe to detach cleanly: there is no scenario where restoring leaves a half-patched function, because the original reference either was captured (and gets restored) or was never touched (nothing to restore). Calling detach() twice is harmless — the second call finds both Original* fields already nil and does nothing to the live functions, only re-clearing the (already empty) ring. After detach(), the kernel’s Bus.publish and Hooks.fire are the exact same functions they would be had Chronicle never attached — no leftover wrapper, no permanent overhead, and no double-recording if something re-attaches later.
The ring buffer
Section titled “The ring buffer”attach(kernel, options?) accepts Seconds (default 30) and MaxEntries (default 2048), both plain fields on the Options table — Seconds is not a hardcoded constant, it’s Options.Seconds or 30, so a caller who wants a longer or shorter trailing window passes it in. MaxEntries works the same way.
Storage is a classic fixed-size ring, structurally the same shape Forensics uses for movement frames: a Ring table sized up to MaxEntries, a Head index that advances Head = Head % MaxEntries + 1 on every record, and a Count that clamps at MaxEntries (math.min(self.Count + 1, self.MaxEntries)). Every _record call overwrites whatever used to sit at the new Head slot — no shifting, no growth past MaxEntries for the process’s lifetime.
The 30-second window and the 2048-entry cap are two independent limits, not one. Seconds only matters when you read — trace() computes Cutoff = self.Clock() - self.Seconds and skips any stored entry older than that. It is not what bounds memory; nothing prunes an old entry out of Ring just because it fell outside the window. What actually reclaims a slot is the ring wrapping back around after MaxEntries more records get written. In other words: on a quiet kernel firing only a few events a minute, entries can physically sit in Ring far longer than 30 seconds — trace() just won’t return them once they age out of the window, because the slot not being overwritten yet doesn’t mean the entry it holds is still “current.” On a busy kernel, 2048 entries can fill in well under 30 seconds, in which case the ring itself — not the window — becomes the effective limit on how far back trace() can see.
Each stored entry has this exact shape:
export type Entry = { At: number, Kind: string, -- "Bus" | "Hook" Topic: string, -- the bus topic or hook point name Args: string, -- summarized, see below Verdict: string?, -- Hook only: "PASS" | "REJECTED"}At is self.Clock() at record time (Clock defaults to os.clock, injectable for specs). Kind distinguishes a Bus:publish entry from a Hooks:fire entry. Topic is the bus topic string or the hook point name — the same string either system already uses to identify itself, no separate naming scheme. Verdict only exists on "Hook" entries: "REJECTED" when the hook chain’s first return was false, "PASS" otherwise; it’s nil on every "Bus" entry, since a publish has no pass/fail outcome to record.
Summarization: the privacy and safety constraint
Section titled “Summarization: the privacy and safety constraint”Args is not the raw argument list — it’s summarizeAll(...), which runs every argument (capped at the first 6) through summarize() and joins the results with ", ". This function is the one piece of Chronicle that has to be exactly right, because it’s what stops a 30-second recorder from becoming a 30-second dangling-reference generator: a Session or Player table sitting in Args after that player has already left would be exactly the kind of hazard a passive, always-on recorder should never introduce.
Reading summarize() directly:
local function summarize(value: any): string local Kind = typeof(value) if Kind == "string" then return if #value <= 40 then value else string.sub(value, 1, 40) .. "…" end if Kind == "number" or Kind == "boolean" then return tostring(value) end if Kind == "Instance" then return (value :: Instance).Name end if Kind == "Vector3" then local V = value :: Vector3 return string.format("(%.1f, %.1f, %.1f)", V.X, V.Y, V.Z) end if Kind == "table" then local Named = (value :: any).Player or (value :: any).Name if typeof(Named) == "Instance" then return (Named :: Instance).Name end if type(Named) == "string" then return Named end if type(Named) == "table" and type((Named :: any).Name) == "string" then return (Named :: any).Name end return "{…}" end return KindendEvery branch returns a plain string or number — never the original value. Concretely:
- A real
Instance(aPlayer, aPart, anything) summarizes to its.Name— a string, not the instance reference. - A
Vector3summarizes to a formatted"(x.x, y.y, z.z)"string, one decimal place. - A table — which is what a
Sessionobject typically is — checks for a.Playeror.Namefield. If that field is itself anInstance, its.Nameis used. If it’s already a string, that string is used directly. If it’s a nested table with its own string.Name, that name is used. Only if none of those match does it fall back to the opaque literal"{…}". At no point does any branch keep a reference to the table, theInstance, or anything reachable through them — the entry stores a name, not a pointer.
This is exactly what the changelog’s “keeps names and numbers, never object references, so the recorder cannot pin sessions” claim describes, confirmed against the actual branches rather than taken on faith: a Session = { Player = { Name = "Chloe" } } published as a bus argument records as the plain string "Chloe" in Args, not as the Session table. Foundations.spec.luau exercises this exact shape — Kernel.Bus:publish("Inventory.Granted", Session, "Sword", 1) with Session = { Player = { Name = "Chloe" } } — and asserts string.find(InventoryTrail[1].Args, "Chloe") ~= nil, i.e. the recorded entry contains the name, not a live handle back to the session or the player. Once that publish call returns, Chronicle is holding a string that happens to read "Chloe" — nothing about it can be dereferenced back into a Player Instance or a Session table, and nothing about it changes or dangles when that player later leaves. Strings also get their own cap independent of the reference question: anything over 40 characters truncates with a trailing …, so even a legitimately long string argument can’t make one entry unboundedly large.
trace(query) — string or predicate
Section titled “trace(query) — string or predicate”function Chronicle.trace(self: any, filter: (string | (entry: Entry) -> boolean)?): { Entry }With no filter, trace() returns every entry currently inside the Seconds window, oldest first. With a string filter, an entry survives if the string is found as a plain substring (string.find(..., 1, true) — the true disables pattern matching, so a literal . or ( in a topic name can’t accidentally act as a Lua pattern) in either Entry.Topic or Entry.Args. That’s the precise meaning of “mentions” — it is not topic-only: trace("Inventory") matches an entry whose topic is "Craft.Completed" if one of its summarized arguments happens to contain the substring "Inventory" somewhere, exactly as readily as it matches an entry whose topic is "Inventory.Granted". With a function filter, trace() calls it as filter(entry) through a pcall, keeping the entry only when the call succeeds and returns exactly true — a filter that errors is treated as “no match” for that entry rather than crashing the whole trace:
Recorder:trace(function(entry) return entry.Kind == "Hook" and entry.Verdict == "REJECTED"end)The predicate form is for anything a substring can’t express: filtering by Kind, by Verdict, by an exact topic match instead of a substring, or by combining several conditions.
format(query) — the readable dump
Section titled “format(query) — the readable dump”function Chronicle.format(self: any, filter: (string | (entry: Entry) -> boolean)?): stringformat() takes the same kind of query as trace() — a string, a predicate function, or nothing — and runs it internally (self:trace(filter)); it does not take a list of entries you already fetched. Each surviving entry becomes one line:
table.insert(Lines, string.format("%.2fs %s %s(%s)%s", Entry.At, Entry.Kind, Entry.Topic, Entry.Args, Verdict))So a printed line looks like:
12.40s Bus Inventory.Granted(Chloe, Sword, 1)12.41s Hook Intent.UseItem(Chloe)[REJECTED]— timestamp to two decimal places, Kind, Topic, the summarized args in parentheses, and, only on Hook entries, a bracketed verdict suffix ([PASS] or [REJECTED]; the suffix is empty on Bus entries since they have no Verdict). All matching lines join with \n into one string, ready to print() as a scrollable timeline.
Attach Chronicle early in boot, before any system starts publishing, so its coverage starts from the beginning of the session rather than from whenever someone remembers to add it:
-- Server Bootstraplocal ReplicatedStorage = game:GetService("ReplicatedStorage")local Chronicle = require(ReplicatedStorage.ChloeKernel.Debug.Chronicle)
return function(kernel) local Recorder = Chronicle.attach(kernel, { Seconds = 30, -- default; how far back trace()/format() can see MaxEntries = 2048, -- default; hard ring cap independent of Seconds })
kernel.Recorder = Recorder -- reachable from a command bar or admin tool
return RecorderendThen, when a report comes in — “a player says they lost their sword” — reach for it from a command-bar script or an admin/debug tool instead of adding prints and waiting for it to happen again:
-- Studio command bar, or a debug-tool button wired to a live kernel referencelocal Recorder = kernel.Recorder
print(Recorder:format("Inventory"))-- 11.80s Bus Inventory.Granted(Chloe, Sword, 1)-- 12.05s Bus Craft.Completed(Chloe, Potion)-- 14.30s Bus Inventory.Removed(Chloe, Sword, 1, TradeCancelled)-- 14.31s Hook Intent.Trade(Chloe)[REJECTED]
-- Or pull the raw entries first if you need to reason about them in code-- (count rejections, check ordering, feed a different report) before printing:local Trail = Recorder:trace("Inventory")print(`{#Trail} inventory-related entries in the last 30s`)print(Recorder:format(function(entry) return entry.Kind == "Hook" and entry.Verdict == "REJECTED"end))"Inventory.Removed(Chloe, Sword, 1, TradeCancelled)" followed by a rejected Intent.Trade hook a few milliseconds later turns “the sword is just gone” into an actual sequence of events — a trade got cancelled after the item had already left inventory, not a duplication bug or a client desync. That’s the entire value proposition: the timeline already exists by the time anyone asks the question.
| Member | Description |
|---|---|
Chronicle.attach(kernel: any, options: Options?) → Chronicle |
Decorates kernel.Bus.publish (required) and kernel.Hooks.fire (only if kernel.Hooks exists and has a fire function), saving the originals for detach(). Returns the recorder instance. Errors ("a Chronicle is already attached to this kernel; detach() it first") if this kernel already has an attached, undetached recorder. |
Recorder:trace(filter: (string | (entry: Entry) -> boolean)?) → { Entry } |
Entries inside the Seconds window, oldest first. No filter returns everything in the window; a string filter substring-matches Topic or Args; a function filter is called per entry and keeps it only on a pcall-safe true. |
Recorder:format(filter: (string | (entry: Entry) -> boolean)?) → string |
Runs the same query as trace() internally and renders each surviving entry as one "{At}s {Kind} {Topic}({Args})[{Verdict}]" line, newline-joined. |
Recorder:detach() |
Restores the original, undecorated Bus.publish and Hooks.fire (whichever were actually captured), clears this kernel’s entry in the attached-recorder registry (permitting a subsequent attach()), then clears the ring and resets Count to 0. Idempotent — calling it again with nothing left to restore is a no-op on the live functions. |
Options: { Seconds: number? (default 30), MaxEntries: number? (default 2048), Clock: (() -> number)? (default os.clock) }.
An Entry: { At: number, Kind: "Bus" | "Hook", Topic: string, Args: string, Verdict: string? } — Verdict only populated on "Hook" entries.
Hooks & bus topics
Section titled “Hooks & bus topics”Chronicle doesn’t publish or consume any bus topic or hook point of its own — its entire interface to the rest of the kernel is the decoration of Bus.publish and Hooks.fire described above, not a subscription. It observes everything flowing through those two functions; it adds nothing to what flows through them (the return values of both are passed through unchanged after being recorded).
See also
Section titled “See also”Forensics is the sibling flight recorder for player movement rather than kernel activity — read it for how a very similar ring-buffer shape serves a different recording job. Debug Panels covers the F8 HOOKS window, which also decorates a live kernel function (Hooks.fire) for the same reason Chronicle does, but renders live counters instead of a queryable timeline. The Bus and Hooks are the two systems Chronicle decorates without either one knowing it.