Audit
local Operator = require(game:GetService("ServerStorage").Operator)
local Audit = Operator.AuditEvery dispatch produces a record: successful, guard-rejected and validation-failed alike. An in-memory ring buffer is on by default, so the log is useful with zero external setup.
Functions
Audit.new
Audit.new(options: Options?): Auditlocal audit = Audit.new({ bufferSize = 500 })
local dispatcher = Dispatcher.new(registry, { audit = audit })Methods
:record
audit:record(entry: Entry): Record?Builds and stores a record. The dispatcher calls this for you; you would only call it directly when driving dispatch yourself. Returns nil when the audit is disabled.
:query
audit:query(query: Query?): { Record }Every filter is optional and results come back newest first.
audit:query({ limit = 20, executorId = player.UserId, status = "Denied", ok = false, path = "ban" })
audit:query({ source = "server" }):addSink
audit:addSink(sink: Sink): () -> ()Returns a function that removes the sink again.
Sinks are called on their own thread, so a slow sink never delays a dispatch, and one that raises is logged and skipped without affecting the record, the buffer, or any other sink.
local remove = audit:addSink({
write = function(_, record)
MyStore.append(record)
end,
}):count
audit:count(): numberHow many records the buffer currently holds.
:destroy
audit:destroy(): ()Closes the sinks it holds.
Types
Record
type Source = "server" | "client"
type Record = {
id: number,
source: Source,
timestamp: number,
jobId: string,
executorId: number?,
executorName: string,
executorDisplayName: string,
path: { string },
text: string,
status: string,
ok: boolean,
message: string?,
args: { [string]: string },
targets: { string },
}| Field | Meaning | Trust |
|---|---|---|
id | a sequence number within this server | server |
source | "server", or "client" for a client command report | server |
timestamp | os.time() when the record was made | server |
jobId | which server it happened on | server |
executorId | who ran it; nil when a script did | server |
executorName | their username, or <server> | server |
executorDisplayName | their display name, or <server> | server |
path | the resolved command path | server, or client-reported |
text | the input that was acted on | server, or client-reported |
status / ok | the outcome | server, or client-reported |
message | the reply, or the real reason it failed | server, or client-reported |
args | the resolved arguments, stringified | server, or client-reported |
targets | players the command acted on, as Name (UserId); always empty when source == "client" | server |
Records are frozen. args holds transformed values, not raw tokens, so a record shows target = "Bob (7)" rather than whatever was typed.
Identity is always server-produced
id, source, timestamp, jobId, executorId, executorName and executorDisplayName are stamped by the server on every record, taken from the remote invocation rather than from any payload. Who ran something, when, and on which server cannot be forged from a client.
A source == "client" record is not server-verified
Client commands execute on the client and never reach the server, so their records are reported by the client rather than observed by the server. For those records path, text, status, ok, message and args are the client's word.
The server refuses a report unless the path resolves to a registered client command that this player is actually permitted to run, with a known status and every length and count capped. That bounds the lie to a command they were allowed to run. It does not verify what they say they ran.
Omission cannot be prevented at all. A modified client can simply not report, and nothing on the server will notice. Absence of a client record proves nothing.
Filter on source == "server" when you need records the server observed itself.
Two things worth noting about failures: a command that raises reports a generic the command failed to the caller while the real error goes into the record, and a command the player was not allowed to run is recorded under its true path even though the caller was told NotFound.
Query
type Query = {
limit: number?,
source: Source?,
executorId: number?,
status: string?,
path: string?,
ok: boolean?,
}| Filter | Effect |
|---|---|
limit | how many records to return; counts matches, not rows scanned |
source | "server" for records the server observed, "client" for client-reported; omit for both |
executorId | only this player |
status | only this Status |
path | only this command path, case-insensitively |
ok | only successes or only failures |
Every filter is optional and they compose. Results come back newest first.
Options
type Options = {
enabled: boolean?,
bufferSize: number?,
sinks: { Sink }?,
logger: Log.Logger?,
}| Field | Default | Meaning |
|---|---|---|
enabled | true | set false and record returns nil |
bufferSize | 200 | how many records the ring buffer keeps |
sinks | none | sinks to install at construction |
logger | silent | a Logger |
Sink
type Sink = {
write: (self: Sink, record: Record) -> (),
destroy: ((self: Sink) -> ())?,
}Entry
What :record is given, before it becomes a Record.
type Entry = {
jobId: string,
executor: Player?,
path: { string },
text: string,
status: string,
ok: boolean,
message: string?,
args: { [string]: any }?,
}The Discord sink
local Discord = Operator.Sinks.Discord
audit:addSink(Discord.new({
webhookUrl = webhookUrl,
send = "failures",
batchSize = 10,
flushSeconds = 5,
}))One optional implementation with no special status. Delete it and nothing else changes. It batches, splits payloads that would exceed Discord's limits, retries with backoff, drops its oldest entries rather than growing without bound if the endpoint stays down, and never blocks a dispatch.
Operator.Start builds this for you from Audit.DiscordWebhook; construct it directly only if you are using Audit without Start.
| Option | Default | Meaning |
|---|---|---|
webhookUrl | required | the Discord webhook to post to |
send | "all" | which records to forward; see Sending only failures |
version | "0.0.0" | the version shown in the footer; Start passes Operator.Version |
footerIcon | the Operator mark | the footer icon URL |
batchSize | 10 | records to accumulate before flushing early; capped at Discord's 10 embeds |
flushSeconds | 5 | how often the queue flushes regardless |
username | webhook default | overrides the webhook's display name |
logger | a silent logger | where its own warnings go |
What an embed looks like
One embed per record, up to ten per message.
| Part | From |
|---|---|
| Author name, headshot | executorDisplayName (@executorName), executorId |
| Title | path, joined with spaces |
| Colour | the outcome; see below |
| Executor ID, Status, Targets | executorId, status, targets, inline |
| Message | message, only on a failure |
| Raw text | text, in a code block |
| Arguments | args, as key: value lines |
| Group, Job ID | everything above the leaf of path, and jobId, inline |
| Timestamp | timestamp, the record's own, not the send time |
| Footer | Operator v<version> • by cb12438 |
Colour is the outcome at a glance, so one failure stands out in a channel of successes:
| Colour | When | |
|---|---|---|
#4FBF87 | green | ok |
#EE6A6A | red | Denied, Errored, Failed, Invalid, NotFound |
#7C8F9B | grey | Cancelled, ExecutorLeft, RateLimited: didn't run, but didn't fail |
#F2A93B | amber | any client-reported record |
These are the console's own palette colours, so the channel matches the tool.
Client-reported records
A record with source = "client" gets three markers, deliberately redundant, because scanning, reading and acting are three different behaviours:
- an amber bar, which overrides the outcome colour, because whether it can be trusted matters more at a glance than whether it succeeded
- a title prefix:
Client-reported · <path> - a callout stating that identity and timestamp are server-stamped and everything else is the client's word
Why that matters is in Audit Log.
Sending only failures
At a few hundred concurrent players with several moderators, one embed per command is a channel nobody reads.
Audit = {
DiscordWebhook = webhookUrl,
DiscordSend = "failures",
}| Value | Forwards |
|---|---|
"all" (default) | every record |
"failures" | only records where ok is false |
| a function | (record) -> boolean; return true to forward |
DiscordSend = function(record)
return not record.ok or record.path[#record.path] == "ban"
endThe filter applies only to the webhook. The ring buffer, the log panel and audit:query are unaffected, so filtering never costs you a record. It only decides what is worth a notification. A filter that raises drops that one record rather than taking the sink down.
Limits
Discord caps a field value at 1024 characters, an embed at 6000, and a message at 10 embeds. The sink truncates with a … (truncated) note rather than failing a send, which a long players reply will hit, and splits into more messages rather than dropping records.
The webhook URL is configuration, not code
Pass it in from somewhere that is not a synced, committed script: Roblox's secrets store, or a server-only module you do not commit.
Excluding a command
Per-command, never a config list of names:
Command.new("help"):noAudit()The debug pack's read-only commands already use it, so browsing help does not bury real activity.