This commit is contained in:
lx
2026-07-06 14:01:11 -04:00
commit c4f97d729d
16468 changed files with 935321 additions and 0 deletions
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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