Console Logging Library
Last updated
May be stale
This page was last updated ago, on 4 October 2026. Check what it says against the code before relying on it.
The CityRP console logging library (cityrp.logging) provides a robust, namespaced, level-filterable logging facility for developer diagnostics and runtime troubleshooting.
Overview
Purpose: Provide structured logging with configurable levels for core systems and plugins.
Key Features:
- Hierarchical namespaced loggers with inheritance
- Configurable log levels (DEBUG, INFO, WARNING, ERROR, CRITICAL, FATAL)
- Special levels (ALWAYS, NEVER, INHERIT, DEFAULT)
- Colored console output via
MsgC - Automatic persistence to
cityrp.logsfor WARNING+ messages - Runtime configuration via console commands
- Safe handling of unvalidated input
- Performance-optimized with lazy evaluation and caching
- Extensible sink API for custom log destinations
Important: This library is for developer diagnostics only. For audit logging (player actions, admin actions, transactions), continue using cityrp.logs.add() directly. For events staff should see live in-game, see Admin Hub Live Logging — it is a separate, non-persistent channel.
Log Levels
Numeric Mapping
| Level | Numeric Value | Description |
|---|---|---|
INHERIT |
-1 | Recursively inherit from parent (default for non-root loggers) |
ALWAYS |
0 | Bypass logger-level filtering (sinks may still filter) |
DEBUG |
20 | Low-level diagnostic info |
INFO |
30 | Routine operational logs |
WARNING |
40 | Unexpected but non-breaking situations |
DEFAULT |
40 | Alias for WARNING (default for root logger) |
ERROR |
50 | Errors requiring investigation |
CRITICAL |
60 | Serious failures |
FATAL |
70 | Highest severity; immediate attention required |
NEVER |
100 | Suppress all output from this logger |
Level Constants
Access level constants via cityrp.logging.levels:
cityrp.logging.levels.INHERIT -- -1
cityrp.logging.levels.ALWAYS -- 0
cityrp.logging.levels.DEBUG -- 20
cityrp.logging.levels.INFO -- 30
cityrp.logging.levels.WARNING -- 40
cityrp.logging.levels.DEFAULT -- 40 (alias)
cityrp.logging.levels.ERROR -- 50
cityrp.logging.levels.CRITICAL -- 60
cityrp.logging.levels.FATAL -- 70
cityrp.logging.levels.NEVER -- 100
Level Semantics
- INHERIT (-1): Logger does not define its own level; resolve effective level by recursively checking parent. This is the implicit default for all non-root loggers until explicitly set.
- ALWAYS (0): Bypasses the logger's effective level check. Entry is always dispatched to sinks (subject to sink-level filtering). Useful for critical output that must not be suppressed.
- NEVER (100): Suppresses all log output from this logger. No sinks are called.
- DEFAULT (40): Alias for WARNING. Used as the default level for the root logger.
Logger API
Acquiring Loggers
cityrp.logging.GetLogger(namespace)
Returns a logger instance for the given dot-notated namespace string.
local log = cityrp.logging.GetLogger("banking")
local childLog = cityrp.logging.GetLogger("banking.sql")
local rootLog = cityrp.logging.GetLogger("") -- or GetLogger(nil)
- Namespaces are hierarchical:
"banking.sql.query"→ parent is"banking.sql"→ parent is"banking"→ parent is root - Root logger namespace is the empty string
"". CallingGetLogger(nil)orGetLogger("")returns the root logger - Loggers are singletons per namespace; repeated calls return the same instance
Logger Instance Methods
All logging methods are chainable (return the logger instance).
Core Logging Methods
logger:Debug(msg_parts..., meta?)
logger:Info(msg_parts..., meta?)
logger:Warning(msg_parts..., meta?)
logger:Error(msg_parts..., meta?)
logger:Critical(msg_parts..., meta?)
logger:Fatal(msg_parts..., meta?)
Accept varargs for message parts. Final table argument is treated as metadata.
Bypass Method
logger:Raw(level, msg_parts..., meta?)
Bypasses the logger's effective level check. Always dispatches to sinks (subject to sink filtering). level is a numeric level (or level constant).
Configuration Methods
logger:SetLevel(level) -- Set explicit level (chainable)
logger:GetLevel() -- Get explicit level (may be nil if inheriting)
logger:GetEffectiveLevel() -- Get resolved effective level (after inheritance)
Hierarchy Navigation
logger:GetName() -- Returns namespace string (e.g., "banking.sql"; root returns "")
logger:GetDisplayName() -- Returns display name ("root" for root logger)
logger:GetParent() -- Returns immediate parent logger instance
logger:GetChild(child_segment) -- Returns child logger for given segment
logger:GetChildren() -- Returns array of direct child logger instances
logger:IsRoot() -- Returns true if this is the root logger
Field Binding
logger:WithFields(fields_table)
Returns a wrapper logger that merges fields_table into metadata on every log call. Useful for binding common context (e.g., request ID, user ID).
Basic Usage
Simple Logging
local log = cityrp.logging.GetLogger("banking")
log:Info("Banking plugin initialized", {version = "1.2.0"})
log:Debug("Player balance lookup", {player = ply:SteamID64(), balance = 5000})
Colored Output
local log = cityrp.logging.GetLogger("auth")
log:Info(Color(0, 255, 0), "Login success: ", Color(255, 255, 255), ply:Name())
Using Sanitization Helpers
local log = cityrp.logging.GetLogger("chat")
log:Info(cityrp.logging.sanitizePlayer(ply), " said: ", msg)
-- Output: [2025-12-14 13:22:10][chat][INFO] PlayerName (76561198000012345) said: hello
Lazy Messages
Defer expensive computation until the log level check passes:
local log = cityrp.logging.GetLogger("expensive")
log:Debug(function()
return "Heavy computation result: " .. compute_expensive()
end)
-- compute_expensive() is only called if DEBUG level is enabled
WithFields (Bound Context)
Bind common metadata to all log entries:
local log = cityrp.logging.GetLogger("http")
local req_log = log:WithFields({request_id = "abc123", endpoint = "/api/balance"})
req_log:Info("Request started")
req_log:Info("Request completed", {status = 200})
-- Both entries include request_id and endpoint in metadata
Chainable Calls
cityrp.logging.GetLogger("startup")
:Info("Initializing subsystems")
:Debug("Loading config")
:Info("Startup complete")
Bypass Level Filtering (Raw)
local log = cityrp.logging.GetLogger("console")
log:Raw(cityrp.logging.levels.INFO, "Command executed: ", cmd)
-- Always logs regardless of logger's effective level
Message Parts (varargs API)
Message parts may include:
- Strings: literal text
- Color objects:
Color(r, g, b, a)— used by console sink for colored output viaMsgC; stripped by non-color sinks - Entities/Players: game objects; sinks are responsible for serialization (e.g., console shows name+steamid; DB stores steamid64)
- Functions: lazy evaluation — function returning string to defer expensive computation
Metadata table (optional final argument):
- Plain Lua table containing structured key-value pairs for additional context
- Metadata is available to sinks for filtering, formatting, and persistence
- Any trailing table argument without metatable is treated as metadata
- Nested plain tables are expanded, so sinks receive real structure rather than a
table: 0x...address. Players and entities are reduced to a name/class string at any depth, colours are kept as colours, and anything carrying a metatable is rendered via its own__tostring
Example:
logger:Info("Player ", ply, " connected from ", ip, {country = "US", new_player = true})
Nested example:
logger:Info("Loadout applied", {job = "medic", loadout = {primary = "ar2", ammo = 90}})
[2025-12-14 13:22:10][jobs][INFO] Loadout applied
{
job: medic
loadout: {
primary: ar2
ammo: 90
}
}
Logger Hierarchy and Inheritance
Dot-Notated Namespaces
Namespaces are dot-separated: "banking", "banking.sql", "banking.sql.query".
Parent-child relationships are implicit from the namespace structure.
Root logger namespace is "" (empty string); displayed as "root" in output.
Inheritance Rules
- Level inheritance: If a logger's explicit level is
nil(orINHERIT), the effective level is resolved by recursively checking the nearest ancestor with an explicit level, or the global default - Explicit overrides: Calling
SetLevel(level)on a logger sets an explicit level that overrides inheritance - Caching and invalidation: Effective levels are cached. When a logger's level changes, it synchronously notifies its children to invalidate their caches
Example
local parent = cityrp.logging.GetLogger("banking")
parent:SetLevel(cityrp.logging.levels.DEBUG)
local child = cityrp.logging.GetLogger("banking.sql")
-- child inherits DEBUG from parent
child:Info("Query executed") -- dispatched because INFO >= DEBUG
child:SetLevel(cityrp.logging.levels.ERROR)
child:Info("Query executed") -- suppressed because INFO < ERROR
Runtime Configuration
Console Commands (Server-only, RCON/Console)
cityrp_log_set <namespace> <level>
Set the log level for a namespace.
cityrp_log_set banking DEBUG
cityrp_log_set banking.sql 20
cityrp_log_set root INFO
<namespace>: dot-notated string (e.g.,"banking.sql") or"root"for root logger<level>: numeric value or string token (e.g.,"DEBUG",20,"INHERIT")- Immediately updates the logger and invalidates child caches
- Writes updated configuration to
data/cityrp/log_levels.json
cityrp_log_get <namespace>
Get the explicit and effective log level for a namespace.
cityrp_log_get banking
cityrp_log_list
List all configured namespaces and their explicit levels.
cityrp_log_list
Persistence
- All namespace → level mappings are stored in
data/cityrp/log_levels.json(single JSON file) - File is loaded at gamemode initialization
- File is written immediately (atomic write) when a level is changed via concommand
Format:
{
"": 40,
"banking": 20,
"banking.sql": 10
}
Client vs Server State
- Client and server logging state are independent
- Configuration (concommands, persistence) is server-only
- Client-side loggers respect client-side defaults but do not write to DB or forward to server
Sinks
Default Sinks
Console sink ("console"):
- Registered by default
- Uses
MsgCfor colored output on both server and client - Colorizes output by level:
- DEBUG: grey
Color(128, 128, 128) - INFO: cyan
Color(0, 255, 255) - WARNING: yellow
Color(255, 215, 0) - ERROR: dark red
Color(128, 0, 0) - CRITICAL: red
Color(192, 0, 0) - FATAL: bright red
Color(255, 0, 0)
- DEBUG: grey
- Format example:
[2025-12-14 13:22:10][banking.sql][INFO] Message text - Metadata, if present, follows on its own lines via
dumper.inspect, with nested tables indented - When running as a multiplayer listen server, adds a CLIENT/SERVER realm indicator
cityrp.logs sink ("cityrp_logs"):
- Registered by default (server-only)
- Forwards entries at or above WARNING to
cityrp.logs.addfor database persistence - Stores:
timestamp(UNIX int)namespace(string)level(numeric)message(resolved final string)meta(JSON-encoded table)
- Client-side logger state never writes to DB
Extensible Sink API
Register custom sinks via:
cityrp.logging.RegisterSink(name, sink_table)
Sink table structure:
{
Write = function(entry)
-- entry.timestamp (os.time())
-- entry.namespace (string)
-- entry.level (numeric)
-- entry.message_parts (array of strings, Color objects, entities, players)
-- entry.meta (table or nil)
end
}
Sinks are responsible for:
- Formatting: Convert message_parts to final output (handle Color objects, serialize entities/players)
- Sanitization and escaping: Loggers may contain unvalidated user input; sinks must protect against injection/exploits
- Filtering: Sinks may filter entries by level, namespace, or any other criteria independent of logger-level filtering
Plugin Integration
Automatic Logger Creation
When a plugin is registered, the core plugin system automatically creates a logger for the plugin:
plugin.logger = cityrp.logging.GetLogger("plugin.<plugin_id>")
<plugin_id> is the plugin key — the folder name, or a file plugin's name without its scope
prefix — not PLUGIN.name. That matters here because cityrp_log_set splits its arguments on
whitespace, so a namespace derived from a display name like "Area Manager" could never be passed
to it. See plugins.md.
Plugin objects expose:
plugin.logger— the plugin's logger instance
Plugins may call plugin.logger:Info(...), plugin.logger:Debug(...), etc.
Lifecycle Event Logging
Core plugin system logs lifecycle events at DEBUG level using the plugin's logger:
- Plugin loaded:
plugin.logger:Debug("Plugin loaded")
Plugin Log Configuration
Per-plugin log level configuration is done via the same namespace-based concommand system:
cityrp_log_set plugin.my_plugin DEBUG
No separate per-plugin convars.
Sanitization Helpers
cityrp.logging.sanitizePlayer(ply)
Returns an array for use in message parts:
{Color(r, g, b), "PlayerName (76561198000012345)", Color(255, 255, 255)}
- Player color priority: rank color if available, else team color, else white
- White reset color at end
cityrp.logging.sanitizeEntity(ent)
Returns an array:
{Color(0, 255, 255), "EntityClass [123]", Color(255, 255, 255)}
- Entity color: cyan/green
- Format: class and entindex
Performance and Optimization
Best Practices
- Use DEBUG sparingly in production: Prefer INFO or higher
- Use lazy message functions for expensive debug messages:
logger:Debug(function() return expensive_trace() end) - Bind common metadata with
WithFieldsto avoid repeated allocations:local req_log = logger:WithFields({request_id = id}) req_log:Info("Request started") - Limit metadata size: Library enforces safe limits — max 50 keys per table, max 4 levels of nesting, max 250 key/value pairs across the whole tree, strings truncated at 1024 chars. A table cut short by any cap gains a
... = <truncated>key so the reader can tell; one below the depth cap becomes<table: max depth>
Optimization Strategies
- Fast level checks: Early-return before formatting or metadata serialization if level filtered out
- Lazy message formatting: Support message parts as functions to defer expensive string construction
- Effective level caching: Cache resolved effective levels; invalidate synchronously when parent/ancestor levels change
- Minimal allocations: Reuse buffers where safe; avoid churn in hot paths
Security and Safety
Safety Guarantees
- Never errors: Logging calls never throw errors, even with nil or malformed input
- Safe serialization: Metadata serializer recurses into nested tables but is bounded on depth, per-table keys and total entries; a cycle is reported as
<cyclic reference>rather than followed, and a value whose__tostringor iteration errors degrades the whole table to<error>instead of propagating - Sanitization: Sinks are responsible for sanitizing and escaping metadata and message content for their output target (console, DB, network)
Security Notes
- Unvalidated user input: Loggers may receive unvalidated player-supplied input (e.g., chat messages, entity names). Sinks MUST sanitize and escape data to prevent injection attacks or exploits
- Client logs: Client-side logs remain on the client. No client-to-server forwarding in any circumstance (no net messages, no admin-request mechanism)
- DB sink: Server-only. Clients cannot write to DB directly or indirectly
- Concommand permissions: Concommands are server console or RCON only; no player-executable commands
Admin Hub Live Logging
cityrp.adminhub.AddLogLine is a third channel, distinct from both of the above. It is the live
staff feed: a coloured one-line event pushed to every online staff member's screen and console the
moment it happens. It persists nothing and cannot be queried afterwards.
cityrp.adminhub.AddLogLine(event, text, colour)
| Parameter | Type | Notes |
|---|---|---|
event |
string |
Short category shown in brackets ("KILL", "Lockpick", "CMD"). |
text |
string |
The already-formatted line. Include names and SteamIDs yourself. |
colour |
Color? |
Defaults to white. Use a COL_* constant from sh_enumerations.lua. |
Server-only (core/plugins/adminhub/sv_init.lua). Call sites in shared SWEP/entity files must guard
with if SERVER then.
What it does with the call
- Logs the line to
cityrp.loggingunderadmin.<event lowercased>at INFO. - Sends it to every player passing
IsTrialMod()viacityrp_adminhub_newlog.
Recipients see it twice: MsgC'd into their game console with a timestamp, and — while the ADMIN LOG
panel is open (!adminhub) — drawn in the top-right HUD panel. The client re-checks IsTrialMod()
on receive, keeps the last 100 entries, and shows as many as cl_adminhub_lines allows.
It is not an audit record
Nothing written through AddLogLine reaches the database. The console copy goes through the admin
logger at INFO, which is below the cityrp_logs sink's WARNING threshold, and the net message is
display-only — an event logged this way is gone as soon as it scrolls off. If the event needs to be
queryable later, call cityrp.logs.add as well. Most existing call sites do exactly that, as two
separate calls:
player:KnockOut(true, player._KnockOutTime)
cityrp.adminhub.AddLogLine("Baton", ply:Nick() .. " knocked out " .. player:Nick())
cityrp.logs.add(ply:Name(), ply:SteamID(), player:Name(), player:SteamID(), "knockout", "ASP Baton")
Choosing a channel
| Need | Use |
|---|---|
| Persistent, queryable record for moderation | cityrp.logs.add |
| Staff should see it happen, live, in-game | cityrp.adminhub.AddLogLine |
| Developer diagnostics, level-filterable | cityrp.logging.GetLogger(ns) |
These are not alternatives to each other — an event that staff should both witness and be able to look up later belongs on the first two.
Silencing and namespaces
Because every line goes through admin.<event>, the server-console copy of the whole feed can be
silenced with the normal level commands:
cityrp_log_set admin NEVER
cityrp_log_set admin.spawn NEVER
This only affects the server console. Staff clients still receive the net message and still see the line in their own console and panel.
The namespace is event lowercased with spaces replaced by underscores ("Riot Shield" →
admin.riot_shield), so every event stays addressable from console — cityrp_log_set splits its
arguments on whitespace and could not otherwise reach a multi-word event. The event string sent to
clients and shown in the panel is untouched.
Cost
AddLogLine iterates every player and sends a net message on each call, with no rate limiting or
coalescing. Keep it to discrete events; never call it from a think/tick path.
Migration from Legacy Logging
Do NOT Replace cityrp.logs for Audit Events
cityrp.logsremains the canonical system for persistent game-audit events (player actions, admin actions, transactions)cityrp.loggingis for developer diagnostics and runtime troubleshooting- Use
cityrp.logs.addfor events that must be queryable and persistent for moderation/audit - Use
cityrp.loggingfor debug output, performance traces, and operational monitoring - Use
cityrp.adminhub.AddLogLinefor events staff should witness live; it persists nothing, so pair it withcityrp.logs.addwhen the event also needs to be auditable
Audit log value conventions
cityrp.logs.add's four value columns are generic text — the meaning of each one is a
convention per log type, not a column contract (say logs put a whole chat line in value1).
Consumers outside the gamemode query these columns directly, so a convention that isn't written
down here evaporates the next time a call site is edited.
Three rules for any log type whose values get queried:
- Identify by uniqueID, not display name. Display names are renamed freely and are not stable
identifiers, and there is no historical name→ID mapping to recover them from (
item_valuesis truncated and rebuilt on everyDBConnected, so retired items simply vanish from it). - Keep aggregation targets as scalars in their own column. Anything a consumer will
COUNT/SUM/GROUP BYmust not live inside a JSON blob — there is no index intotext. JSON is for the long tail (display name, cost, flags, source entity). - Encode exactly once.
util.TableToJSONon a table, never on a string that is already JSON.caw_metrics.metricsis double-encoded and consumers have to compare against the literal'"{\"cause\":\"prop\"}"'to work around it; don't reproduce that.
Replacing Legacy Prints
Replace developer-facing print(), ServerLog(), MsgN() calls with cityrp.logging:
Old:
print("Banking: Player " .. ply:Name() .. " balance: " .. bal)
New:
local log = cityrp.logging.GetLogger("banking")
log:Debug("Player balance: ", bal, {player = ply, balance = bal})
Examples
Basic Logging
local log = cityrp.logging.GetLogger("banking")
log:Info("Banking plugin initialized", {version = "1.2.0"})
log:Debug("Player balance lookup", {player = ply:SteamID64(), balance = 5000})
Using Player/Entity Sanitization
local log = cityrp.logging.GetLogger("chat")
log:Info(cityrp.logging.sanitizePlayer(ply), " said: ", msg)
Lazy Evaluation
local log = cityrp.logging.GetLogger("expensive")
log:Debug(function()
return "Heavy computation result: " .. compute_expensive()
end)
Bound Context with WithFields
local log = cityrp.logging.GetLogger("http")
local req_log = log:WithFields({request_id = "abc123", endpoint = "/api/balance"})
req_log:Info("Request started")
req_log:Info("Request completed", {status = 200})
Chainable Logging
cityrp.logging.GetLogger("startup")
:Info("Initializing subsystems")
:Debug("Loading config")
:Info("Startup complete")
Hierarchical Configuration
-- Set parent to DEBUG
cityrp.logging.GetLogger("banking"):SetLevel(cityrp.logging.levels.DEBUG)
-- Child inherits DEBUG
local child = cityrp.logging.GetLogger("banking.sql")
child:Info("Query executed") -- dispatched
-- Override child to ERROR
child:SetLevel(cityrp.logging.levels.ERROR)
child:Info("Query executed") -- suppressed
API Reference
Global API
-- Level management
cityrp.logging.SetDefaultLevel(level)
cityrp.logging.GetDefaultLevel()
-- Logger factory
cityrp.logging.GetLogger(namespace)
-- Sink management
cityrp.logging.RegisterSink(name, sink_table)
cityrp.logging.UnregisterSink(name)
-- Sanitization helpers
cityrp.logging.sanitizePlayer(ply)
cityrp.logging.sanitizeEntity(ent)
-- Level constants
cityrp.logging.levels.INHERIT
cityrp.logging.levels.ALWAYS
cityrp.logging.levels.DEBUG
cityrp.logging.levels.INFO
cityrp.logging.levels.WARNING
cityrp.logging.levels.DEFAULT
cityrp.logging.levels.ERROR
cityrp.logging.levels.CRITICAL
cityrp.logging.levels.FATAL
cityrp.logging.levels.NEVER
Logger Instance Methods
-- Logging methods
logger:Debug(...)
logger:Info(...)
logger:Warning(...)
logger:Error(...)
logger:Critical(...)
logger:Fatal(...)
logger:Raw(level, ...)
-- Configuration
logger:SetLevel(level)
logger:GetLevel()
logger:GetEffectiveLevel()
-- Hierarchy
logger:GetName()
logger:GetDisplayName()
logger:GetParent()
logger:GetChild(segment)
logger:GetChildren()
logger:IsRoot()
-- Context binding
logger:WithFields(fields_table)
Console Commands (Server-only)
cityrp_log_set <namespace> <level>
cityrp_log_get <namespace>
cityrp_log_list