CityRP Documentation Repository

Console Logging Library

Last updated

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.logs for 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 "". Calling GetLogger(nil) or GetLogger("") 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 via MsgC; 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 (or INHERIT), 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 MsgC for 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)
  • 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.add for 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

  1. Use DEBUG sparingly in production: Prefer INFO or higher
  2. Use lazy message functions for expensive debug messages:
    logger:Debug(function() return expensive_trace() end)
    
  3. Bind common metadata with WithFields to avoid repeated allocations:
    local req_log = logger:WithFields({request_id = id})
    req_log:Info("Request started")
    
  4. 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 __tostring or 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

  1. Logs the line to cityrp.logging under admin.<event lowercased> at INFO.
  2. Sends it to every player passing IsTrialMod() via cityrp_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.logs remains the canonical system for persistent game-audit events (player actions, admin actions, transactions)
  • cityrp.logging is for developer diagnostics and runtime troubleshooting
  • Use cityrp.logs.add for events that must be queryable and persistent for moderation/audit
  • Use cityrp.logging for debug output, performance traces, and operational monitoring
  • Use cityrp.adminhub.AddLogLine for events staff should witness live; it persists nothing, so pair it with cityrp.logs.add when 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_values is truncated and rebuilt on every DBConnected, so retired items simply vanish from it).
  • Keep aggregation targets as scalars in their own column. Anything a consumer will COUNT/SUM/GROUP BY must not live inside a JSON blob — there is no index into text. JSON is for the long tail (display name, cost, flags, source entity).
  • Encode exactly once. util.TableToJSON on a table, never on a string that is already JSON. caw_metrics.metrics is 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