How to make a Rust chat logger plugin (rolling log + search)
Records every chat message with timestamp, SteamID, name, channel and text into a capped oxide/data file, and adds a /chatlog command that prints a player's recent messages. Uses the OnPlayerChat hook with BasePlayer, string and Chat.ChatChannel.
Chat in Rust is not persisted anywhere. If a player reports harassment or an admin wants to check what someone said in team or global chat last night, the server console scrollback is often gone by then. This plugin keeps a rolling history instead of an unbounded log: every message is appended with a UTC timestamp, the sender's SteamID and display name, the chat channel and the text, and the oldest entries are trimmed so the file never grows past a configurable cap.
The hook used is OnPlayerChat(BasePlayer player, string message, Chat.ChatChannel channel). Messages are buffered in memory and written on a timer plus a forced save on unload, so a busy server is not hitting the data file on every line. /chatlog <player> (name or SteamID) prints a player's most recent messages to the admin's console with index and channel. The config controls the maximum number of stored messages, how many /chatlog shows by default, the timestamp format and the save interval.
The plugin code
using System.Linq;
using System;
using System.Collections.Generic;
using System.Globalization;
using Newtonsoft.Json;
using Oxide.Core;
using UnityEngine;
namespace Oxide.Plugins
{
[Info("ChatLogger", "RustPluginGenerator", "1.0.0")]
[Description("Stores a rolling log of chat messages and lets admins read a player's recent messages.")]
public class ChatLogger : RustPlugin
{
private const string PermUse = "chatlogger.use";
private class PluginConfig
{
[JsonProperty("Maximum stored messages")]
public int MaxStoredMessages = 3000;
[JsonProperty("Messages shown per query")]
public int MessagesShown = 50;
[JsonProperty("Max message length")]
public int MaxMessageLength = 500;
[JsonProperty("Timestamp format")]
public string TimestampFormat = "yyyy-MM-dd HH:mm:ss";
[JsonProperty("Save interval seconds")]
public float SaveIntervalSeconds = 20f;
}
private class ChatRecord
{
public string Time;
public string SteamId;
public string Name;
public string Channel;
public string Text;
}
private class LogData
{
public List<ChatRecord> Records = new List<ChatRecord>();
}
private PluginConfig config;
private LogData data;
private Timer saveTimer;
private bool dirty;
protected override void LoadDefaultConfig() => config = new PluginConfig();
protected override void LoadConfig()
{
base.LoadConfig();
try
{
config = Config.ReadObject<PluginConfig>();
if (config == null) throw new Exception();
}
catch
{
PrintWarning("Configuration file is invalid, using defaults");
LoadDefaultConfig();
}
SaveConfig();
}
protected override void SaveConfig() => Config.WriteObject(config);
protected override void LoadDefaultMessages()
{
lang.RegisterMessages(new Dictionary<string, string>
{
["NoPermission"] = "You do not have permission to use this command.",
["Usage"] = "Usage: /chatlog <player name or SteamID> [count]",
["NoMessages"] = "No stored chat messages for {0}.",
["Header"] = "Recent messages for {0} ({1} found):",
["Line"] = "{0}. [{1}] [{2}] {3}",
["BadCount"] = "Count must be a number between 1 and 200."
}, this);
}
private string Msg(string key, string userId = null, params object[] args) => string.Format(lang.GetMessage(key, this, userId), args);
private void Init()
{
permission.RegisterPermission(PermUse, this);
}
private void OnServerInitialized()
{
try
{
data = Interface.Oxide.DataFileSystem.ReadObject<LogData>(Name);
}
catch
{
data = null;
}
if (data == null || data.Records == null) data = new LogData();
if (data.Records.Count > config.MaxStoredMessages) TrimToLimit();
saveTimer = timer.Every(config.SaveIntervalSeconds, () =>
{
if (dirty) SaveLog();
});
}
private void Unload()
{
if (saveTimer != null) saveTimer.Destroy();
SaveLog();
}
// Capture every chat message; return null so chat behavior is unchanged.
private object OnPlayerChat(BasePlayer player, string message, ConVar.Chat.ChatChannel channel)
{
if (player == null || string.IsNullOrEmpty(message)) return null;
if (data == null) return null;
string text = message.Length > config.MaxMessageLength
? message.Substring(0, config.MaxMessageLength) + "..."
: message;
data.Records.Add(new ChatRecord
{
Time = DateTime.UtcNow.ToString(config.TimestampFormat, CultureInfo.InvariantCulture),
SteamId = player.userID.Get().ToString(),
Name = player.displayName ?? "unknown",
Channel = channel.ToString(),
Text = text
});
TrimToLimit();
dirty = true;
return null;
}
private void TrimToLimit()
{
int limit = Mathf.Max(1, config.MaxStoredMessages);
while (data.Records.Count > limit) data.Records.RemoveAt(0);
}
private void SaveLog()
{
if (data == null) return;
Interface.Oxide.DataFileSystem.WriteObject(Name, data);
dirty = false;
}
[ChatCommand("chatlog")]
private void CmdChatLog(BasePlayer player, string command, string[] args)
{
if (player == null) return;
if (!permission.UserHasPermission(player.UserIDString, PermUse))
{
SendReply(player, Msg("NoPermission", player.UserIDString));
return;
}
if (args == null || args.Length == 0)
{
SendReply(player, Msg("Usage", player.UserIDString));
return;
}
int count = config.MessagesShown;
int queryLength = args.Length;
if (args.Length > 1 && int.TryParse(args[args.Length - 1], out int requested))
{
if (requested < 1 || requested > 200)
{
SendReply(player, Msg("BadCount", player.UserIDString));
return;
}
count = requested;
queryLength--;
}
string query = string.Join(" ", args, 0, queryLength);
if (string.IsNullOrEmpty(query))
{
SendReply(player, Msg("Usage", player.UserIDString));
return;
}
bool byId = query.Length == 17 && ulong.TryParse(query, out _);
var matches = new List<ChatRecord>();
foreach (var record in data.Records)
{
if (record == null) continue;
if (byId)
{
if (record.SteamId == query) matches.Add(record);
}
else if (string.Equals(record.Name, query, StringComparison.OrdinalIgnoreCase))
{
matches.Add(record);
}
}
if (matches.Count == 0)
{
SendReply(player, Msg("NoMessages", player.UserIDString, query));
return;
}
int start = Mathf.Max(0, matches.Count - count);
SendReply(player, Msg("Header", player.UserIDString, query, matches.Count));
for (int i = start; i < matches.Count; i++)
{
var record = matches[i];
SendReply(player, Msg("Line", player.UserIDString, i + 1, record.Time, record.Channel, record.Text));
}
}
}
}Hooks used: OnServerInitialized, OnPlayerChat
Config file
Created automatically on first load at oxide/config/ChatLogger.json.
{
"Maximum stored messages": 3000,
"Messages shown per query": 50,
"Max message length": 500,
"Timestamp format": "yyyy-MM-dd HH:mm:ss",
"Save interval seconds": 20
}Commands and permissions
| Command | What it does | Permission |
|---|---|---|
/chatlog <player> [count] | Prints the most recent chat messages of a player (matched by display name or SteamID) to the caller's console. | chatlogger.use |
| Permission | Grants |
|---|---|
chatlogger.use | Allows using /chatlog to read stored chat messages. |
How the code works
Capture messages in OnPlayerChat
The hook signature is
object OnPlayerChat(BasePlayer player, string message, Chat.ChatChannel channel). Null-checkplayer, ignore empty text, and build a record fromDateTime.UtcNow,player.userID.Get().ToString(),player.displayName, the channel enum and the message. Returnnullso the chat itself is untouched by the plugin. Because this hook fires on every line typed, nothing is printed here; all output happens from the command.Rolling buffer and trimming
Records are kept in a
List<ChatRecord>field and also persisted throughInterface.Oxide.DataFileSystem.WriteObject. After each append, a while loop removes entries from the front untilconfig.MaxStoredMessagesis respected, so the data file only ever holds the newest N lines. SetMaximum stored messageslow (2000-5000) if your server is very busy, since the whole list is serialized on each save.Batched saving with a timer
A
Timercreated inOnServerInitializedcallsSaveLog()everySave interval seconds. A dirty flag avoids rewriting the file when no new message arrived.UnloadcallsSaveLog()once more and destroys the timer, so nothing is lost on a reload. Data is loaded inOnServerInitializedwithInterface.Oxide.DataFileSystem.ReadObject<LogData>(Name), and a null result falls back to an empty list.Configuration
The nested
PluginConfigclass has[JsonProperty]fields forMaximum stored messages,Messages shown per query,Timestamp formatandSave interval seconds.LoadConfigreads the file, falls back toLoadDefaultConfigon failure and always callsSaveConfig, which writes the object back so admins see every key with its current value.Reading a player's history
[ChatCommand("chatlog")]checkspermission.UserHasPermission(player.UserIDString, PermUse), then joins the arguments. It compares each record'sNamecase-insensitively and itsSteamIdfor a match. The first argument may be a full SteamID (17 digits); otherwise the joined text is treated as a name. Entries are filtered, the lastcountare printed withSendReply, and the channel is included as text.Output format
Each printed line uses the index in the stored list and the pre-formatted
Timestring, which the hook fills withDateTime.UtcNow.ToString(config.TimestampFormat). Keep the format parseable, likeyyyy-MM-dd HH:mm:ss, so other tools can read the file. Messages are truncated atMax message lengthcharacters before storage so a single pasted wall of text cannot bloat the record.
Install it on your server
Save ChatLogger.cs
Keep the file name exactly as the class name. Oxide matches them.Upload it to the plugins folder
Oxide:oxide/plugins/. Carbon:carbon/plugins/. The server compiles and loads it within a few seconds, no restart needed.Edit the config if you want
On first load the default config is written tooxide/config/ChatLogger.json(Carbon:carbon/configs/ChatLogger.json). After editing, reload it:oxide.reload ChatLogger c.reload ChatLogger # CarbonGrant the permission
Give it to every player, or swapdefaultfor a group such asvip:oxide.grant group default chatlogger.use c.grant group default chatlogger.use # Carbon
Variations
Each one opens the generator with a ready prompt you can tweak.
Search across all players
Adds a keyword search instead of a player lookup.
Generate this versionFilter channels
Lets admins ignore team and card chat noise.
Generate this versionDiscord relay of logs
Pushes each logged line to a Discord webhook.
Generate this version
FAQ
Does the plugin block or modify chat?
No. OnPlayerChat returns null, so the message is delivered exactly as typed. The plugin only copies the text into the data file. Filtering or muting belongs in a separate plugin that returns a value or non-null result from the hook.
Where is the log stored and how big does it get?
In oxide/data/ChatLogger.json. Its size is bounded by Maximum stored messages in the config; the oldest lines are dropped as new ones arrive. A few thousand messages is a small file, but keep the cap modest on very busy servers because the whole list is rewritten on save.
Why do timestamps show UTC?
The record is written with DateTime.UtcNow, so the log is stable regardless of server time zone. Change Timestamp format in the config if you want a different layout, and convert to local time yourself when reading the file.
How do I install it on Oxide or Carbon?
Drop ChatLogger.cs into oxide/plugins, then run oxide.reload ChatLogger from the server console. Carbon loads the same file. Grant chatlogger.use to your admin group with oxide.grant group admin chatlogger.use.
Can I search by SteamID?
Yes. If the first argument is a 17-digit SteamID, /chatlog 7656119... matches records by ID. Otherwise the joined arguments are treated as a display name, matched case-insensitively.
Customize it for your server
Change the reward, add a cooldown, restrict it to VIPs. Start from this prompt and edit anything.
Edit the prompt on the next screen. 2 free plugins a month, no card.
More chat plugins
- Automatic announcementsAutoBroadcast sends a list of chat messages to every online player on a repeating timer, in order or random order, and fills {players} and {time}โฆOnServerInitialized
- Chat mentionsDetects @name tokens in chat and sends the mentioned online player a private notice plus a notification sound, with a per-sender cooldown.OnPlayerChatOnPlayerDisconnected
- Chat titles and colorsReplaces vanilla global chat with formatted lines: a group tag plus separate name and message colors picked by the player's highest-priority OxideโฆOnPlayerChat
Last updated Oct 26, 2026.
Customize chat logger
Customize