Complete refactor, includes loki logging
This commit is contained in:
parent
7e81d1f0ea
commit
c404d28fb1
@ -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
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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
3
config/shared.lua
Normal file
@ -0,0 +1,3 @@
|
||||
return {
|
||||
ERROR_EVENT = "Error:Server:Report",
|
||||
}
|
||||
@ -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)
|
||||
@ -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
105
server/sv_logger.lua
Normal 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
80
server/sv_loki.lua
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user