Skip to content

Audit

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

Every 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

luau
Audit.new(options: Options?): Audit
luau
local audit = Audit.new({ bufferSize = 500 })
local dispatcher = Dispatcher.new(registry, { audit = audit })

Methods

:record

luau
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

luau
audit:query(query: Query?): { Record }

Every filter is optional and results come back newest first.

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

:addSink

luau
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.

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

:count

luau
audit:count(): number

How many records the buffer currently holds.

:destroy

luau
audit:destroy(): ()

Closes the sinks it holds.

Types

Record

luau
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 },
}
FieldMeaningTrust
ida sequence number within this serverserver
source"server", or "client" for a client command reportserver
timestampos.time() when the record was madeserver
jobIdwhich server it happened onserver
executorIdwho ran it; nil when a script didserver
executorNametheir username, or <server>server
executorDisplayNametheir display name, or <server>server
paththe resolved command pathserver, or client-reported
textthe input that was acted onserver, or client-reported
status / okthe outcomeserver, or client-reported
messagethe reply, or the real reason it failedserver, or client-reported
argsthe resolved arguments, stringifiedserver, or client-reported
targetsplayers 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

luau
type Query = {
	limit: number?,
	source: Source?,
	executorId: number?,
	status: string?,
	path: string?,
	ok: boolean?,
}
FilterEffect
limithow many records to return; counts matches, not rows scanned
source"server" for records the server observed, "client" for client-reported; omit for both
executorIdonly this player
statusonly this Status
pathonly this command path, case-insensitively
okonly successes or only failures

Every filter is optional and they compose. Results come back newest first.

Options

luau
type Options = {
	enabled: boolean?,
	bufferSize: number?,
	sinks: { Sink }?,
	logger: Log.Logger?,
}
FieldDefaultMeaning
enabledtrueset false and record returns nil
bufferSize200how many records the ring buffer keeps
sinksnonesinks to install at construction
loggersilenta Logger

Sink

luau
type Sink = {
	write: (self: Sink, record: Record) -> (),
	destroy: ((self: Sink) -> ())?,
}

Entry

What :record is given, before it becomes a Record.

luau
type Entry = {
	jobId: string,
	executor: Player?,
	path: { string },
	text: string,
	status: string,
	ok: boolean,
	message: string?,
	args: { [string]: any }?,
}

The Discord sink

luau
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.

OptionDefaultMeaning
webhookUrlrequiredthe 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
footerIconthe Operator markthe footer icon URL
batchSize10records to accumulate before flushing early; capped at Discord's 10 embeds
flushSeconds5how often the queue flushes regardless
usernamewebhook defaultoverrides the webhook's display name
loggera silent loggerwhere its own warnings go

What an embed looks like

One embed per record, up to ten per message.

PartFrom
Author name, headshotexecutorDisplayName (@executorName), executorId
Titlepath, joined with spaces
Colourthe outcome; see below
Executor ID, Status, TargetsexecutorId, status, targets, inline
Messagemessage, only on a failure
Raw texttext, in a code block
Argumentsargs, as key: value lines
Group, Job IDeverything above the leaf of path, and jobId, inline
Timestamptimestamp, the record's own, not the send time
FooterOperator v<version> • by cb12438

Colour is the outcome at a glance, so one failure stands out in a channel of successes:

ColourWhen
#4FBF87greenok
#EE6A6AredDenied, Errored, Failed, Invalid, NotFound
#7C8F9BgreyCancelled, ExecutorLeft, RateLimited: didn't run, but didn't fail
#F2A93Bamberany 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.

luau
Audit = {
	DiscordWebhook = webhookUrl,
	DiscordSend = "failures",
}
ValueForwards
"all" (default)every record
"failures"only records where ok is false
a function(record) -> boolean; return true to forward
luau
DiscordSend = function(record)
	return not record.ok or record.path[#record.path] == "ban"
end

The 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:

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

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

Released under the MIT Licence.