Complete refactor, includes loki logging

This commit is contained in:
GetParanoid 2025-11-26 11:27:38 -06:00
parent 7e81d1f0ea
commit c404d28fb1
8 changed files with 452 additions and 85 deletions

View File

@ -1,26 +1,66 @@
local ClientConfig = require "config.client" --! Import Configs and Modules
local _trace = Citizen.Trace local ClientConfig <const> = require "config.client"
local SharedConfig <const> = require "config.shared"
local originalTrace <const> = Citizen.Trace
local tablePack <const> = table.pack
local tableUnpack <const> = table.unpack
--! Set ERROR_EVENT as local constant
local ERROR_EVENT <const> = SharedConfig.ERROR_EVENT
function error(...) local keywords = {}
local resource = GetCurrentResourceName() for _, word in ipairs(ClientConfig.errorWords or {}) do
print(string.format("----- RESOURCE ERROR -----")) if type(word) == "string" then
print(...) keywords[#keywords + 1] = string.lower(word)
print(string.format("------ PLEASE REPORT THIS TO STAFF ------")) end
print(string.format("------ IDEALLY WITH CLIPS & SCREENSHOTS OF WHAT YOU'RE DOING ------")) end
TriggerServerEvent("Error:Server:Report", resource, ...)
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 end
---@diagnostic disable-next-line: duplicate-set-field ---@diagnostic disable-next-line: duplicate-set-field
function Citizen.Trace(...) function Citizen.Trace(...)
if type(...) == "string" then local args = tablePack(...)
local args = string.lower(...)
for _, word in ipairs(ClientConfig.errorWords) do if suppressTraceIntercept then
if string.find(args, word) then originalTrace(tableUnpack(args, 1, args.n))
error(...)
return return
end end
local message = args[1]
if type(message) == "string" and shouldReportError(message) then
reportClientError(args)
return
end end
end
_trace(...) originalTrace(tableUnpack(args, 1, args.n))
end end

View File

@ -2,8 +2,8 @@ return {
errorWords = { errorWords = {
"failure", "failure",
"error", "error",
"not",
"failed", "failed",
"not",
"not safe", "not safe",
"invalid", "invalid",
"cannot", "cannot",
@ -13,6 +13,23 @@ return {
"attempt", "attempt",
"traceback", "traceback",
"stack", "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"
} }
} }

View File

@ -1,3 +1,16 @@
return { 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()
}
},
}
} }

3
config/shared.lua Normal file
View File

@ -0,0 +1,3 @@
return {
ERROR_EVENT = "Error:Server:Report",
}

View File

@ -1,41 +1,91 @@
---@diagnostic disable: param-type-mismatch --! Import Configs and Modules
local ServerConfig = require "config.server" local ServerConfig <const> = require "config.server"
local ServerFunctions = require "server.sv_functions" local SharedConfig <const> = require "config.shared"
local QBX = exports.qbx_core local ServerFunctions <const> = require "server.sv_functions"
local LOGGER = require '@qbx_core.modules.logger' local DiscordLogger <const> = require "server.sv_logger"
local LokiLogger <const> = require "server.sv_loki"
RegisterServerEvent("Error:Server:Report", function(resource, ...) --! Set ERROR_EVENT as local constant
local ERROR_EVENT <const> = SharedConfig.ERROR_EVENT
--! Logging setup
local LoggingConfig <const> = 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 <const> = selectLogger()
RegisterNetEvent(ERROR_EVENT, function(resource, ...)
local src = source local src = source
local errorMessage = ... local args = table.pack(...)
errorMessage = errorMessage:gsub("%^%d+", "") local playerData = ServerFunctions.GetPlayerData(src)
local citizenId = playerData and playerData.citizenid or "UNKNOWN"
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 ped = GetPlayerPed(src) local ped = GetPlayerPed(src)
local x, y, z = table.unpack(GetEntityCoords(ped))
local heading = GetEntityHeading(ped) local x, y, z, heading = 0.0, 0.0, 0.0, 0.0
LOGGER.log({ if ped and ped ~= 0 then
source = citizenid, x, y, z = table.unpack(GetEntityCoords(ped))
webhook = ServerConfig.Webhook, heading = GetEntityHeading(ped)
event = 'Error:Server:Report', end
color = 'red',
message = string.format("**__Script Error In %s__**", resource) ..'\n'.. local errorMessage = ServerFunctions.SanitizeErrorArgs(args)
'---------------------------------------\n'.. if errorMessage == "" then
'**__Triggered By:__** \n'.. errorMessage = "No error message supplied."
'**Username:** '.. userName ..'\n'.. end
'**Steam Account:** '.. 'https://steamcommunity.com/profiles/'.. tonumber(steamID, 16) ..'\n'..
'**Discord:** <@'.. discordID ..'> \n'.. local steamProfile = ServerFunctions.SteamProfile(GetPlayerIdentifierByType(src, 'steam'))
'**Source(ID):** '.. src ..'\n'.. local discordMention = ServerFunctions.DiscordMention(GetPlayerIdentifierByType(src, 'discord'))
'**CitizenID:** '.. citizenid ..'\n'.. local status = ServerFunctions.DeadOrLastStand(src)
'**Coords:** '.. ServerFunctions.FormatCoords(x,y,z,heading) ..'\n'.. local identifiers = ServerFunctions.FetchIdentifiers(src)
'**Status:** '.. ServerFunctions.DeadOrLastStand(src) ..'\n'.. local coords = ServerFunctions.FormatCoords(x, y, z, heading)
'**Identifiers:** '.. ServerFunctions.FetchIdentifiers(src) .. '\n'..
'---------------------------------------\n'.. local message = table.concat({
errorMessage.. string.format("**__Script Error In %s__**", resource or "unknown"),
'---------------------------------------\n' "---------------------------------------",
"**__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) end)

View File

@ -1,37 +1,96 @@
local QBX = exports.qbx_core local QBX = exports.qbx_core
local function FormatCoords(x,y,z,heading) local Functions = {}
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 function FetchIdentifiers (source) function Functions.FormatCoords(x, y, z, heading)
if not x or not y or not z then
local identifiersString = '' return "Coords unavailable"
for _, identifier in ipairs(GetPlayerIdentifiers(source)) do
identifiersString = identifiersString .. '\n'..identifier
end end
identifiersString = identifiersString .. '\n'
return identifiersString return string.format("%.2f, %.2f, %.2f, H: %.2f", x, y, z, heading or 0.0)
end end
local function DeadOrLastStand (source) function Functions.FetchIdentifiers(source)
local _source = source if type(source) ~= "number" then
local Player = QBX:GetPlayer(_source) return "\nIdentifiers unavailable"
local dead = Player.PlayerData.metadata.isDead end
local laststand = Player.PlayerData.metadata.inLaststand
if dead then return "Player Dead" end local identifiers = GetPlayerIdentifiers(source)
if laststand then return "Player in Laststand" end 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" return "Player Alive"
end end
return { function Functions.SanitizeErrorArgs(args)
FormatCoords = FormatCoords, if type(args) ~= "table" then
FetchIdentifiers = FetchIdentifiers, return ""
DeadOrLastStand = DeadOrLastStand 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

105
server/sv_logger.lua Normal file
View File

@ -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

80
server/sv_loki.lua Normal file
View File

@ -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