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"
local _trace = Citizen.Trace
--! Import Configs and Modules
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 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(...)
local args = tablePack(...)
if suppressTraceIntercept then
originalTrace(tableUnpack(args, 1, args.n))
return
end
local message = args[1]
if type(message) == "string" and shouldReportError(message) then
reportClientError(args)
return
end
end
_trace(...)
originalTrace(tableUnpack(args, 1, args.n))
end

View File

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

View File

@ -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()
}
},
}
}

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
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 <const> = require "config.server"
local SharedConfig <const> = require "config.shared"
local ServerFunctions <const> = require "server.sv_functions"
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 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)

View File

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

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