add gs
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"language": {
|
||||
"mode": "nonstrict"
|
||||
},
|
||||
"lint": {
|
||||
"LocalUnused": "fatal",
|
||||
"ImportUnused": "fatal",
|
||||
"ImplicitReturn": "fatal",
|
||||
"DeprecatedGlobal": "fatal"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
--[[
|
||||
This module creates a crash object that can be sent to Backtrace.
|
||||
For information about what are the acceptable fields, see document:
|
||||
https://api.backtrace.io/#tag/submit-crash
|
||||
]]
|
||||
|
||||
local CorePackages = game:GetService("CorePackages")
|
||||
local HttpService = game:GetService("HttpService")
|
||||
|
||||
local Cryo = require(CorePackages.Packages.Cryo)
|
||||
local t = require(CorePackages.Packages.t)
|
||||
|
||||
local ProcessErrorStack = require(script.Parent.ProcessErrorStack)
|
||||
|
||||
local DEFAULT_THREAD_NAME = "default"
|
||||
|
||||
local IBacktraceStack = t.strictInterface({
|
||||
guessed_frame = t.optional(t.boolean),
|
||||
funcName = t.optional(t.string),
|
||||
address = t.optional(t.string),
|
||||
line = t.optional(t.string),
|
||||
column = t.optional(t.string),
|
||||
sourceCode = t.optional(t.string),
|
||||
library = t.optional(t.string),
|
||||
debug_identifier = t.optional(t.string),
|
||||
faulted = t.optional(t.boolean),
|
||||
registers = t.optional(t.map(t.string, t.some(t.string, t.number))),
|
||||
})
|
||||
|
||||
local IBacktraceThread = t.strictInterface({
|
||||
name = t.optional(t.string),
|
||||
fault = t.optional(t.boolean),
|
||||
stack = t.optional(t.array(IBacktraceStack)),
|
||||
})
|
||||
|
||||
local IArch = t.strictInterface({
|
||||
name = t.string,
|
||||
registers = t.map(t.string, t.string),
|
||||
})
|
||||
|
||||
local ISourceCode = t.strictInterface({
|
||||
text = t.optional(t.string),
|
||||
startLine = t.optional(t.number),
|
||||
startColumn = t.optional(t.number),
|
||||
startPos = t.optional(t.number),
|
||||
path = t.optional(t.string),
|
||||
tabWidth = t.optional(t.number),
|
||||
})
|
||||
|
||||
local IPerm = t.strictInterface({
|
||||
read = t.boolean,
|
||||
write = t.boolean,
|
||||
exec = t.boolean,
|
||||
})
|
||||
|
||||
local IMemory = t.strictInterface({
|
||||
start = t.string,
|
||||
size = t.optional(t.number),
|
||||
data = t.optional(t.string),
|
||||
perms = t.optional(IPerm),
|
||||
})
|
||||
|
||||
local IModule = t.strictInterface({
|
||||
start = t.string,
|
||||
size = t.number,
|
||||
code_file = t.optional(t.string),
|
||||
version = t.optional(t.string),
|
||||
debug_file = t.optional(t.string),
|
||||
debug_identifier = t.optional(t.string),
|
||||
debug_file_exists = t.optional(t.boolean),
|
||||
})
|
||||
|
||||
local IAttributes = t.optional(t.map(t.string, t.some(t.string, t.number, t.boolean)))
|
||||
|
||||
local IAnnotation = function(annotation)
|
||||
local function checkTypeRecursive(value)
|
||||
if type(value) == "table" then
|
||||
for key, subValue in pairs(value) do
|
||||
local valid, error = checkTypeRecursive(subValue)
|
||||
if not valid then
|
||||
return false, string.format("error when checking key: %s - %s", key, error)
|
||||
end
|
||||
end
|
||||
return true
|
||||
else
|
||||
local type = t.some(t.string, t.number, t.boolean)
|
||||
return type(value)
|
||||
end
|
||||
end
|
||||
|
||||
return checkTypeRecursive(annotation)
|
||||
end
|
||||
|
||||
local IAnnotations = t.optional(t.map(t.string, IAnnotation))
|
||||
|
||||
local IBacktraceReport = t.intersection(
|
||||
t.strictInterface({
|
||||
-- Must haves
|
||||
uuid = t.string,
|
||||
timestamp = t.number,
|
||||
lang = t.string,
|
||||
langVersion = t.string,
|
||||
agent = t.string,
|
||||
agentVersion = t.string,
|
||||
threads = t.map(t.string, IBacktraceThread),
|
||||
mainThread = t.string,
|
||||
|
||||
-- Optionals
|
||||
attributes = IAttributes,
|
||||
annotations = IAnnotations,
|
||||
symbolication = t.optional(t.literal("minidump")),
|
||||
entryThread = t.optional(t.string),
|
||||
arch = t.optional(IArch),
|
||||
fingerprint = t.optional(t.string),
|
||||
classifiers = t.optional(t.array(t.string)),
|
||||
sourceCode = t.optional(t.map(t.string, ISourceCode)),
|
||||
memory = t.optional(t.array(IMemory)),
|
||||
modules = t.optional(t.array(IModule)),
|
||||
}),
|
||||
function(report)
|
||||
local hasRegisters = false
|
||||
|
||||
local threads = report.threads
|
||||
for _, thread in pairs(threads) do
|
||||
local stacks = thread.stack
|
||||
if stacks ~= nil then
|
||||
for _, stack in ipairs(stacks) do
|
||||
if stack.registers ~= nil then
|
||||
hasRegisters = true
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
if hasRegisters then
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
if hasRegisters and report.arch == nil then
|
||||
return false, "arch must exist if you want to have registers in the stack"
|
||||
else
|
||||
return true
|
||||
end
|
||||
end
|
||||
)
|
||||
|
||||
local BacktraceReport = {
|
||||
IAttributes = IAttributes,
|
||||
IAnnotations = IAnnotations,
|
||||
}
|
||||
BacktraceReport.__index = BacktraceReport
|
||||
|
||||
function BacktraceReport:validate()
|
||||
return IBacktraceReport(self)
|
||||
end
|
||||
|
||||
-- Return a basic report that has all the required fields
|
||||
function BacktraceReport.new()
|
||||
local self = {
|
||||
uuid = HttpService:GenerateGUID(false):lower(),
|
||||
timestamp = os.time(),
|
||||
lang = "lua",
|
||||
langVersion = "Roblox",
|
||||
agent = "backtrace-Lua",
|
||||
agentVersion = "0.1.0",
|
||||
threads = {},
|
||||
mainThread = DEFAULT_THREAD_NAME,
|
||||
}
|
||||
|
||||
setmetatable(self, BacktraceReport)
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
function BacktraceReport:addAttributes(newAttributes)
|
||||
if type(newAttributes) ~= "table" then
|
||||
warn("Cannot add attributes of type: ", type(newAttributes))
|
||||
return
|
||||
end
|
||||
|
||||
local attributes = self.attributes or {}
|
||||
|
||||
attributes = Cryo.Dictionary.join(attributes, newAttributes)
|
||||
|
||||
self.attributes = attributes
|
||||
end
|
||||
|
||||
function BacktraceReport:addAnnotations(newAnnotations)
|
||||
if type(newAnnotations) ~= "table" then
|
||||
warn("Cannot add annotations of type: ", type(newAnnotations))
|
||||
return
|
||||
end
|
||||
|
||||
local annotations = self.annotations or {}
|
||||
|
||||
annotations = Cryo.Dictionary.join(annotations, newAnnotations)
|
||||
|
||||
self.annotations = annotations
|
||||
end
|
||||
|
||||
function BacktraceReport:addStackToThread(stack, threadName)
|
||||
local threads = self.threads
|
||||
|
||||
threads = Cryo.Dictionary.join(threads, {
|
||||
[threadName] = {
|
||||
name = threadName,
|
||||
stack = stack,
|
||||
}
|
||||
})
|
||||
|
||||
self.threads = threads
|
||||
end
|
||||
|
||||
function BacktraceReport:addStackToMainThread(stack)
|
||||
self:addStackToThread(stack, self.mainThread)
|
||||
end
|
||||
|
||||
function BacktraceReport.fromMessageAndStack(errorMessage, errorStack)
|
||||
local report = BacktraceReport.new()
|
||||
|
||||
report:addAttributes({
|
||||
["error.message"] = errorMessage,
|
||||
})
|
||||
|
||||
local stack, sourceCode = ProcessErrorStack(errorStack)
|
||||
report:addStackToMainThread(stack)
|
||||
report.sourceCode = sourceCode
|
||||
|
||||
return report
|
||||
end
|
||||
|
||||
return BacktraceReport
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
return function()
|
||||
local BacktraceReport = require(script.Parent.BacktraceReport)
|
||||
local CorePackages = game:GetService("CorePackages")
|
||||
|
||||
local tutils = require(CorePackages.Packages.tutils)
|
||||
|
||||
describe(".new", function()
|
||||
it("should return a valid report", function()
|
||||
local report = BacktraceReport.new()
|
||||
local isValid = report:validate()
|
||||
|
||||
expect(isValid).to.equal(true)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe(":validate", function()
|
||||
it("should return false if the report has registers but no arch information, true otherwise", function()
|
||||
local report = BacktraceReport.new()
|
||||
|
||||
report.threads = {
|
||||
default = {
|
||||
stack = {
|
||||
[1] = {
|
||||
registers = {
|
||||
rax = "16045690984833335023",
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
expect(report:validate()).to.equal(false)
|
||||
|
||||
-- invalid arch
|
||||
report.arch = {
|
||||
name = "x64",
|
||||
regsiters = nil,
|
||||
}
|
||||
|
||||
expect(report:validate()).to.equal(false)
|
||||
|
||||
-- correct arch
|
||||
report.arch = {
|
||||
name = "x64",
|
||||
registers = {
|
||||
rax = "u64",
|
||||
},
|
||||
}
|
||||
|
||||
expect(report:validate()).to.equal(true)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("IAnnotations", function()
|
||||
local IAnnotations = BacktraceReport.IAnnotations
|
||||
|
||||
it("should return false if not a table", function()
|
||||
local result = IAnnotations("string")
|
||||
expect(result).to.equal(false)
|
||||
end)
|
||||
|
||||
it("should return false if a non-table value is not string, number, or boolean", function()
|
||||
local result = IAnnotations({
|
||||
Value = function() end,
|
||||
})
|
||||
expect(result).to.equal(false)
|
||||
|
||||
result = IAnnotations({
|
||||
Value = "ha",
|
||||
Value2 = 1,
|
||||
Value3 = false,
|
||||
Recursive = {
|
||||
Value11 = "haha",
|
||||
MoreRecursive = {
|
||||
Value12 = "hahaha",
|
||||
Value22 = function() end,
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(result).to.equal(false)
|
||||
end)
|
||||
|
||||
it("should return true for an empty table", function()
|
||||
expect(IAnnotations({})).to.equal(true)
|
||||
end)
|
||||
|
||||
it("should return true if a non-table value is either string, number, or boolean", function()
|
||||
local result = IAnnotations({
|
||||
Value = "ha",
|
||||
Value2 = 1,
|
||||
Value3 = false,
|
||||
Recursive = {
|
||||
Value11 = "haha",
|
||||
Value21 = 2,
|
||||
Array = {
|
||||
[1] = "hahaha",
|
||||
[2] = 3,
|
||||
[3] = true,
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(result).to.equal(true)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe(":addAttributes", function()
|
||||
it("should correctly add attributes", function()
|
||||
local report = BacktraceReport.new()
|
||||
report:addAttributes({
|
||||
att1 = 1,
|
||||
})
|
||||
expect(tutils.fieldCount(report.attributes)).to.equal(1)
|
||||
expect(report.attributes.att1).to.equal(1)
|
||||
report:addAttributes({
|
||||
att1 = 2,
|
||||
att2 = false,
|
||||
att3 = "test",
|
||||
})
|
||||
expect(tutils.fieldCount(report.attributes)).to.equal(3)
|
||||
expect(report.attributes.att1).to.equal(2)
|
||||
expect(report.attributes.att2).to.equal(false)
|
||||
expect(report.attributes.att3).to.equal("test")
|
||||
end)
|
||||
end)
|
||||
|
||||
describe(":addAnnotations", function()
|
||||
it("should correctly add annotations", function()
|
||||
local environmentVariables = {
|
||||
ENV_VAR_EXAMPLE = "example",
|
||||
}
|
||||
local dependencies = {
|
||||
Roact = {
|
||||
version = "0.2.0",
|
||||
},
|
||||
Otter = {
|
||||
version = "0.1.0",
|
||||
},
|
||||
}
|
||||
|
||||
local report = BacktraceReport.new()
|
||||
|
||||
report:addAnnotations({
|
||||
EnvironmentVariables = environmentVariables,
|
||||
})
|
||||
expect(tutils.fieldCount(report.annotations)).to.equal(1)
|
||||
expect(tutils.deepEqual(report.annotations.EnvironmentVariables, environmentVariables)).to.equal(true)
|
||||
|
||||
report:addAnnotations({
|
||||
SomeProperty = true,
|
||||
Dependencies = dependencies,
|
||||
})
|
||||
expect(tutils.fieldCount(report.annotations)).to.equal(3)
|
||||
expect(tutils.deepEqual(report.annotations.EnvironmentVariables, environmentVariables)).to.equal(true)
|
||||
expect(report.annotations.SomeProperty).to.equal(true)
|
||||
expect(tutils.deepEqual(report.annotations.Dependencies, dependencies)).to.equal(true)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe(":addStackToThread", function()
|
||||
it("should correctly add stack to the thread with the name provided", function()
|
||||
local stack1 = {
|
||||
[1] = {
|
||||
line = "100",
|
||||
funcName = "field testError",
|
||||
sourceCode = "1",
|
||||
}
|
||||
}
|
||||
local stack2 = {
|
||||
[1] = {
|
||||
line = "110",
|
||||
funcName = "field ?",
|
||||
sourceCode = "2",
|
||||
}
|
||||
}
|
||||
|
||||
local report = BacktraceReport.new()
|
||||
|
||||
report:addStackToThread(stack1, "main")
|
||||
expect(tutils.fieldCount(report.threads)).to.equal(1)
|
||||
expect(tutils.deepEqual(report.threads.main.stack, stack1)).to.equal(true)
|
||||
|
||||
report:addStackToThread(stack2, "1")
|
||||
expect(tutils.fieldCount(report.threads)).to.equal(2)
|
||||
expect(tutils.deepEqual(report.threads.main.stack, stack1)).to.equal(true)
|
||||
expect(tutils.deepEqual(report.threads["1"].stack, stack2)).to.equal(true)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe(".fromMessageAndStack", function()
|
||||
it("should return a valid report", function()
|
||||
local report = BacktraceReport.fromMessageAndStack("index nil", "Script 'Workspace.Script', Line 3")
|
||||
local isValid = report:validate()
|
||||
|
||||
expect(isValid).to.equal(true)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
+287
@@ -0,0 +1,287 @@
|
||||
--[[
|
||||
Specialized reporter for sending data to Backtrace.
|
||||
Useful for reporting Lua errors.
|
||||
]]
|
||||
|
||||
local CorePackages = game:GetService("CorePackages")
|
||||
|
||||
local Cryo = require(CorePackages.Cryo)
|
||||
local t = require(CorePackages.Packages.t)
|
||||
|
||||
local BacktraceReport = require(script.Parent.BacktraceReport)
|
||||
local ErrorQueue = require(script.Parent.Parent.ErrorQueue)
|
||||
|
||||
local DEVELOPMENT_IN_STUDIO = game:GetService("RunService"):IsStudio()
|
||||
|
||||
local DEFAULT_LOG_INTERVAL = 60 -- seconds
|
||||
|
||||
local BacktraceReporter = {}
|
||||
BacktraceReporter.__index = BacktraceReporter
|
||||
|
||||
local IBacktraceReporter = t.strictInterface({
|
||||
httpService = t.some(t.instanceOf("HttpService"), t.interface({
|
||||
JSONEncode = t.callback,
|
||||
JSONDecode = t.callback,
|
||||
RequestInternal = t.callback,
|
||||
})),
|
||||
token = t.string,
|
||||
processErrorReportMethod = t.optional(t.callback),
|
||||
queueOptions = t.optional(t.table),
|
||||
generateLogMethod = t.optional(t.callback),
|
||||
logIntervalInSeconds = t.optional(t.numberPositive),
|
||||
})
|
||||
|
||||
function BacktraceReporter.new(arguments)
|
||||
local valid, message = IBacktraceReporter(arguments)
|
||||
local self
|
||||
|
||||
if valid then
|
||||
self = {
|
||||
_isEnabled = true,
|
||||
_httpService = arguments.httpService,
|
||||
_errorQueue = nil,
|
||||
_reportUrl = game:GetFastString("ErrorUploadToBacktraceBaseUrl") .. "token=" .. arguments.token,
|
||||
_processErrorReportMethod = arguments.processErrorReportMethod,
|
||||
|
||||
_sharedAttributes = {},
|
||||
_sharedAnnotations = {},
|
||||
_generateLogMethod = arguments.generateLogMethod,
|
||||
_logIntervalInSeconds = arguments.logIntervalInSeconds or DEFAULT_LOG_INTERVAL,
|
||||
_lastLogTime = 0,
|
||||
}
|
||||
elseif (DEVELOPMENT_IN_STUDIO or _G.__TESTEZ_RUNNING_TEST__) then
|
||||
error("invalid arguments for BacktraceReporter: " .. message)
|
||||
else
|
||||
self = {
|
||||
_isEnabled = false,
|
||||
}
|
||||
end
|
||||
|
||||
setmetatable(self, BacktraceReporter)
|
||||
|
||||
-- Create and start the ErrorQueue for deferred reports.
|
||||
if self._isEnabled then
|
||||
self._errorQueue = ErrorQueue.new(function(...)
|
||||
self:_reportErrorFromErrorQueue(...)
|
||||
end, arguments.queueOptions)
|
||||
|
||||
self._errorQueue:startTimer()
|
||||
end
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
function BacktraceReporter:sendErrorReport(report, log)
|
||||
if not self._isEnabled then
|
||||
return
|
||||
end
|
||||
|
||||
-- Validating the report can be slow;
|
||||
-- And an invalid report might still be able to be processed, sent, and accepted by Backtrace.
|
||||
-- So we don't validate reports in production.
|
||||
if DEVELOPMENT_IN_STUDIO or _G.__TESTEZ_RUNNING_TEST__ then
|
||||
assert(report:validate())
|
||||
end
|
||||
|
||||
local encodeSuccess, jsonData = pcall(function()
|
||||
return self._httpService:JSONEncode(report)
|
||||
end)
|
||||
|
||||
if not encodeSuccess then
|
||||
warn("Cannot convert report to Json")
|
||||
return
|
||||
end
|
||||
|
||||
pcall(function()
|
||||
local httpRequest = self._httpService:RequestInternal({
|
||||
Url = self._reportUrl .. "&format=json",
|
||||
Method = "POST",
|
||||
Headers = {
|
||||
["Content-Type"] = "application/json",
|
||||
},
|
||||
Body = jsonData,
|
||||
})
|
||||
|
||||
httpRequest:Start(function(success, response)
|
||||
-- Be aware that even when a response is 200, the report
|
||||
-- might still be rejected/deleted by Backtrace after it is received.
|
||||
if response.StatusCode == 200 and
|
||||
log ~= nil then
|
||||
|
||||
local decodeSuccesss, decodedBody = pcall(function()
|
||||
return self._httpService:JSONDecode(response.Body)
|
||||
end)
|
||||
|
||||
if decodeSuccesss and decodedBody._rxid ~= nil then
|
||||
self:_sendLogToReport(decodedBody._rxid, log)
|
||||
end
|
||||
end
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
function BacktraceReporter:_sendLogToReport(reportRxid, log)
|
||||
if type(log) ~= "string" or #log == 0 then
|
||||
return
|
||||
end
|
||||
|
||||
pcall(function()
|
||||
local httpRequest = self._httpService:RequestInternal({
|
||||
Url = self._reportUrl .. "&object=" .. reportRxid .. "&attachment_name=log.txt",
|
||||
Method = "POST",
|
||||
Headers = {
|
||||
["Content-Type"] = "text/plain",
|
||||
},
|
||||
Body = log,
|
||||
})
|
||||
|
||||
httpRequest:Start(function(reqSuccess, response)
|
||||
-- We have no use for the result of this request right now.
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
function BacktraceReporter:_generateLog()
|
||||
if self._generateLogMethod ~= nil and
|
||||
tick() - self._lastLogTime > self._logIntervalInSeconds then
|
||||
self._lastLogTime = tick()
|
||||
|
||||
local success, log = pcall(function()
|
||||
return self._generateLogMethod()
|
||||
end)
|
||||
|
||||
if success and type(log) == "string" and #log > 0 then
|
||||
return log
|
||||
end
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
function BacktraceReporter:_generateErrorReport(errorMessage, errorStack, details)
|
||||
local report = BacktraceReport.fromMessageAndStack(errorMessage, errorStack)
|
||||
|
||||
report:addAttributes(self._sharedAttributes)
|
||||
report:addAnnotations(self._sharedAnnotations)
|
||||
|
||||
if type(details) == "string" and details ~= "" then
|
||||
report:addAnnotations({
|
||||
["stackDetails"] = details,
|
||||
})
|
||||
end
|
||||
|
||||
return report
|
||||
end
|
||||
|
||||
-- Immediate reports
|
||||
-- You most likely should not use this. Use reportErrorDeferred instead.
|
||||
function BacktraceReporter:reportErrorImmediately(errorMessage, errorStack, details)
|
||||
if not self._isEnabled then
|
||||
return
|
||||
end
|
||||
|
||||
local newReport = self:_generateErrorReport(errorMessage, errorStack, details)
|
||||
|
||||
if self._processErrorReportMethod ~= nil then
|
||||
newReport = self._processErrorReportMethod(newReport)
|
||||
end
|
||||
|
||||
local log = self:_generateLog()
|
||||
|
||||
self:sendErrorReport(newReport, log)
|
||||
end
|
||||
|
||||
-- Deferred reports using an error queue
|
||||
function BacktraceReporter:reportErrorDeferred(errorMessage, errorStack, details)
|
||||
if not self._isEnabled then
|
||||
return
|
||||
end
|
||||
|
||||
local errorKey = string.format("%s | %s", errorMessage, errorStack)
|
||||
local errorData = {}
|
||||
|
||||
-- If this error is a new one, we want a full report on it.
|
||||
-- Similar errors following this one will be squashed in the queue and share report with this one
|
||||
-- before they're flushed out and reported.
|
||||
if not self._errorQueue:hasError(errorKey) then
|
||||
local newReport = self:_generateErrorReport(errorMessage, errorStack, details)
|
||||
|
||||
if self._processErrorReportMethod ~= nil then
|
||||
newReport = self._processErrorReportMethod(newReport)
|
||||
end
|
||||
|
||||
errorData = {
|
||||
backtraceReport = newReport,
|
||||
log = self:_generateLog(),
|
||||
}
|
||||
end
|
||||
|
||||
self._errorQueue:addError(errorKey, errorData)
|
||||
end
|
||||
|
||||
function BacktraceReporter:_reportErrorFromErrorQueue(errorKey, errorData, errorCount)
|
||||
local errorReport = errorData.backtraceReport
|
||||
local log = errorData.log
|
||||
|
||||
errorReport:addAttributes({
|
||||
ErrorCount = errorCount,
|
||||
})
|
||||
|
||||
self:sendErrorReport(errorReport, log)
|
||||
end
|
||||
|
||||
-- API for updating shared attributes/annotations
|
||||
local IAttributes = BacktraceReport.IAttributes
|
||||
|
||||
function BacktraceReporter:updateSharedAttributes(newAttributes)
|
||||
-- Merge with current one first. This allows usage of Cryo.None.
|
||||
local mergedAttributes = Cryo.Dictionary.join(self._sharedAttributes, newAttributes)
|
||||
|
||||
-- Validate the merged result, and only update if it's valid.
|
||||
local valid, message = IAttributes(mergedAttributes)
|
||||
if not valid then
|
||||
if DEVELOPMENT_IN_STUDIO or _G.__TESTEZ_RUNNING_TEST__ then
|
||||
assert(valid, message)
|
||||
else
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
self._sharedAttributes = mergedAttributes
|
||||
end
|
||||
|
||||
local IAnnotations = BacktraceReport.IAnnotations
|
||||
|
||||
function BacktraceReporter:updateSharedAnnotations(newAnnotations)
|
||||
-- Although annotations can be nested tables, this is not a recursive merge.
|
||||
local mergedAnnotations = Cryo.Dictionary.join(self._sharedAnnotations, newAnnotations)
|
||||
|
||||
local valid, message = IAnnotations(mergedAnnotations)
|
||||
if not valid then
|
||||
if DEVELOPMENT_IN_STUDIO or _G.__TESTEZ_RUNNING_TEST__ then
|
||||
assert(valid, message)
|
||||
else
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
self._sharedAnnotations = mergedAnnotations
|
||||
end
|
||||
|
||||
-- Flush all reports in the queue.
|
||||
function BacktraceReporter:reportAllErrors()
|
||||
if self._errorQueue ~= nil then
|
||||
self._errorQueue:reportAllErrors()
|
||||
end
|
||||
end
|
||||
|
||||
function BacktraceReporter:stop()
|
||||
self._isEnabled = false
|
||||
|
||||
if self._errorQueue ~= nil then
|
||||
self:reportAllErrors()
|
||||
self._errorQueue:stopTimer()
|
||||
end
|
||||
end
|
||||
|
||||
return BacktraceReporter
|
||||
+513
@@ -0,0 +1,513 @@
|
||||
return function()
|
||||
local BacktraceReporter = require(script.Parent.BacktraceReporter)
|
||||
local BacktraceReport = require(script.Parent.BacktraceReport)
|
||||
|
||||
local CorePackages = game:GetService("CorePackages")
|
||||
local Cryo = require(CorePackages.Cryo)
|
||||
local tutils = require(CorePackages.tutils)
|
||||
|
||||
local requestsSent = 0
|
||||
local requestBody = nil
|
||||
|
||||
local mockHttpRequestObj = {}
|
||||
function mockHttpRequestObj:Start(onComplete)
|
||||
requestsSent = requestsSent + 1
|
||||
onComplete(true, {
|
||||
StatusCode = 200,
|
||||
Body = {
|
||||
_rxid = 12345,
|
||||
}
|
||||
})
|
||||
end
|
||||
|
||||
local mockHttpService = {}
|
||||
function mockHttpService:RequestInternal(options)
|
||||
requestBody = options.Body
|
||||
return mockHttpRequestObj
|
||||
end
|
||||
function mockHttpService:JSONEncode(data)
|
||||
return data
|
||||
end
|
||||
function mockHttpService:JSONDecode(data)
|
||||
return data
|
||||
end
|
||||
|
||||
local mockErrorMessage = "index nil"
|
||||
local mockErrorStack = "Script 'Workspace.Script', Line 3"
|
||||
|
||||
describe(".new", function()
|
||||
it("should error if no httpService or token is passed in", function()
|
||||
expect(function()
|
||||
BacktraceReporter.new({})
|
||||
end).to.throw()
|
||||
|
||||
expect(function()
|
||||
BacktraceReporter.new({
|
||||
httpService = mockHttpService,
|
||||
token = nil,
|
||||
})
|
||||
end).to.throw()
|
||||
|
||||
expect(function()
|
||||
local reporter = BacktraceReporter.new({
|
||||
httpService = mockHttpService,
|
||||
token = "",
|
||||
})
|
||||
reporter:stop()
|
||||
end).to.never.throw()
|
||||
end)
|
||||
end)
|
||||
|
||||
describe(":sendErrorReport", function()
|
||||
it("should send error report through provided httpService", function()
|
||||
requestsSent = 0
|
||||
local reporter = BacktraceReporter.new({
|
||||
httpService = mockHttpService,
|
||||
token = "12345",
|
||||
})
|
||||
local report = BacktraceReport.new()
|
||||
|
||||
reporter:sendErrorReport(report)
|
||||
|
||||
expect(requestsSent).to.equal(1)
|
||||
|
||||
reporter:sendErrorReport(report)
|
||||
|
||||
expect(requestsSent).to.equal(2)
|
||||
|
||||
reporter:stop()
|
||||
end)
|
||||
|
||||
it("should assert if the error report is not valid", function()
|
||||
requestsSent = 0
|
||||
local reporter = BacktraceReporter.new({
|
||||
httpService = mockHttpService,
|
||||
token = "12345",
|
||||
})
|
||||
local errorReport = {
|
||||
error = "random",
|
||||
}
|
||||
|
||||
expect(function()
|
||||
reporter:sendErrorReport(errorReport)
|
||||
end).to.throw()
|
||||
|
||||
expect(requestsSent).to.equal(0)
|
||||
|
||||
reporter:stop()
|
||||
end)
|
||||
end)
|
||||
|
||||
describe(":reportErrorImmediately", function()
|
||||
it("should send error report through provided httpService", function()
|
||||
requestsSent = 0
|
||||
|
||||
local reporter = BacktraceReporter.new({
|
||||
httpService = mockHttpService,
|
||||
token = "12345",
|
||||
})
|
||||
|
||||
reporter:reportErrorImmediately(mockErrorMessage, mockErrorStack)
|
||||
|
||||
expect(requestsSent).to.equal(1)
|
||||
|
||||
reporter:reportErrorImmediately(mockErrorMessage, mockErrorStack)
|
||||
|
||||
expect(requestsSent).to.equal(2)
|
||||
|
||||
reporter:stop()
|
||||
end)
|
||||
|
||||
it("should set details in the report if it's not nil", function()
|
||||
requestsSent = 0
|
||||
requestBody = nil
|
||||
|
||||
local reporter = BacktraceReporter.new({
|
||||
httpService = mockHttpService,
|
||||
token = "12345",
|
||||
})
|
||||
|
||||
reporter:reportErrorImmediately(mockErrorMessage, mockErrorStack, "SomeDetails")
|
||||
|
||||
expect(requestsSent).to.equal(1)
|
||||
|
||||
expect(requestBody.annotations.stackDetails).to.equal("SomeDetails")
|
||||
|
||||
reporter:stop()
|
||||
end)
|
||||
end)
|
||||
|
||||
describe(":reportErrorDeferred", function()
|
||||
it("should put error to a queue and send later", function()
|
||||
requestsSent = 0
|
||||
requestBody = nil
|
||||
|
||||
local reporter = BacktraceReporter.new({
|
||||
httpService = mockHttpService,
|
||||
token = "12345",
|
||||
queueOptions = {
|
||||
-- The queue should flush when there are 2 or more than 2 errors.
|
||||
queueErrorLimit = 2,
|
||||
},
|
||||
})
|
||||
|
||||
reporter:reportErrorDeferred(mockErrorMessage, mockErrorStack)
|
||||
|
||||
expect(requestsSent).to.equal(0)
|
||||
|
||||
reporter:reportErrorDeferred(mockErrorMessage, mockErrorStack)
|
||||
|
||||
-- These 2 errors would be squashed together
|
||||
expect(requestsSent).to.equal(1)
|
||||
expect(requestBody.attributes.ErrorCount).to.equal(2)
|
||||
|
||||
reporter:stop()
|
||||
end)
|
||||
|
||||
it("should set details in the report if it's not nil", function()
|
||||
local reporter = BacktraceReporter.new({
|
||||
httpService = mockHttpService,
|
||||
token = "12345",
|
||||
queueOptions = {
|
||||
-- The queue should flush when there are 2 or more than 2 errors.
|
||||
queueErrorLimit = 2,
|
||||
},
|
||||
})
|
||||
|
||||
requestsSent = 0
|
||||
requestBody = nil
|
||||
reporter:reportErrorDeferred(mockErrorMessage, mockErrorStack, "SomeDetails")
|
||||
reporter:reportErrorDeferred(mockErrorMessage, mockErrorStack, "SomeDetails")
|
||||
|
||||
expect(requestsSent).to.equal(1)
|
||||
|
||||
expect(requestBody.annotations.stackDetails).to.equal("SomeDetails")
|
||||
|
||||
reporter:stop()
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("arguments.processErrorReportMethod", function()
|
||||
it("should modify the error reports if passed in - reportErrorImmediately", function()
|
||||
requestsSent = 0
|
||||
requestBody = nil
|
||||
|
||||
local processErrorReport = function(report)
|
||||
report.uuid = "id"
|
||||
report.timestamp = 1
|
||||
report:addAttributes({
|
||||
["Message"] = "test",
|
||||
})
|
||||
return report
|
||||
end
|
||||
|
||||
local reporter = BacktraceReporter.new({
|
||||
httpService = mockHttpService,
|
||||
token = "12345",
|
||||
processErrorReportMethod = processErrorReport,
|
||||
})
|
||||
|
||||
reporter:reportErrorImmediately(mockErrorMessage, mockErrorStack)
|
||||
|
||||
expect(requestsSent).to.equal(1)
|
||||
expect(requestBody.uuid).to.equal("id")
|
||||
expect(requestBody.timestamp).to.equal(1)
|
||||
expect(requestBody.attributes["Message"]).to.equal("test")
|
||||
|
||||
reporter:stop()
|
||||
end)
|
||||
|
||||
it("should modify the error reports if passed in - reportErrorDeferred", function()
|
||||
requestsSent = 0
|
||||
requestBody = nil
|
||||
|
||||
local processErrorReport = function(report)
|
||||
report.uuid = "id"
|
||||
report.timestamp = 1
|
||||
report:addAttributes({
|
||||
["Message"] = "test",
|
||||
})
|
||||
return report
|
||||
end
|
||||
|
||||
local reporter = BacktraceReporter.new({
|
||||
httpService = mockHttpService,
|
||||
token = "12345",
|
||||
processErrorReportMethod = processErrorReport,
|
||||
queueOptions = {
|
||||
-- The queue should flush when there are 1 or more than 1 errors.
|
||||
queueErrorLimit = 1,
|
||||
},
|
||||
})
|
||||
|
||||
reporter:reportErrorDeferred(mockErrorMessage, mockErrorStack)
|
||||
|
||||
expect(requestsSent).to.equal(1)
|
||||
expect(requestBody.uuid).to.equal("id")
|
||||
expect(requestBody.timestamp).to.equal(1)
|
||||
expect(requestBody.attributes["Message"]).to.equal("test")
|
||||
|
||||
reporter:stop()
|
||||
end)
|
||||
end)
|
||||
|
||||
describe(":updateSharedAttributes", function()
|
||||
it("should put the same attributes to all error reports", function()
|
||||
requestsSent = 0
|
||||
requestBody = nil
|
||||
|
||||
local reporter = BacktraceReporter.new({
|
||||
httpService = mockHttpService,
|
||||
token = "12345",
|
||||
})
|
||||
|
||||
reporter:updateSharedAttributes({
|
||||
["Message"] = "test",
|
||||
["Locale"] = "en-us",
|
||||
})
|
||||
|
||||
reporter:reportErrorImmediately(mockErrorMessage, mockErrorStack)
|
||||
|
||||
expect(requestsSent).to.equal(1)
|
||||
expect(requestBody.attributes["Message"]).to.equal("test")
|
||||
expect(requestBody.attributes["Locale"]).to.equal("en-us")
|
||||
|
||||
requestBody = nil
|
||||
reporter:reportErrorImmediately("some other message", mockErrorStack)
|
||||
|
||||
expect(requestsSent).to.equal(2)
|
||||
expect(requestBody.attributes["Message"]).to.equal("test")
|
||||
expect(requestBody.attributes["Locale"]).to.equal("en-us")
|
||||
|
||||
reporter:stop()
|
||||
end)
|
||||
|
||||
it("should merge attributes if called more than once", function()
|
||||
requestsSent = 0
|
||||
requestBody = nil
|
||||
|
||||
local reporter = BacktraceReporter.new({
|
||||
httpService = mockHttpService,
|
||||
token = "12345",
|
||||
})
|
||||
|
||||
reporter:updateSharedAttributes({
|
||||
["Message"] = "test",
|
||||
["Locale"] = "en-us",
|
||||
})
|
||||
|
||||
reporter:reportErrorImmediately(mockErrorMessage, mockErrorStack)
|
||||
|
||||
expect(requestsSent).to.equal(1)
|
||||
expect(requestBody.attributes["Message"]).to.equal("test")
|
||||
expect(requestBody.attributes["Locale"]).to.equal("en-us")
|
||||
|
||||
reporter:updateSharedAttributes({
|
||||
["Message"] = Cryo.None,
|
||||
["Locale"] = "zh-cn",
|
||||
["Theme"] = "light",
|
||||
})
|
||||
|
||||
requestBody = nil
|
||||
reporter:reportErrorImmediately("some other message", mockErrorStack)
|
||||
|
||||
expect(requestsSent).to.equal(2)
|
||||
expect(requestBody.attributes["Message"]).to.equal(nil)
|
||||
expect(requestBody.attributes["Locale"]).to.equal("zh-cn")
|
||||
expect(requestBody.attributes["Theme"]).to.equal("light")
|
||||
|
||||
reporter:stop()
|
||||
end)
|
||||
|
||||
it("should throw if new attributes are ill-formatted", function()
|
||||
requestsSent = 0
|
||||
requestBody = nil
|
||||
|
||||
local reporter = BacktraceReporter.new({
|
||||
httpService = mockHttpService,
|
||||
token = "12345",
|
||||
})
|
||||
|
||||
expect(function()
|
||||
reporter:updateSharedAttributes({
|
||||
["Message"] = Cryo.None,
|
||||
["Locale"] = "zh-cn",
|
||||
["Theme"] = function() end, -- callbacks are not allowed
|
||||
})
|
||||
end).to.throw()
|
||||
|
||||
reporter:stop()
|
||||
end)
|
||||
end)
|
||||
|
||||
describe(":updateSharedAnnotations", function()
|
||||
it("should put the same annotations to all error reports", function()
|
||||
requestsSent = 0
|
||||
requestBody = nil
|
||||
|
||||
local reporter = BacktraceReporter.new({
|
||||
httpService = mockHttpService,
|
||||
token = "12345",
|
||||
})
|
||||
|
||||
local annotations = {
|
||||
["Message"] = "test",
|
||||
["AppInfo"] = {
|
||||
["Locale"] = "en-us",
|
||||
["Theme"] = "light",
|
||||
},
|
||||
}
|
||||
reporter:updateSharedAnnotations(annotations)
|
||||
|
||||
reporter:reportErrorImmediately(mockErrorMessage, mockErrorStack)
|
||||
|
||||
expect(requestsSent).to.equal(1)
|
||||
expect(tutils.deepEqual(annotations, requestBody.annotations, true)).to.equal(true)
|
||||
|
||||
requestBody = nil
|
||||
reporter:reportErrorImmediately("some other message", mockErrorStack)
|
||||
|
||||
expect(requestsSent).to.equal(2)
|
||||
expect(tutils.deepEqual(annotations, requestBody.annotations, true)).to.equal(true)
|
||||
|
||||
reporter:stop()
|
||||
end)
|
||||
|
||||
it("should merge annotations if called more than once", function()
|
||||
requestsSent = 0
|
||||
requestBody = nil
|
||||
|
||||
local reporter = BacktraceReporter.new({
|
||||
httpService = mockHttpService,
|
||||
token = "12345",
|
||||
})
|
||||
|
||||
local annotations = {
|
||||
["Message"] = "test",
|
||||
["AppInfo"] = {
|
||||
["Locale"] = "en-us",
|
||||
["Theme"] = "light",
|
||||
},
|
||||
["AppVersion"] = "1.0",
|
||||
}
|
||||
reporter:updateSharedAnnotations(annotations)
|
||||
|
||||
reporter:reportErrorImmediately(mockErrorMessage, mockErrorStack)
|
||||
|
||||
expect(requestsSent).to.equal(1)
|
||||
expect(tutils.deepEqual(annotations, requestBody.annotations, true)).to.equal(true)
|
||||
|
||||
reporter:updateSharedAnnotations({
|
||||
["Message"] = Cryo.None,
|
||||
["AppInfo"] = {
|
||||
["Theme"] = "dark",
|
||||
},
|
||||
})
|
||||
|
||||
requestBody = nil
|
||||
reporter:reportErrorImmediately("some other message", mockErrorStack)
|
||||
|
||||
local expectedAnnotations = {
|
||||
["AppInfo"] = {
|
||||
["Theme"] = "dark",
|
||||
},
|
||||
["AppVersion"] = "1.0",
|
||||
}
|
||||
expect(requestsSent).to.equal(2)
|
||||
expect(tutils.deepEqual(expectedAnnotations, requestBody.annotations, true)).to.equal(true)
|
||||
|
||||
reporter:stop()
|
||||
end)
|
||||
|
||||
it("should throw if new annotations are ill-formatted", function()
|
||||
requestsSent = 0
|
||||
requestBody = nil
|
||||
|
||||
local reporter = BacktraceReporter.new({
|
||||
httpService = mockHttpService,
|
||||
token = "12345",
|
||||
})
|
||||
|
||||
expect(function()
|
||||
reporter:updateSharedAnnotations({
|
||||
["Message"] = Cryo.None,
|
||||
["AppInfo"] = {
|
||||
["Locale"] = "en-us",
|
||||
["Theme"] = function() end, -- callbacks are not allowed
|
||||
},
|
||||
})
|
||||
end).to.throw()
|
||||
|
||||
reporter:stop()
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("Logging", function()
|
||||
it("should send logs if provided generateLogMethod and error report is successful", function()
|
||||
requestsSent = 0
|
||||
requestBody = nil
|
||||
|
||||
local logText = "test log text"
|
||||
|
||||
local reporter = BacktraceReporter.new({
|
||||
httpService = mockHttpService,
|
||||
token = "12345",
|
||||
generateLogMethod = function()
|
||||
return logText
|
||||
end,
|
||||
})
|
||||
|
||||
reporter:reportErrorImmediately(mockErrorMessage, mockErrorStack)
|
||||
|
||||
expect(requestsSent).to.equal(2) -- one for error, one for log
|
||||
expect(requestBody).to.equal(logText)
|
||||
|
||||
reporter:stop()
|
||||
end)
|
||||
|
||||
it("should not send log if generateLogMethod did not return a string", function()
|
||||
requestsSent = 0
|
||||
requestBody = nil
|
||||
|
||||
local reporter = BacktraceReporter.new({
|
||||
httpService = mockHttpService,
|
||||
token = "12345",
|
||||
generateLogMethod = function()
|
||||
return 123
|
||||
end,
|
||||
})
|
||||
|
||||
reporter:reportErrorImmediately(mockErrorMessage, mockErrorStack)
|
||||
|
||||
expect(requestsSent).to.equal(1)
|
||||
|
||||
reporter:stop()
|
||||
end)
|
||||
|
||||
it("should not send more than 1 log in logIntervalInSeconds provided", function()
|
||||
requestsSent = 0
|
||||
requestBody = nil
|
||||
|
||||
local logText = "test log text"
|
||||
|
||||
local reporter = BacktraceReporter.new({
|
||||
httpService = mockHttpService,
|
||||
token = "12345",
|
||||
generateLogMethod = function()
|
||||
return logText
|
||||
end,
|
||||
logIntervalInSeconds = 2,
|
||||
})
|
||||
|
||||
reporter:reportErrorImmediately(mockErrorMessage, mockErrorStack)
|
||||
|
||||
expect(requestsSent).to.equal(2) -- one for error, one for log
|
||||
expect(requestBody).to.equal(logText)
|
||||
|
||||
reporter:reportErrorImmediately(mockErrorMessage, mockErrorStack)
|
||||
expect(requestsSent).to.equal(3) -- only one more, the error report
|
||||
|
||||
reporter:stop()
|
||||
end)
|
||||
end)
|
||||
end
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
--[[
|
||||
Module for splitting the errorStack string to a list.
|
||||
|
||||
Currently works with:
|
||||
Pattern of error from ScriptContext.Error:
|
||||
see ScriptContext.extractCallStack
|
||||
|
||||
Pattern of error from ScriptContext.Error: (luau)
|
||||
see ScriptContext.extractCallStack
|
||||
|
||||
Pattern of error from debug.traceback:
|
||||
see ScriptContext.printCallStack
|
||||
|
||||
Pattern of error from debug.traceback: (luau)
|
||||
see luau_backtrace
|
||||
]]
|
||||
|
||||
local function splitStringWithMarks(string, matches)
|
||||
if type(string) ~= "string" or
|
||||
type(matches) ~= "table" then
|
||||
return string, ""
|
||||
end
|
||||
|
||||
for _, match in ipairs(matches) do
|
||||
local start, stop = string.find(string, match, nil, true)
|
||||
|
||||
if start ~= nil then
|
||||
local first = string.sub(string, 1, start - 1)
|
||||
local rest = string.sub(string, stop + 1)
|
||||
return first, rest
|
||||
end
|
||||
end
|
||||
|
||||
return string, ""
|
||||
end
|
||||
|
||||
local function findFileNameFromPath(pathStr)
|
||||
if type(pathStr) ~= "string" then
|
||||
return ""
|
||||
end
|
||||
|
||||
return string.match(pathStr, "([^.]*)$")
|
||||
end
|
||||
|
||||
local function ProcessErrorStack(errorStack)
|
||||
local stack = {}
|
||||
local sourceCodeDict = {}
|
||||
local numOfSourceCode = 0
|
||||
|
||||
if type(errorStack) ~= "string" then
|
||||
return stack, sourceCodeDict
|
||||
end
|
||||
|
||||
for line in errorStack:gmatch("[^\r\n]+") do
|
||||
local newLine
|
||||
local source
|
||||
local funcName
|
||||
local lineNumber
|
||||
|
||||
source, newLine = splitStringWithMarks(line, {", line ", ", Line ", ":"})
|
||||
lineNumber, funcName = splitStringWithMarks(newLine, {" - "})
|
||||
|
||||
if lineNumber ~= "" and source ~= "" then
|
||||
-- Convert "Script 'filePath'" to filePath
|
||||
local _, _, matchedSource = string.find(source, "Script '(.*)'")
|
||||
if matchedSource ~= nil then
|
||||
source = matchedSource
|
||||
end
|
||||
|
||||
local index = sourceCodeDict[source]
|
||||
if index == nil then
|
||||
numOfSourceCode = numOfSourceCode + 1
|
||||
index = numOfSourceCode
|
||||
sourceCodeDict[source] = index
|
||||
end
|
||||
|
||||
-- If no funcName is provided, Backtrace has difficulty differentiating the error
|
||||
-- stacks apart. So we want to have something.
|
||||
if funcName == "" then
|
||||
funcName = findFileNameFromPath(source)
|
||||
end
|
||||
|
||||
table.insert(stack, {
|
||||
line = lineNumber,
|
||||
funcName = funcName,
|
||||
sourceCode = tostring(index),
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
local sourceCodeOutput = {}
|
||||
for path, index in pairs(sourceCodeDict) do
|
||||
sourceCodeOutput[tostring(index)] = {
|
||||
path = path,
|
||||
}
|
||||
end
|
||||
|
||||
return stack, sourceCodeOutput
|
||||
end
|
||||
|
||||
return ProcessErrorStack
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
return function()
|
||||
local ProcessErrorStack = require(script.Parent.ProcessErrorStack)
|
||||
local CorePackages = game:GetService("CorePackages")
|
||||
|
||||
local tutils = require(CorePackages.Packages.tutils)
|
||||
|
||||
local testCasesNormal = {
|
||||
ScriptContextError = {
|
||||
error = "CoreGui.RobloxGui.Modules.LuaApp.Components.Home.HomePageWithAvatarViewportFrame, line 98 - field testError\nCoreGui.RobloxGui.Modules.LuaApp.Components.Home.HomePageWithAvatarViewportFrame, line 111 - field ?\nCorePackages.Packages._Index.roact.roact.SingleEventManager, line 83",
|
||||
expectedOutput = {
|
||||
stack = {
|
||||
[1] = {
|
||||
line = "98",
|
||||
funcName = "field testError",
|
||||
sourceCode = "1",
|
||||
},
|
||||
[2] = {
|
||||
line = "111",
|
||||
funcName = "field ?",
|
||||
sourceCode = "1",
|
||||
},
|
||||
[3] = {
|
||||
line = "83",
|
||||
funcName = "SingleEventManager",
|
||||
sourceCode = "2",
|
||||
},
|
||||
},
|
||||
sourceCodeOutput = {
|
||||
["1"] = {
|
||||
path = "CoreGui.RobloxGui.Modules.LuaApp.Components.Home.HomePageWithAvatarViewportFrame",
|
||||
},
|
||||
["2"] = {
|
||||
path = "CorePackages.Packages._Index.roact.roact.SingleEventManager",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
ScriptContextErrorLuau = {
|
||||
error = "CoreGui.RobloxGui.Modules.LuaApp.Components.Home.HomePageWithAvatarViewportFrame, line 98\nCoreGui.RobloxGui.Modules.LuaApp.Components.Home.HomePageWithAvatarViewportFrame, line 111\nCorePackages.Packages._Index.roact.roact.SingleEventManager, line 83",
|
||||
expectedOutput = {
|
||||
stack = {
|
||||
[1] = {
|
||||
line = "98",
|
||||
funcName = "HomePageWithAvatarViewportFrame",
|
||||
sourceCode = "1",
|
||||
},
|
||||
[2] = {
|
||||
line = "111",
|
||||
funcName = "HomePageWithAvatarViewportFrame",
|
||||
sourceCode = "1",
|
||||
},
|
||||
[3] = {
|
||||
line = "83",
|
||||
funcName = "SingleEventManager",
|
||||
sourceCode = "2",
|
||||
},
|
||||
},
|
||||
sourceCodeOutput = {
|
||||
["1"] = {
|
||||
path = "CoreGui.RobloxGui.Modules.LuaApp.Components.Home.HomePageWithAvatarViewportFrame",
|
||||
},
|
||||
["2"] = {
|
||||
path = "CorePackages.Packages._Index.roact.roact.SingleEventManager",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
DebugTraceback = {
|
||||
error = "Stack Begin\nScript 'CoreGui.RobloxGui.Modules.LuaApp.Components.Home.HomePageWithAvatarViewportFrame', Line 100 - field testError\nScript 'CoreGui.RobloxGui.Modules.LuaApp.Components.Home.HomePageWithAvatarViewportFrame', Line 111 - field ?\nScript 'CorePackages.Packages._Index.roact.roact.SingleEventManager', Line 83\nStack End",
|
||||
expectedOutput = {
|
||||
stack = {
|
||||
[1] = {
|
||||
line = "100",
|
||||
funcName = "field testError",
|
||||
sourceCode = "1",
|
||||
},
|
||||
[2] = {
|
||||
line = "111",
|
||||
funcName = "field ?",
|
||||
sourceCode = "1",
|
||||
},
|
||||
[3] = {
|
||||
line = "83",
|
||||
funcName = "SingleEventManager",
|
||||
sourceCode = "2",
|
||||
},
|
||||
},
|
||||
sourceCodeOutput = {
|
||||
["1"] = {
|
||||
path = "CoreGui.RobloxGui.Modules.LuaApp.Components.Home.HomePageWithAvatarViewportFrame",
|
||||
},
|
||||
["2"] = {
|
||||
path = "CorePackages.Packages._Index.roact.roact.SingleEventManager",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
DebugTracebackLuau = {
|
||||
error = "CoreGui.RobloxGui.Modules.LuaApp.Components.Home.HomePageWithAvatarViewportFrame:100\nCoreGui.RobloxGui.Modules.LuaApp.Components.Home.HomePageWithAvatarViewportFrame:111\nCorePackages.Packages._Index.roact.roact.SingleEventManager:83",
|
||||
expectedOutput = {
|
||||
stack = {
|
||||
[1] = {
|
||||
line = "100",
|
||||
funcName = "HomePageWithAvatarViewportFrame",
|
||||
sourceCode = "1",
|
||||
},
|
||||
[2] = {
|
||||
line = "111",
|
||||
funcName = "HomePageWithAvatarViewportFrame",
|
||||
sourceCode = "1",
|
||||
},
|
||||
[3] = {
|
||||
line = "83",
|
||||
funcName = "SingleEventManager",
|
||||
sourceCode = "2",
|
||||
},
|
||||
},
|
||||
sourceCodeOutput = {
|
||||
["1"] = {
|
||||
path = "CoreGui.RobloxGui.Modules.LuaApp.Components.Home.HomePageWithAvatarViewportFrame",
|
||||
},
|
||||
["2"] = {
|
||||
path = "CorePackages.Packages._Index.roact.roact.SingleEventManager",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
OneLineError = {
|
||||
error = "Script 'Workspace.Script', Line 3",
|
||||
expectedOutput = {
|
||||
stack = {
|
||||
[1] = {
|
||||
line = "3",
|
||||
funcName = "Script",
|
||||
sourceCode = "1",
|
||||
},
|
||||
},
|
||||
sourceCodeOutput = {
|
||||
["1"] = {
|
||||
path = "Workspace.Script",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
PathWithNumbers = {
|
||||
error = "Script 'Workspace1.Script2', Line 3",
|
||||
expectedOutput = {
|
||||
stack = {
|
||||
[1] = {
|
||||
line = "3",
|
||||
funcName = "Script2",
|
||||
sourceCode = "1",
|
||||
},
|
||||
},
|
||||
sourceCodeOutput = {
|
||||
["1"] = {
|
||||
path = "Workspace1.Script2",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
it("should convert the error strings to the correct format", function()
|
||||
for key, testCase in pairs(testCasesNormal) do
|
||||
local stack, sourceCodeOutput = ProcessErrorStack(testCase.error)
|
||||
|
||||
expect(tutils.deepEqual(testCase.expectedOutput, {
|
||||
stack = stack,
|
||||
sourceCodeOutput = sourceCodeOutput,
|
||||
}, true)).to.equal(true)
|
||||
end
|
||||
end)
|
||||
|
||||
local testCasesOther = {
|
||||
{},
|
||||
123,
|
||||
function() error("test") end,
|
||||
"",
|
||||
" - ",
|
||||
", line ",
|
||||
", line - ",
|
||||
":",
|
||||
" - , line ",
|
||||
}
|
||||
|
||||
it("should return empty results with inputs that are not valid stack trace", function()
|
||||
for _, testCase in ipairs(testCasesOther) do
|
||||
local stack, sourceCodeOutput = ProcessErrorStack(testCase)
|
||||
|
||||
expect(tutils.shallowEqual(stack, {})).to.equal(true)
|
||||
expect(tutils.shallowEqual(sourceCodeOutput, {})).to.equal(true)
|
||||
end
|
||||
end)
|
||||
|
||||
it("should return empty results with nil input", function()
|
||||
local stack, sourceCodeOutput = ProcessErrorStack(nil)
|
||||
|
||||
expect(tutils.shallowEqual(stack, {})).to.equal(true)
|
||||
expect(tutils.shallowEqual(sourceCodeOutput, {})).to.equal(true)
|
||||
end)
|
||||
end
|
||||
@@ -0,0 +1,129 @@
|
||||
local CorePackages = game:GetService("CorePackages")
|
||||
local RunService = game:GetService("RunService")
|
||||
|
||||
local Cryo = require(CorePackages.Packages.Cryo)
|
||||
local t = require(CorePackages.Packages.t)
|
||||
|
||||
-- Time limit is in seconds.
|
||||
local DEFAULT_QUEUE_TIME_LIMIT = 30
|
||||
local DEFAULT_QUEUE_ERROR_LIMIT = 30
|
||||
local DEFAULT_QUEUE_KEY_LIMIT = 10
|
||||
|
||||
local ErrorQueue = {}
|
||||
ErrorQueue.__index = ErrorQueue
|
||||
|
||||
local IErrorQueue = t.tuple(
|
||||
t.callback,
|
||||
t.optional(t.strictInterface({
|
||||
queueTimeLimit = t.optional(t.numberPositive),
|
||||
queueErrorLimit = t.optional(t.numberPositive),
|
||||
queueKeyLimit = t.optional(t.numberPositive),
|
||||
}))
|
||||
)
|
||||
|
||||
function ErrorQueue.new(reportMethod, options)
|
||||
assert(IErrorQueue(reportMethod, options))
|
||||
|
||||
options = options or {}
|
||||
|
||||
local self = {
|
||||
_reportMethod = reportMethod,
|
||||
|
||||
-- config
|
||||
_queueTimeLimit = options.queueTimeLimit or DEFAULT_QUEUE_TIME_LIMIT,
|
||||
_queueErrorLimit = options.queueErrorLimit or DEFAULT_QUEUE_ERROR_LIMIT,
|
||||
_queueKeyLimit = options.queueKeyLimit or DEFAULT_QUEUE_KEY_LIMIT,
|
||||
|
||||
_errors = {},
|
||||
_totalErrorCount = 0,
|
||||
_totalKeyCount = 0,
|
||||
|
||||
_runningTime = 0,
|
||||
_renderSteppedConnection = nil,
|
||||
}
|
||||
|
||||
setmetatable(self, ErrorQueue)
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
function ErrorQueue:hasError(errorKey)
|
||||
if type(errorKey) ~= "string" or errorKey == "" then
|
||||
return false
|
||||
else
|
||||
return self._errors[errorKey] ~= nil
|
||||
end
|
||||
end
|
||||
|
||||
function ErrorQueue:addError(errorKey, errorData)
|
||||
if type(errorKey) ~= "string" or errorKey == "" then
|
||||
return
|
||||
end
|
||||
|
||||
if not self._errors[errorKey] then
|
||||
-- Errors with the same key will be sent together as 1 error, with a count parameter.
|
||||
-- We only keep the data from the oldest error with this key in the queue.
|
||||
self._errors[errorKey] = {
|
||||
data = errorData,
|
||||
count = 1,
|
||||
}
|
||||
self._totalKeyCount = self._totalKeyCount + 1
|
||||
else
|
||||
self._errors[errorKey].count = self._errors[errorKey].count + 1
|
||||
end
|
||||
|
||||
self._totalErrorCount = self._totalErrorCount + 1
|
||||
|
||||
if self:isReadyToReport() then
|
||||
self:reportAllErrors()
|
||||
end
|
||||
end
|
||||
|
||||
function ErrorQueue:isReadyToReport()
|
||||
return self._totalKeyCount >= self._queueKeyLimit or
|
||||
self._totalErrorCount >= self._queueErrorLimit or
|
||||
(self._totalErrorCount > 0 and self._runningTime >= self._queueTimeLimit)
|
||||
end
|
||||
|
||||
function ErrorQueue:reportAllErrors()
|
||||
-- copy the error queue and instantly clear it out
|
||||
local errors = Cryo.Dictionary.join(self._errors, {})
|
||||
|
||||
self._errors = {}
|
||||
self._totalErrorCount = 0
|
||||
self._totalKeyCount = 0
|
||||
self._runningTime = 0
|
||||
|
||||
-- report the errors
|
||||
for errorKey, errData in pairs(errors) do
|
||||
self._reportMethod(errorKey, errData.data, errData.count)
|
||||
end
|
||||
end
|
||||
|
||||
function ErrorQueue:_onRenderStep(dt)
|
||||
self._runningTime = self._runningTime + dt
|
||||
|
||||
if self:isReadyToReport() then
|
||||
self:reportAllErrors()
|
||||
end
|
||||
end
|
||||
|
||||
function ErrorQueue:startTimer()
|
||||
if self._renderSteppedConnection == nil then
|
||||
self._runningTime = 0
|
||||
|
||||
self._renderSteppedConnection = RunService.renderStepped:Connect(function(dt)
|
||||
self:_onRenderStep(dt)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
function ErrorQueue:stopTimer()
|
||||
if self._renderSteppedConnection ~= nil then
|
||||
self._renderSteppedConnection:Disconnect()
|
||||
self._runningTime = 0
|
||||
self._renderSteppedConnection = nil
|
||||
end
|
||||
end
|
||||
|
||||
return ErrorQueue
|
||||
@@ -0,0 +1,299 @@
|
||||
return function()
|
||||
local ErrorQueue = require(script.Parent.ErrorQueue)
|
||||
local CorePackages = game:GetService("CorePackages")
|
||||
|
||||
local tutils = require(CorePackages.Packages.tutils)
|
||||
|
||||
local errorsToAdd = {
|
||||
[1] = {
|
||||
key = "test",
|
||||
value = 2.33,
|
||||
},
|
||||
[2] = {
|
||||
key = "test",
|
||||
value = 2,
|
||||
},
|
||||
[3] = {
|
||||
key = "test",
|
||||
value = 3.14,
|
||||
},
|
||||
[4] = {
|
||||
key = "test2",
|
||||
value = 123,
|
||||
},
|
||||
[5] = {
|
||||
key = "test2",
|
||||
value = 456,
|
||||
},
|
||||
[6] = {
|
||||
key = "test3",
|
||||
value = "something",
|
||||
},
|
||||
}
|
||||
|
||||
describe("queueErrorLimit", function()
|
||||
it("should report errors correctly when the total error limit is reached", function()
|
||||
local expectedErrorsReported = {
|
||||
["test"] = {
|
||||
data = 2.33,
|
||||
count = 3,
|
||||
},
|
||||
["test2"] = {
|
||||
data = 123,
|
||||
count = 2,
|
||||
},
|
||||
["test3"] = {
|
||||
data = "something",
|
||||
count = 1,
|
||||
},
|
||||
}
|
||||
|
||||
local errorsReported = {}
|
||||
local reportCount = 0
|
||||
local reportMethod = function(errorKey, errorData, errorCount)
|
||||
reportCount = reportCount + 1
|
||||
errorsReported[errorKey] = {
|
||||
data = errorData,
|
||||
count = errorCount,
|
||||
}
|
||||
end
|
||||
|
||||
local errorQueue = ErrorQueue.new(reportMethod, {
|
||||
queueErrorLimit = 6,
|
||||
})
|
||||
|
||||
for _, error in ipairs(errorsToAdd) do
|
||||
errorQueue:addError(error.key, error.value)
|
||||
end
|
||||
|
||||
expect(reportCount).to.equal(3)
|
||||
for errorKey, expectedError in pairs(expectedErrorsReported) do
|
||||
expect(tutils.deepEqual(expectedError, errorsReported[errorKey])).to.equal(true)
|
||||
end
|
||||
end)
|
||||
|
||||
it("should not report errors before the total error limit is reached", function()
|
||||
local errorsReported = {}
|
||||
local reportCount = 0
|
||||
local reportMethod = function(errorKey, errorData, errorCount)
|
||||
reportCount = reportCount + 1
|
||||
errorsReported[errorKey] = {
|
||||
data = errorData,
|
||||
count = errorCount,
|
||||
}
|
||||
end
|
||||
|
||||
local errorQueue = ErrorQueue.new(reportMethod, {
|
||||
queueErrorLimit = 6,
|
||||
})
|
||||
|
||||
for index = 1, 5 do
|
||||
local error = errorsToAdd[index]
|
||||
errorQueue:addError(error.key, error.value)
|
||||
end
|
||||
|
||||
expect(reportCount).to.equal(0)
|
||||
expect(tutils.deepEqual(errorsReported, {})).to.equal(true)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("queueKeyLimit", function()
|
||||
it("should report errors correctly when the total key limit is reached", function()
|
||||
local expectedErrorsReported = {
|
||||
["test"] = {
|
||||
data = 2.33,
|
||||
count = 3,
|
||||
},
|
||||
["test2"] = {
|
||||
data = 123,
|
||||
count = 2,
|
||||
},
|
||||
["test3"] = {
|
||||
data = "something",
|
||||
count = 1,
|
||||
},
|
||||
}
|
||||
|
||||
local errorsReported = {}
|
||||
local reportCount = 0
|
||||
local reportMethod = function(errorKey, errorData, errorCount)
|
||||
reportCount = reportCount + 1
|
||||
errorsReported[errorKey] = {
|
||||
data = errorData,
|
||||
count = errorCount,
|
||||
}
|
||||
end
|
||||
|
||||
local errorQueue = ErrorQueue.new(reportMethod, {
|
||||
queueKeyLimit = 3,
|
||||
queueErrorLimit = 100,
|
||||
})
|
||||
|
||||
for _, error in ipairs(errorsToAdd) do
|
||||
errorQueue:addError(error.key, error.value)
|
||||
end
|
||||
|
||||
expect(reportCount).to.equal(3)
|
||||
for errorKey, expectedError in pairs(expectedErrorsReported) do
|
||||
expect(tutils.deepEqual(expectedError, errorsReported[errorKey])).to.equal(true)
|
||||
end
|
||||
end)
|
||||
|
||||
it("should not report errors before the total key limit is reached", function()
|
||||
local errorsReported = {}
|
||||
local reportCount = 0
|
||||
local reportMethod = function(errorKey, errorData, errorCount)
|
||||
reportCount = reportCount + 1
|
||||
errorsReported[errorKey] = {
|
||||
data = errorData,
|
||||
count = errorCount,
|
||||
}
|
||||
end
|
||||
|
||||
local errorQueue = ErrorQueue.new(reportMethod, {
|
||||
queueKeyLimit = 4,
|
||||
queueErrorLimit = 100,
|
||||
})
|
||||
|
||||
for _, error in ipairs(errorsToAdd) do
|
||||
errorQueue:addError(error.key, error.value)
|
||||
end
|
||||
|
||||
expect(reportCount).to.equal(0)
|
||||
expect(tutils.deepEqual(errorsReported, {})).to.equal(true)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("queueTimeLimit", function()
|
||||
it("should report errors correctly when the total time limit is reached", function()
|
||||
local expectedErrorsReported = {
|
||||
["test"] = {
|
||||
data = 2.33,
|
||||
count = 3,
|
||||
},
|
||||
["test2"] = {
|
||||
data = 123,
|
||||
count = 2,
|
||||
},
|
||||
["test3"] = {
|
||||
data = "something",
|
||||
count = 1,
|
||||
},
|
||||
}
|
||||
|
||||
local errorsReported = {}
|
||||
local reportCount = 0
|
||||
local reportMethod = function(errorKey, errorData, errorCount)
|
||||
reportCount = reportCount + 1
|
||||
errorsReported[errorKey] = {
|
||||
data = errorData,
|
||||
count = errorCount,
|
||||
}
|
||||
end
|
||||
|
||||
local errorQueue = ErrorQueue.new(reportMethod, {
|
||||
queueTimeLimit = 0.5,
|
||||
queueErrorLimit = 100,
|
||||
queueKeyLimit = 100,
|
||||
})
|
||||
|
||||
errorQueue:startTimer()
|
||||
|
||||
for _, error in ipairs(errorsToAdd) do
|
||||
errorQueue:addError(error.key, error.value)
|
||||
end
|
||||
|
||||
errorQueue:_onRenderStep(0.5)
|
||||
|
||||
expect(reportCount).to.equal(3)
|
||||
for errorKey, expectedError in pairs(expectedErrorsReported) do
|
||||
expect(tutils.deepEqual(expectedError, errorsReported[errorKey])).to.equal(true)
|
||||
end
|
||||
|
||||
errorQueue:stopTimer()
|
||||
end)
|
||||
|
||||
it("should not report errors before the total time limit is reached", function()
|
||||
local errorsReported = {}
|
||||
local reportCount = 0
|
||||
local reportMethod = function(errorKey, errorData, errorCount)
|
||||
reportCount = reportCount + 1
|
||||
errorsReported[errorKey] = {
|
||||
data = errorData,
|
||||
count = errorCount,
|
||||
}
|
||||
end
|
||||
|
||||
local errorQueue = ErrorQueue.new(reportMethod, {
|
||||
queueTimeLimit = 2,
|
||||
queueErrorLimit = 100,
|
||||
queueKeyLimit = 100,
|
||||
})
|
||||
|
||||
errorQueue:startTimer()
|
||||
|
||||
for _, error in ipairs(errorsToAdd) do
|
||||
errorQueue:addError(error.key, error.value)
|
||||
end
|
||||
|
||||
errorQueue:_onRenderStep(1)
|
||||
|
||||
expect(reportCount).to.equal(0)
|
||||
expect(tutils.deepEqual(errorsReported, {})).to.equal(true)
|
||||
|
||||
errorQueue:stopTimer()
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("reportAllErrors", function()
|
||||
it("should report all errors correctly when called", function()
|
||||
local expectedErrorsReported = {
|
||||
["test"] = {
|
||||
data = 2.33,
|
||||
count = 3,
|
||||
},
|
||||
["test2"] = {
|
||||
data = 123,
|
||||
count = 2,
|
||||
},
|
||||
["test3"] = {
|
||||
data = "something",
|
||||
count = 1,
|
||||
},
|
||||
}
|
||||
|
||||
local errorsReported = {}
|
||||
local reportCount = 0
|
||||
local reportMethod = function(errorKey, errorData, errorCount)
|
||||
reportCount = reportCount + 1
|
||||
errorsReported[errorKey] = {
|
||||
data = errorData,
|
||||
count = errorCount,
|
||||
}
|
||||
end
|
||||
|
||||
local errorQueue = ErrorQueue.new(reportMethod, {
|
||||
queueTimeLimit = 100,
|
||||
queueErrorLimit = 100,
|
||||
queueKeyLimit = 100,
|
||||
})
|
||||
|
||||
errorQueue:startTimer()
|
||||
|
||||
for _, error in ipairs(errorsToAdd) do
|
||||
errorQueue:addError(error.key, error.value)
|
||||
end
|
||||
|
||||
expect(reportCount).to.equal(0)
|
||||
|
||||
errorQueue:reportAllErrors()
|
||||
|
||||
expect(reportCount).to.equal(3)
|
||||
for errorKey, expectedError in pairs(expectedErrorsReported) do
|
||||
expect(tutils.deepEqual(expectedError, errorsReported[errorKey])).to.equal(true)
|
||||
end
|
||||
|
||||
errorQueue:stopTimer()
|
||||
end)
|
||||
end)
|
||||
end
|
||||
Reference in New Issue
Block a user