diff --git a/client/cl_main.lua b/client/cl_main.lua index 6951faa..8ac21bb 100644 --- a/client/cl_main.lua +++ b/client/cl_main.lua @@ -1,26 +1,66 @@ -local ClientConfig = require "config.client" -local _trace = Citizen.Trace +--! Import Configs and Modules +local ClientConfig = require "config.client" +local SharedConfig = require "config.shared" +local originalTrace = Citizen.Trace +local tablePack = table.pack +local tableUnpack = table.unpack +--! Set ERROR_EVENT as local constant +local ERROR_EVENT = SharedConfig.ERROR_EVENT -function error(...) - local resource = GetCurrentResourceName() - print(string.format("----- RESOURCE ERROR -----")) - print(...) - print(string.format("------ PLEASE REPORT THIS TO STAFF ------")) - print(string.format("------ IDEALLY WITH CLIPS & SCREENSHOTS OF WHAT YOU'RE DOING ------")) - TriggerServerEvent("Error:Server:Report", resource, ...) +local keywords = {} +for _, word in ipairs(ClientConfig.errorWords or {}) do + if type(word) == "string" then + keywords[#keywords + 1] = string.lower(word) + end +end + +local suppressTraceIntercept = false + +local function traceLine(message) + originalTrace(tostring(message)) +end + +local function shouldReportError(message) + local lowered = string.lower(message) + for i = 1, #keywords do + if lowered:find(keywords[i], 1, true) then + return true + end + end + return false +end + +local function reportClientError(args) + local resourceName = GetCurrentResourceName() + + suppressTraceIntercept = true + --? Trace/Print the error message to client F8 console. + traceLine("----- RESOURCE ERROR -----") + for i = 1, args.n do + traceLine(args[i]) + end + traceLine("------ PLEASE REPORT THIS TO STAFF ------") + traceLine("------ IDEALLY WITH CLIPS & SCREENSHOTS OF WHAT YOU'RE DOING ------") + suppressTraceIntercept = false + + TriggerServerEvent(ERROR_EVENT, resourceName, tableUnpack(args, 1, args.n)) end ---@diagnostic disable-next-line: duplicate-set-field function Citizen.Trace(...) - if type(...) == "string" then - local args = string.lower(...) - for _, word in ipairs(ClientConfig.errorWords) do - if string.find(args, word) then - error(...) - return - end - end + local args = tablePack(...) + + if suppressTraceIntercept then + originalTrace(tableUnpack(args, 1, args.n)) + return end - _trace(...) + + local message = args[1] + if type(message) == "string" and shouldReportError(message) then + reportClientError(args) + return + end + + originalTrace(tableUnpack(args, 1, args.n)) end \ No newline at end of file diff --git a/config/client.lua b/config/client.lua index c3d2f51..8c098b3 100644 --- a/config/client.lua +++ b/config/client.lua @@ -2,8 +2,8 @@ return { errorWords = { "failure", "error", - "not", "failed", + "not", "not safe", "invalid", "cannot", @@ -13,6 +13,23 @@ return { "attempt", "traceback", "stack", - "function" - } + "function", + --? Possible false positives below, use with caution + "nil", + "no such", + "bad argument", + "expected", + "script", + "exception", + "panic", + "crash", + "unhandled", + "timeout", + "denied", + "refused", + "missing", + "unknown", + "syntax", + "warning" + } } \ No newline at end of file diff --git a/config/server.lua b/config/server.lua index 86a6e10..4964c0d 100644 --- a/config/server.lua +++ b/config/server.lua @@ -1,3 +1,16 @@ return { - Webhook = 'https://discord.com/api/' + Logging = { + --! Discord is not a logging service, please switch to loki/grafana, datadog, fivemanage, etc for production. + backend = 'discord', -- options: 'discord', 'loki' + discord = { + webhook = 'https://discord.com/api/' + }, + --! Loki configuration uses ox_lib logger and it's configuration for endpoint/auth (https://coxdocs.dev/ox_lib/Modules/Logger/Server#grafana-loki) + loki = { + enabled = false, + labels = { + app = GetCurrentResourceName() + } + }, + } } \ No newline at end of file diff --git a/config/shared.lua b/config/shared.lua new file mode 100644 index 0000000..3dd35a8 --- /dev/null +++ b/config/shared.lua @@ -0,0 +1,3 @@ +return { + ERROR_EVENT = "Error:Server:Report", +} \ No newline at end of file diff --git a/server/sv_errorLog.lua b/server/sv_errorLog.lua index c7bb877..ed4eb5a 100644 --- a/server/sv_errorLog.lua +++ b/server/sv_errorLog.lua @@ -1,41 +1,91 @@ ----@diagnostic disable: param-type-mismatch -local ServerConfig = require "config.server" -local ServerFunctions = require "server.sv_functions" -local QBX = exports.qbx_core -local LOGGER = require '@qbx_core.modules.logger' +--! Import Configs and Modules +local ServerConfig = require "config.server" +local SharedConfig = require "config.shared" +local ServerFunctions = require "server.sv_functions" +local DiscordLogger = require "server.sv_logger" +local LokiLogger = require "server.sv_loki" -RegisterServerEvent("Error:Server:Report", function(resource, ...) +--! Set ERROR_EVENT as local constant +local ERROR_EVENT = SharedConfig.ERROR_EVENT + +--! Logging setup +local LoggingConfig = ServerConfig.Logging or {} + +local function selectLogger() + local backend = type(LoggingConfig.backend) == "string" and LoggingConfig.backend:lower() or "discord" + + if backend == "loki" then + local config = LoggingConfig.loki or {} + if config.enabled then + return function(entry) + LokiLogger.log(config, entry) + end + end + end + + local config = LoggingConfig.discord or {} + return function(entry) + DiscordLogger.log(config, entry) + end +end + +local ActiveLogger = selectLogger() + + + +RegisterNetEvent(ERROR_EVENT, function(resource, ...) local src = source - local errorMessage = ... - errorMessage = errorMessage:gsub("%^%d+", "") - - local userName = GetPlayerName(src) - local steamIdent = GetPlayerIdentifierByType(src, 'steam') - local steamID = (steamIdent and steamIdent:gsub('steam:',"")) or 'ERROR: STEAM-NOT-FOUND' - local discordIdent = GetPlayerIdentifierByType(src, 'discord') - local discordID = (discordIdent and discordIdent:gsub('discord:',"")) or 'ERROR: DISCORD-NOT-FOUND' - local citizenid = QBX:GetPlayer(src).PlayerData.citizenid + local args = table.pack(...) + local playerData = ServerFunctions.GetPlayerData(src) + local citizenId = playerData and playerData.citizenid or "UNKNOWN" local ped = GetPlayerPed(src) - local x, y, z = table.unpack(GetEntityCoords(ped)) - local heading = GetEntityHeading(ped) - LOGGER.log({ - source = citizenid, - webhook = ServerConfig.Webhook, - event = 'Error:Server:Report', - color = 'red', - message = string.format("**__Script Error In %s__**", resource) ..'\n'.. - '---------------------------------------\n'.. - '**__Triggered By:__** \n'.. - '**Username:** '.. userName ..'\n'.. - '**Steam Account:** '.. 'https://steamcommunity.com/profiles/'.. tonumber(steamID, 16) ..'\n'.. - '**Discord:** <@'.. discordID ..'> \n'.. - '**Source(ID):** '.. src ..'\n'.. - '**CitizenID:** '.. citizenid ..'\n'.. - '**Coords:** '.. ServerFunctions.FormatCoords(x,y,z,heading) ..'\n'.. - '**Status:** '.. ServerFunctions.DeadOrLastStand(src) ..'\n'.. - '**Identifiers:** '.. ServerFunctions.FetchIdentifiers(src) .. '\n'.. - '---------------------------------------\n'.. - errorMessage.. - '---------------------------------------\n' + + local x, y, z, heading = 0.0, 0.0, 0.0, 0.0 + if ped and ped ~= 0 then + x, y, z = table.unpack(GetEntityCoords(ped)) + heading = GetEntityHeading(ped) + end + + local errorMessage = ServerFunctions.SanitizeErrorArgs(args) + if errorMessage == "" then + errorMessage = "No error message supplied." + end + + local steamProfile = ServerFunctions.SteamProfile(GetPlayerIdentifierByType(src, 'steam')) + local discordMention = ServerFunctions.DiscordMention(GetPlayerIdentifierByType(src, 'discord')) + local status = ServerFunctions.DeadOrLastStand(src) + local identifiers = ServerFunctions.FetchIdentifiers(src) + local coords = ServerFunctions.FormatCoords(x, y, z, heading) + + local message = table.concat({ + string.format("**__Script Error In %s__**", resource or "unknown"), + "---------------------------------------", + "**__Triggered By:__**", + string.format("**Username:** %s", GetPlayerName(src) or "UNKNOWN"), + string.format("**Steam Account:** %s", steamProfile), + string.format("**Discord:** %s", discordMention), + string.format("**Source(ID):** %s", src), + string.format("**CitizenID:** %s", citizenId), + string.format("**Coords:** %s", coords), + string.format("**Status:** %s", status), + string.format("**Identifiers:** %s", identifiers), + "---------------------------------------", + errorMessage, + "---------------------------------------" + }, "\n") + + ActiveLogger({ + source = citizenId, + event = ERROR_EVENT, + color = 16711680, + message = message, + level = "error", + --? Grafana Tags + tags = { + resource = resource or "unknown", + citizenid = citizenId, + source_id = tostring(src), + username = GetPlayerName(src) or "UNKNOWN", + }, }) end) \ No newline at end of file diff --git a/server/sv_functions.lua b/server/sv_functions.lua index d0a7175..605fb9f 100644 --- a/server/sv_functions.lua +++ b/server/sv_functions.lua @@ -1,37 +1,96 @@ local QBX = exports.qbx_core -local function FormatCoords(x,y,z,heading) - local formattedX = math.floor(x * 100) / 100 - local formattedY = math.floor(y * 100) / 100 - local formattedZ = math.floor(z * 100) / 100 - local formattedHeading = math.floor(heading * 100) / 100 - local formattedString = formattedX .. ', '.. formattedY .. ', '.. formattedZ .. ', H: '..formattedHeading - return formattedString -end +local Functions = {} -local function FetchIdentifiers (source) - - local identifiersString = '' - for _, identifier in ipairs(GetPlayerIdentifiers(source)) do - identifiersString = identifiersString .. '\n'..identifier +function Functions.FormatCoords(x, y, z, heading) + if not x or not y or not z then + return "Coords unavailable" end - identifiersString = identifiersString .. '\n' - return identifiersString + return string.format("%.2f, %.2f, %.2f, H: %.2f", x, y, z, heading or 0.0) end -local function DeadOrLastStand (source) - local _source = source - local Player = QBX:GetPlayer(_source) - local dead = Player.PlayerData.metadata.isDead - local laststand = Player.PlayerData.metadata.inLaststand - if dead then return "Player Dead" end - if laststand then return "Player in Laststand" end +function Functions.FetchIdentifiers(source) + if type(source) ~= "number" then + return "\nIdentifiers unavailable" + end + + local identifiers = GetPlayerIdentifiers(source) + if type(identifiers) ~= "table" or #identifiers == 0 then + return "\nIdentifiers unavailable" + end + + return "\n" .. table.concat(identifiers, "\n") +end + +function Functions.DeadOrLastStand(source) + local player = QBX:GetPlayer(source) + if not player or not player.PlayerData then + return "Player state unavailable" + end + + local metadata = player.PlayerData.metadata or {} + if metadata.isDead then + return "Player Dead" + end + + if metadata.inLaststand then + return "Player In Laststand" + end + return "Player Alive" end -return { - FormatCoords = FormatCoords, - FetchIdentifiers = FetchIdentifiers, - DeadOrLastStand = DeadOrLastStand -} \ No newline at end of file +function Functions.SanitizeErrorArgs(args) + if type(args) ~= "table" then + return "" + end + + local count = args.n or #args + local messages = {} + + for index = 1, count do + local value = args[index] + if type(value) == "string" then + messages[#messages + 1] = value:gsub("%^%d+", "") + else + messages[#messages + 1] = tostring(value) + end + end + + return table.concat(messages, "\n") +end + +function Functions.SteamProfile(identifier) + if type(identifier) ~= "string" then + return "ERROR: STEAM-NOT-FOUND" + end + + local hexId = identifier:gsub("steam:", "") + local decimalId = tonumber(hexId, 16) + if not decimalId then + return "ERROR: STEAM-CONVERSION-FAILED" + end + + return string.format("https://steamcommunity.com/profiles/%d", decimalId) +end + +function Functions.DiscordMention(identifier) + if type(identifier) ~= "string" then + return "ERROR: DISCORD-NOT-FOUND" + end + + local discordId = identifier:gsub("discord:", "") + return string.format("<@%s>", discordId) +end + +function Functions.GetPlayerData(sourceId) + local player = QBX:GetPlayer(sourceId) + if not player or not player.PlayerData then + return nil + end + + return player.PlayerData +end + +return Functions \ No newline at end of file diff --git a/server/sv_logger.lua b/server/sv_logger.lua new file mode 100644 index 0000000..08b30c6 --- /dev/null +++ b/server/sv_logger.lua @@ -0,0 +1,105 @@ +---@diagnostic disable: undefined-global + +local Logger = {} + +local queue = {} +local processing = false +local rateLimitMs = 2000 + + +local function jsonEncode(payload) + local ok, encoded = pcall(json.encode, payload) + if ok and encoded then + return encoded + end + + return "{}" +end + +local function processQueue() + local entry = table.remove(queue, 1) + if not entry then + processing = false + return + end + + if type(entry.webhook) ~= "string" or entry.webhook == "" then + processQueue() + return + end + + processing = true + + local body = jsonEncode(entry.payload) + + PerformHttpRequest(entry.webhook, function() + if #queue == 0 then + processing = false + return + end + + SetTimeout(rateLimitMs, processQueue) + end, 'POST', body, { + ['Content-Type'] = 'application/json' + }) +end + +function Logger.log(config, entry) + if type(entry) ~= "table" then + return + end + + config = config or {} + + if type(config.rateLimitMs) == "number" and config.rateLimitMs > 0 then + rateLimitMs = math.floor(config.rateLimitMs) + end + + local webhook = entry.webhook or config.webhook + if type(webhook) ~= "string" or webhook == "" then + return + end + + local embed = entry.embed or { + title = entry.event or "Server Log", + description = entry.message or "", + color = entry.color, + timestamp = os.date('!%Y-%m-%dT%H:%M:%SZ') + } + + if entry.source then + embed.footer = embed.footer or { + text = string.format("Source: %s", entry.source) + } + end + + local payload = { + username = entry.username or config.username or "Logger", + avatar_url = entry.avatarUrl or config.avatarUrl, + content = entry.content, + embeds = entry.embeds or { embed } + } + + queue[#queue + 1] = { + webhook = webhook, + payload = payload + } + + if not processing then + processQueue() + end +end + +function Logger.setRateLimit(ms) + if type(ms) ~= "number" or ms <= 0 then + return + end + + rateLimitMs = math.floor(ms) +end + +function Logger.queueSize() + return #queue +end + +return Logger diff --git a/server/sv_loki.lua b/server/sv_loki.lua new file mode 100644 index 0000000..63c043a --- /dev/null +++ b/server/sv_loki.lua @@ -0,0 +1,80 @@ +---@diagnostic disable: undefined-global + +local LokiLogger = {} + + +--? Shotout ox_lib for the following code. + -- ? Function to format tags into a string suitable for Loki logging + -- ? Intakes a table of key-value pairs and returns a formatted loki label string i.e key:value,key2:value2 +local function formatTags(tagData) + if type(tagData) ~= "table" then + return "" + end + + local keys = {} + for key in pairs(tagData) do + keys[#keys + 1] = key + end + + table.sort(keys) + + local segments = {} + + for index = 1, #keys do + local key = keys[index] + local value = tagData[key] + if value ~= nil then + local specifier = type(value) == "number" and "%d" or "%s" + segments[#segments + 1] = string.format("%s:" .. specifier, key, value) + end + end + + table.sort(segments) + + return table.concat(segments, ",") +end + +local function mergeTags(base, extra) + local merged = {} + + if type(base) == "table" then + for key, value in pairs(base) do + merged[key] = value + end + end + + if type(extra) == "table" then + for key, value in pairs(extra) do + merged[key] = value + end + end + + return merged +end + +function LokiLogger.log(config, entry) + if type(config) ~= "table" or config.enabled == false then + return + end + + if type(entry) ~= "table" then + return + end + + local tags = mergeTags(config.labels, entry.tags) + tags.level = entry.level or tags.level or "error" + + local formattedTags = formatTags(tags) + + local source = entry.source or config.source or "hof-errors" + local event = entry.event or "ErrorEvent" + local message = entry.message or "" + + if formattedTags == "" then + formattedTags = "level:error" + end + + lib.logger(source, event, message, formattedTags) +end + +return LokiLogger