Skip to content

Audit Log

Every dispatch produces a record: successful, guard-rejected and validation-failed alike. The record exists whether or not any sink is configured, and an in-memory ring buffer is on by default, so the log is useful with zero external setup.

luau
local Audit = require(game:GetService("ServerStorage").Operator).Audit

local audit = Audit.new()
local dispatcher = Dispatcher.new(registry, { audit = audit })

What a record contains

FieldMeaning
ida sequence number within this server
source"server", or "client" for a client command report
timestampos.time() when the record was made
jobIdwhich server it happened on
executorId / executorName / executorDisplayNamewho ran it; <server> when a script did
paththe resolved command path
textthe input that was acted on
status / okthe outcome
messagethe reply, or the real reason it failed
argsthe resolved arguments, stringified
targetsany players the command acted on, as Name (UserId)

Records are frozen. args holds the transformed values, not the raw tokens, so a record shows amount = "42" and target = "Bob (7)" rather than whatever was typed.

What you can and cannot trust

Most records are built by the server at dispatch, from what the server itself observed. Client commands are the exception, and the difference matters when you are investigating an incident.

Identity is always server-produced

id, source, timestamp, jobId, executorId, executorName and executorDisplayName are stamped by the server on every record, from the remote invocation rather than from anything a client sent. Who ran something, when, and on which server cannot be forged.

A client-reported record is not server-verified

A client command runs entirely on the player's machine and never reaches the server, so the server cannot observe it. The client reports it instead, and those records carry source = "client", and show as client-reported in the log panel.

For those records path, text, status, ok, message and args are the client's word. The server refuses a report unless it names a registered client command that this player was actually permitted to run, with a known status and every length capped. That bounds the lie to a command they were allowed to run, but 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-reported record proves nothing.

When you need only what the server observed itself, filter on it:

luau
audit:query({ source = "server" })   -- verified only
audit:query({ source = "client" })   -- client-reported only
audit:query({})                      -- both

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.

Reading the log

luau
audit:query({ limit = 20, executorId = player.UserId, status = "Denied", ok = false, path = "ban" })

Every filter is optional and results come back newest first. The buffer holds the last 200 records by default; set bufferSize to change it.

The moderation pack ships a logs command, which opens the log panel and also prints its results as text:

logs
logs 25
logs 25 --player=Bob
logs --failed

Excluding a command

Per-command, never a config list of names:

luau
Command.new("help"):noAudit()

The debug pack's read-only commands (help, ping, jobid, players) already use it, so browsing help does not bury real activity.

Sinks

An AuditSink is anything with a write method, and an optional destroy:

luau
audit:addSink({
	write = function(_, record)
		MyStore.append(record)
	end,
})

addSink returns a function that removes it 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. audit:destroy() closes the sinks it holds.

Forwarding to Discord

A Discord sink ships with the package for the common case. Give Start a webhook URL and every record becomes an embed in that channel:

luau
Operator.Start({
	Audit = { DiscordWebhook = webhookUrl },
})

Each embed leads with who ran it, display name and headshot, titled with the command, and coloured by outcome: green ran, red failed, grey never ran. A moderator scanning the channel finds the one failure without reading every entry. Below that: the executor's user ID, the status, the targets, the raw line, the arguments as readable key: value lines, the group, and the job ID of the server it happened on.

Client-reported records are amber, prefixed Client-reported ·, and carry a callout saying what is and is not verified. That is three markers rather than one, because a colour alone is easy to miss when you are reading rather than scanning.

It batches, retries with backoff, truncates rather than failing a send, drops rather than retrying forever, and never blocks a dispatch.

Only send what needs attention

On a busy game, one embed per command is a channel nobody reads. DiscordSend = "failures" forwards only what did not succeed, while everything still lands in the ring buffer and the log panel. See Configuration.

Every field, colour and option is in api/audit.

The webhook URL is configuration, not code

Pass it in from somewhere that is not a committed script: Roblox's secrets store, or a server-only module you do not commit.

Storing records yourself

A sink is also how you get history that outlives the server. The log is in memory and per-server; if you need bans to survive a restart, or one place to search across all your servers, a sink writing to your own storage is the supported route.

You choose the storage. Operator does not pick a DataStore key, a write budget or a retention policy for you.

Keeping your own logging bounded

Anything that logs on a rejection can be driven by whoever causes the rejections. Left uncapped, an exploiter spamming the remote turns your logging adapter into an amplifier, and if that adapter feeds a webhook, into your infrastructure.

Operator throttles at the adapter rather than at each call site, so this is handled for the package's own logging whatever sink you install. The limits are in api/log.

Released under the MIT Licence.